From 63673b15e5ad00bf80031f33aa9a2f4f473f532d Mon Sep 17 00:00:00 2001 From: Miguel Rincon Date: Thu, 16 Oct 2025 14:46:13 +0200 Subject: [PATCH 1/5] Adds `simple-copy-button` component to fix copying in Safari Adds yet another component to render a button that programmatically copies text to the clipboard. It should fix problems when copying the Runner installation CLI instructions in Safari. Changelog: fixed --- .../instructions/runner_cli_instructions.vue | 19 +- .../components/simple_copy_button.vue | 184 ++++++++++++++++++ locale/gitlab.pot | 3 + 3 files changed, 195 insertions(+), 11 deletions(-) create mode 100644 app/assets/javascripts/vue_shared/components/simple_copy_button.vue diff --git a/app/assets/javascripts/ci/runner/components/registration/runner_instructions/instructions/runner_cli_instructions.vue b/app/assets/javascripts/ci/runner/components/registration/runner_instructions/instructions/runner_cli_instructions.vue index 37e780a6c948a4..b74df2ba02d7ec 100644 --- a/app/assets/javascripts/ci/runner/components/registration/runner_instructions/instructions/runner_cli_instructions.vue +++ b/app/assets/javascripts/ci/runner/components/registration/runner_instructions/instructions/runner_cli_instructions.vue @@ -1,7 +1,7 @@ @@ -128,11 +127,10 @@ export default {
{{
           instructions.installInstructions
         }}
- @@ -141,11 +139,10 @@ export default {
{{
           registerInstructionsWithToken
         }}
- diff --git a/app/assets/javascripts/vue_shared/components/simple_copy_button.vue b/app/assets/javascripts/vue_shared/components/simple_copy_button.vue new file mode 100644 index 00000000000000..320517d1a4f87b --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/simple_copy_button.vue @@ -0,0 +1,184 @@ + + diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 3cf0fd899af590..43f01b4bdb5f25 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -19409,6 +19409,9 @@ msgstr "" msgid "Copied reference." msgstr "" +msgid "Copied to clipboard." +msgstr "" + msgid "Copy" msgstr "" -- GitLab From 4face23c9849ddc0d9df1794770c3d44b0ba000b Mon Sep 17 00:00:00 2001 From: Miguel Rincon Date: Thu, 16 Oct 2025 16:59:33 +0200 Subject: [PATCH 2/5] Add tests to copyToClipboard function --- .../lib/utils/copy_to_clipboard.js | 38 +++++ .../components/simple_copy_button.vue | 35 +--- .../lib/utils/copy_to_clipboard_spec.js | 154 ++++++++++++++++++ 3 files changed, 195 insertions(+), 32 deletions(-) create mode 100644 app/assets/javascripts/lib/utils/copy_to_clipboard.js create mode 100644 spec/frontend/lib/utils/copy_to_clipboard_spec.js diff --git a/app/assets/javascripts/lib/utils/copy_to_clipboard.js b/app/assets/javascripts/lib/utils/copy_to_clipboard.js new file mode 100644 index 00000000000000..03acae65dc3a19 --- /dev/null +++ b/app/assets/javascripts/lib/utils/copy_to_clipboard.js @@ -0,0 +1,38 @@ +/** + * Programmatically copies a text string to the clipboard. Attempts to copy in both secure and non-secure contexts. + * + * Accepts a container element. This helps ensure the text can get copied to the clipboard correctly in non-secure + * environments, the container should be active (such as a button in a modal) to ensure the content can be copied. + * + * @param {String} text - Text to copy + * @param {HTMLElement} container - Container to dummy textarea (for fallback behavior). + */ +export const copyToClipboard = (text, container = document.body) => { + // First, try a simple clipboard.writeText (works on https and localhost) + if (navigator.clipboard && window.isSecureContext) { + return navigator.clipboard.writeText(text); + } + + // Second, try execCommand to copy from a dynamically created invisible textarea (for http and older browsers) + const textarea = document.createElement('textarea'); + textarea.value = text; + textarea.style.position = 'absolute'; + textarea.style.left = '-9999px'; // eslint-disable-line @gitlab/require-i18n-strings + textarea.style.top = '0'; + textarea.setAttribute('readonly', ''); // prevent keyboard popup on mobile + + // textarea must be in document to be selectable, but we add it to the button so it works in modals + container.appendChild(textarea); + + textarea.select(); // for Safari + textarea.setSelectionRange(0, textarea.value.length); // for mobile devices + + try { + const done = document.execCommand('copy'); + container.removeChild(textarea); + return done ? Promise.resolve() : Promise.reject(); + } catch (err) { + container.removeChild(textarea); + return Promise.reject(err); + } +}; diff --git a/app/assets/javascripts/vue_shared/components/simple_copy_button.vue b/app/assets/javascripts/vue_shared/components/simple_copy_button.vue index 320517d1a4f87b..9e8d62eec8255b 100644 --- a/app/assets/javascripts/vue_shared/components/simple_copy_button.vue +++ b/app/assets/javascripts/vue_shared/components/simple_copy_button.vue @@ -2,6 +2,7 @@ import { GlButton, GlTooltipDirective } from '@gitlab/ui'; import { uniqueId } from 'lodash'; import { __ } from '~/locale'; +import { copyToClipboard } from '~/lib/utils/copy_to_clipboard'; import * as Sentry from '~/sentry/sentry_browser_wrapper'; /** @@ -15,7 +16,7 @@ import * as Sentry from '~/sentry/sentry_browser_wrapper'; * * @example * - * { + let mockWriteText; + + beforeEach(() => { + document.execCommand = jest.fn().mockReturnValue(true); + mockWriteText = jest.spyOn(navigator.clipboard, 'writeText'); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('in secure context (HTTPS/localhost)', () => { + beforeEach(() => { + window.isSecureContext = true; + }); + + it('should use navigator.clipboard.writeText', async () => { + mockWriteText.mockResolvedValue(); + + await copyToClipboard('test text'); + + expect(mockWriteText).toHaveBeenCalledWith('test text'); + expect(document.execCommand).not.toHaveBeenCalled(); + }); + + it('should reject when navigator.clipboard.writeText fails', async () => { + const error = new Error('Clipboard write failed'); + mockWriteText.mockRejectedValue(error); + + await expect(copyToClipboard('test text')).rejects.toThrow('Clipboard write failed'); + }); + }); + + describe('in non-secure context (HTTP)', () => { + beforeEach(() => { + window.isSecureContext = false; + document.execCommand.mockReturnValue(true); + }); + + it('should use execCommand', async () => { + await copyToClipboard('test text'); + + expect(mockWriteText).not.toHaveBeenCalled(); + expect(document.execCommand).toHaveBeenCalledWith('copy'); + }); + + it('should create an invisible textarea', async () => { + jest.spyOn(document, 'createElement'); + + await copyToClipboard('test text'); + + expect(document.createElement).toHaveBeenCalledWith('textarea'); + + const textarea = document.createElement.mock.results[0].value; + + expect(textarea.value).toBe('test text'); + expect(textarea.style).toMatchObject({ + position: 'absolute', + left: '-9999px', + top: '0px', + }); + expect(textarea.getAttribute('readonly')).toBe(''); + }); + + it('should append textarea to default container (document.body)', async () => { + const appendChildSpy = jest.spyOn(document.body, 'appendChild'); + const removeChildSpy = jest.spyOn(document.body, 'removeChild'); + + await copyToClipboard('test text'); + + expect(appendChildSpy).toHaveBeenCalled(); + expect(removeChildSpy).toHaveBeenCalled(); + }); + + it('should append textarea to custom container', async () => { + const container = document.createElement('div'); + + const appendChildSpy = jest.spyOn(container, 'appendChild'); + const removeChildSpy = jest.spyOn(container, 'removeChild'); + + await copyToClipboard('test text', container); + + expect(appendChildSpy).toHaveBeenCalled(); + expect(removeChildSpy).toHaveBeenCalled(); + }); + + it('should call select and setSelectionRange on textarea', async () => { + const textarea = document.createElement('textarea'); + jest.spyOn(textarea, 'select'); + jest.spyOn(textarea, 'setSelectionRange'); + + jest.spyOn(document, 'createElement').mockImplementation(() => { + return textarea; + }); + + await copyToClipboard('test text'); + + expect(textarea.select).toHaveBeenCalled(); + expect(textarea.setSelectionRange).toHaveBeenCalledWith(0, 9); // 'test text'.length = 9 + }); + + it('should resolve promise when execCommand returns true', async () => { + document.execCommand.mockReturnValue(true); + + await expect(copyToClipboard('test text')).resolves.toBeUndefined(); + }); + + it('should reject promise when execCommand returns false', async () => { + document.execCommand.mockReturnValue(false); + + await expect(copyToClipboard('test text')).rejects.toBeUndefined(); + }); + + it('should reject promise when execCommand throws an error', async () => { + const error = new Error('execCommand failed'); + document.execCommand.mockImplementation(() => { + throw error; + }); + + await expect(copyToClipboard('test text')).rejects.toThrow('execCommand failed'); + }); + + it('should remove textarea from DOM even when execCommand fails', async () => { + document.execCommand.mockReturnValue(false); + const removeChildSpy = jest.spyOn(document.body, 'removeChild'); + + try { + await copyToClipboard('test text'); + } catch (err) { + // Expected to reject + } + + expect(removeChildSpy).toHaveBeenCalled(); + }); + + it('should remove textarea from DOM even when execCommand throws', async () => { + document.execCommand.mockImplementation(() => { + throw new Error('Failed'); + }); + const removeChildSpy = jest.spyOn(document.body, 'removeChild'); + + try { + await copyToClipboard('test text'); + } catch (err) { + // Expected to reject + } + + expect(removeChildSpy).toHaveBeenCalled(); + }); + }); +}); -- GitLab From 6848e44c1e1fe4729ff392dd47b959edca56a2f0 Mon Sep 17 00:00:00 2001 From: Miguel Rincon Date: Fri, 17 Oct 2025 15:06:59 +0200 Subject: [PATCH 3/5] Adds Vue tests --- .../components/simple_copy_button.vue | 15 +- .../runner_cli_instructions_spec.js | 19 +- .../components/simple_copy_button_spec.js | 211 ++++++++++++++++++ 3 files changed, 234 insertions(+), 11 deletions(-) create mode 100644 spec/frontend/vue_shared/components/simple_copy_button_spec.js diff --git a/app/assets/javascripts/vue_shared/components/simple_copy_button.vue b/app/assets/javascripts/vue_shared/components/simple_copy_button.vue index 9e8d62eec8255b..42fe127d42f9de 100644 --- a/app/assets/javascripts/vue_shared/components/simple_copy_button.vue +++ b/app/assets/javascripts/vue_shared/components/simple_copy_button.vue @@ -19,7 +19,7 @@ import * as Sentry from '~/sentry/sentry_browser_wrapper'; * * * Other customization options: @@ -28,7 +28,8 @@ import * as Sentry from '~/sentry/sentry_browser_wrapper'; * title="Copy another URL" * text="http://another.example.com" * class="custom-class-1 custom-class-2" - * :success-toast="false" # Skip success toast message if you want your own success handling + * variant="confirm" + * :toast-message="false" # Skip success toast message if you want your own handling at `@copied` * @copied="onCopied" * @error="onError" # Rare, but can happen * /> @@ -37,6 +38,7 @@ import * as Sentry from '~/sentry/sentry_browser_wrapper'; */ export default { + name: 'SimpleCopyButton', components: { GlButton, }, @@ -51,15 +53,14 @@ export default { }, text: { type: String, - required: false, - default: '', + required: true, }, title: { type: String, required: false, default: __('Copy'), }, - successToast: { + toastMessage: { type: [String, Boolean], required: false, default: __('Copied to clipboard.'), @@ -110,8 +111,8 @@ export default { try { await copyToClipboard(this.text, this.$el); - if (this.successToast) { - this.$toast?.show(this.successToast); + if (this.toastMessage) { + this.$toast?.show(this.toastMessage); } this.$emit('copied'); } catch (e) { diff --git a/spec/frontend/ci/runner/components/registration/runner_instructions/instructions/runner_cli_instructions_spec.js b/spec/frontend/ci/runner/components/registration/runner_instructions/instructions/runner_cli_instructions_spec.js index 3cd1a7f47e8901..dad521d6bbb729 100644 --- a/spec/frontend/ci/runner/components/registration/runner_instructions/instructions/runner_cli_instructions_spec.js +++ b/spec/frontend/ci/runner/components/registration/runner_instructions/instructions/runner_cli_instructions_spec.js @@ -5,6 +5,7 @@ import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; +import SimpleCopyButton from '~/vue_shared/components/simple_copy_button.vue'; import getRunnerSetupInstructionsQuery from '~/ci/runner/components/registration/runner_instructions/graphql/get_runner_setup.query.graphql'; import RunnerCliInstructions from '~/ci/runner/components/registration/runner_instructions/instructions/runner_cli_instructions.vue'; @@ -33,6 +34,7 @@ describe('RunnerCliInstructions component', () => { const findGlLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findAlert = () => wrapper.findComponent(GlAlert); const findArchitectureDropdownItems = () => wrapper.findAllComponents(GlListboxItem); + const findSimpleCopyButtons = () => wrapper.findAllComponents(SimpleCopyButton); const findBinaryDownloadButton = () => wrapper.findByTestId('binary-download-button'); const findBinaryInstructions = () => wrapper.findByTestId('binary-instructions'); const findRegisterCommand = () => wrapper.findByTestId('register-command'); @@ -89,14 +91,23 @@ describe('RunnerCliInstructions component', () => { const instructions = findBinaryInstructions().text(); expect(instructions).toBe(installInstructions.trim()); + + expect(findSimpleCopyButtons().at(0).props()).toMatchObject({ + title: 'Copy instructions', + text: installInstructions, + }); }); it('register command is shown with a replaced token', () => { - const command = findRegisterCommand().text(); + const command = + 'sudo gitlab-runner register --url http://localhost/ --registration-token MY_TOKEN'; - expect(command).toBe( - 'sudo gitlab-runner register --url http://localhost/ --registration-token MY_TOKEN', - ); + expect(findRegisterCommand().text()).toBe(command); + + expect(findSimpleCopyButtons().at(1).props()).toMatchObject({ + title: 'Copy command', + text: command, + }); }); it('architecture download link is shown', () => { diff --git a/spec/frontend/vue_shared/components/simple_copy_button_spec.js b/spec/frontend/vue_shared/components/simple_copy_button_spec.js new file mode 100644 index 00000000000000..207cbd7caa54e0 --- /dev/null +++ b/spec/frontend/vue_shared/components/simple_copy_button_spec.js @@ -0,0 +1,211 @@ +import { GlButton } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import { copyToClipboard } from '~/lib/utils/copy_to_clipboard'; +import waitForPromises from 'helpers/wait_for_promises'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; + +import SimpleCopyButton from '~/vue_shared/components/simple_copy_button.vue'; + +jest.mock('~/lib/utils/copy_to_clipboard'); +jest.mock('~/sentry/sentry_browser_wrapper'); + +describe('clipboard button', () => { + let wrapper; + let mockToastShow; + + const createWrapper = ({ props, ...options } = {}) => { + wrapper = mount(SimpleCopyButton, { + propsData: { + text: 'copy me', + ...props, + }, + directives: { + GlTooltip: createMockDirective('gl-tooltip'), + }, + mocks: { + $toast: { show: mockToastShow }, + }, + ...options, + }); + }; + + const findButton = () => wrapper.findComponent(GlButton); + const getTooltip = () => getBinding(findButton().element, 'gl-tooltip'); + + const clickButton = async () => { + findButton().vm.$emit('click'); + await waitForPromises(); + }; + + beforeEach(() => { + mockToastShow = jest.fn(); + copyToClipboard.mockResolvedValue(); + }); + + describe('default options', () => { + beforeEach(() => { + createWrapper(); + + jest.spyOn(wrapper.vm.$root, '$emit'); + }); + + it('renders a button to copy', () => { + expect(findButton().props('category')).toBe('secondary'); + expect(findButton().props('size')).toBe('medium'); + expect(findButton().props('variant')).toBe('default'); + expect(findButton().props('icon')).toBe('copy-to-clipboard'); + + expect(findButton().attributes('aria-live')).toBe('polite'); + expect(findButton().attributes('aria-label')).toBe('Copy'); + }); + + it('configures tooltip', () => { + expect(getTooltip()).toMatchObject({ + modifiers: { click: true, focus: true, hover: true }, + value: { disabled: false, placement: 'top', title: 'Copy' }, + }); + }); + + describe('when clicked', () => { + beforeEach(async () => { + await clickButton(); + }); + + it('copies', () => { + expect(copyToClipboard).toHaveBeenCalledWith('copy me', wrapper.element); + }); + + it('shows toast', () => { + expect(mockToastShow).toHaveBeenCalledWith('Copied to clipboard.'); + }); + + it('emits "copied" event', () => { + expect(wrapper.emitted('copied')).toEqual([[]]); + }); + + it('hides tooltip temporarily', async () => { + expect(wrapper.vm.$root.$emit).toHaveBeenCalledWith( + 'bv::hide::tooltip', + wrapper.element.id, + ); + expect(getTooltip()).toMatchObject({ + value: { disabled: true }, + }); + + await jest.runOnlyPendingTimers(); + expect(getTooltip()).toMatchObject({ + value: { disabled: false }, + }); + }); + }); + + describe('when on mouseout', () => { + beforeEach(() => { + findButton().vm.$emit('mouseout'); + }); + + it('hides tooltip', () => { + expect(wrapper.vm.$root.$emit).toHaveBeenCalledWith( + 'bv::hide::tooltip', + wrapper.element.id, + ); + }); + }); + }); + + describe('customization', () => { + it('renders a button to copy with other options', () => { + createWrapper({ + props: { + category: 'tertiary', + size: 'small', + variant: 'confirm', + icon: 'pencil', + ariaLabel: 'My aria label', + title: 'My title', + }, + }); + + expect(findButton().props('category')).toBe('tertiary'); + expect(findButton().props('size')).toBe('small'); + expect(findButton().props('variant')).toBe('confirm'); + expect(findButton().props('icon')).toBe('pencil'); + + expect(findButton().attributes('aria-live')).toBe('polite'); + expect(findButton().attributes('aria-label')).toBe('My aria label'); + + expect(getTooltip()).toMatchObject({ + value: { title: 'My title' }, + }); + }); + + it('shows another toast message', async () => { + createWrapper({ + props: { + toastMessage: 'Copied! Yey!', + }, + }); + + await clickButton(); + + expect(mockToastShow).toHaveBeenCalledWith('Copied! Yey!'); + }); + + it('shows no toast message', async () => { + createWrapper({ + props: { + toastMessage: '', + }, + }); + + await clickButton(); + + expect(mockToastShow).not.toHaveBeenCalled(); + }); + }); + + describe('on error', () => { + beforeEach(() => { + createWrapper(); + + copyToClipboard.mockRejectedValue(new Error('error copying')); + }); + + describe('when clicked', () => { + beforeEach(async () => { + jest.spyOn(wrapper.vm.$root, '$emit'); + await clickButton(); + }); + + it('tries to copy', () => { + expect(copyToClipboard).toHaveBeenCalledWith('copy me', wrapper.element); + }); + + it('does not shows toast', () => { + expect(mockToastShow).not.toHaveBeenCalled(); + }); + + it('emits "error" event and reports to sentry', () => { + expect(wrapper.emitted('error')).toEqual([[new Error('error copying')]]); + + expect(Sentry.captureException).toHaveBeenCalledWith(new Error('error copying')); + }); + + it('still hides tooltip temporarily', async () => { + expect(wrapper.vm.$root.$emit).toHaveBeenCalledWith( + 'bv::hide::tooltip', + wrapper.element.id, + ); + expect(getTooltip()).toMatchObject({ + value: { disabled: true }, + }); + + await jest.runOnlyPendingTimers(); + expect(getTooltip()).toMatchObject({ + value: { disabled: false }, + }); + }); + }); + }); +}); -- GitLab From afd995dccd6d2b1b9a8b782bfe701fddfcda5f94 Mon Sep 17 00:00:00 2001 From: Miguel Rincon Date: Mon, 20 Oct 2025 09:01:38 +0200 Subject: [PATCH 4/5] Fix Vue 3 jest tests --- .../components/simple_copy_button_spec.js | 25 +++++++------------ 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/spec/frontend/vue_shared/components/simple_copy_button_spec.js b/spec/frontend/vue_shared/components/simple_copy_button_spec.js index 207cbd7caa54e0..ad086e2af7c746 100644 --- a/spec/frontend/vue_shared/components/simple_copy_button_spec.js +++ b/spec/frontend/vue_shared/components/simple_copy_button_spec.js @@ -1,5 +1,5 @@ import { GlButton } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; +import { mount, createWrapper as vtuCreateWrapper } from '@vue/test-utils'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import { copyToClipboard } from '~/lib/utils/copy_to_clipboard'; import waitForPromises from 'helpers/wait_for_promises'; @@ -12,6 +12,7 @@ jest.mock('~/sentry/sentry_browser_wrapper'); describe('clipboard button', () => { let wrapper; + let rootWrapper; let mockToastShow; const createWrapper = ({ props, ...options } = {}) => { @@ -28,6 +29,8 @@ describe('clipboard button', () => { }, ...options, }); + + rootWrapper = vtuCreateWrapper(wrapper.vm.$root); }; const findButton = () => wrapper.findComponent(GlButton); @@ -46,8 +49,6 @@ describe('clipboard button', () => { describe('default options', () => { beforeEach(() => { createWrapper(); - - jest.spyOn(wrapper.vm.$root, '$emit'); }); it('renders a button to copy', () => { @@ -85,10 +86,8 @@ describe('clipboard button', () => { }); it('hides tooltip temporarily', async () => { - expect(wrapper.vm.$root.$emit).toHaveBeenCalledWith( - 'bv::hide::tooltip', - wrapper.element.id, - ); + expect(rootWrapper.emitted('bv::hide::tooltip')).toEqual([[wrapper.element.id]]); + expect(getTooltip()).toMatchObject({ value: { disabled: true }, }); @@ -106,10 +105,7 @@ describe('clipboard button', () => { }); it('hides tooltip', () => { - expect(wrapper.vm.$root.$emit).toHaveBeenCalledWith( - 'bv::hide::tooltip', - wrapper.element.id, - ); + expect(rootWrapper.emitted('bv::hide::tooltip')).toEqual([[wrapper.element.id]]); }); }); }); @@ -174,7 +170,6 @@ describe('clipboard button', () => { describe('when clicked', () => { beforeEach(async () => { - jest.spyOn(wrapper.vm.$root, '$emit'); await clickButton(); }); @@ -193,10 +188,8 @@ describe('clipboard button', () => { }); it('still hides tooltip temporarily', async () => { - expect(wrapper.vm.$root.$emit).toHaveBeenCalledWith( - 'bv::hide::tooltip', - wrapper.element.id, - ); + expect(rootWrapper.emitted('bv::hide::tooltip')).toEqual([[wrapper.element.id]]); + expect(getTooltip()).toMatchObject({ value: { disabled: true }, }); -- GitLab From 78a2fe240b2b827e95adfab1e3a8587632c61efb Mon Sep 17 00:00:00 2001 From: Miguel Rincon Date: Fri, 24 Oct 2025 12:25:20 +0200 Subject: [PATCH 5/5] Address reviewer feedback --- .../instructions/runner_cli_instructions.vue | 6 ++- .../lib/utils/copy_to_clipboard.js | 3 +- .../components/simple_copy_button.vue | 26 ++--------- .../lib/utils/copy_to_clipboard_spec.js | 2 +- .../components/simple_copy_button_spec.js | 46 +++++-------------- 5 files changed, 22 insertions(+), 61 deletions(-) diff --git a/app/assets/javascripts/ci/runner/components/registration/runner_instructions/instructions/runner_cli_instructions.vue b/app/assets/javascripts/ci/runner/components/registration/runner_instructions/instructions/runner_cli_instructions.vue index b74df2ba02d7ec..c35627be644c61 100644 --- a/app/assets/javascripts/ci/runner/components/registration/runner_instructions/instructions/runner_cli_instructions.vue +++ b/app/assets/javascripts/ci/runner/components/registration/runner_instructions/instructions/runner_cli_instructions.vue @@ -96,6 +96,8 @@ export default { downloadInstallBinary: s__('Runners|Download and install binary'), downloadLatestBinary: s__('Runners|Download latest binary'), registerRunnerCommand: s__('Runners|Command to register runner'), + copyInstructions: s__('Runners|Copy instructions'), + copyCommand: s__('Runners|Copy command'), }, }; @@ -128,7 +130,7 @@ export default { instructions.installInstructions }} { try { const done = document.execCommand('copy'); container.removeChild(textarea); - return done ? Promise.resolve() : Promise.reject(); + // eslint-disable-next-line @gitlab/require-i18n-strings + return done ? Promise.resolve() : Promise.reject(new Error('Copy command failed')); } catch (err) { container.removeChild(textarea); return Promise.reject(err); diff --git a/app/assets/javascripts/vue_shared/components/simple_copy_button.vue b/app/assets/javascripts/vue_shared/components/simple_copy_button.vue index 42fe127d42f9de..e1385d9c038ae3 100644 --- a/app/assets/javascripts/vue_shared/components/simple_copy_button.vue +++ b/app/assets/javascripts/vue_shared/components/simple_copy_button.vue @@ -97,21 +97,12 @@ export default { }, }, emits: ['copied', 'error'], - data() { - return { - tooltipTimeout: null, - tooltipDisabled: false, - }; - }, methods: { - hideTooltip() { - this.$root.$emit('bv::hide::tooltip', this.id); - }, async onClick() { try { await copyToClipboard(this.text, this.$el); - if (this.toastMessage) { + if (typeof this.toastMessage === 'string' && this.toastMessage) { this.$toast?.show(this.toastMessage); } this.$emit('copied'); @@ -119,18 +110,10 @@ export default { this.$emit('error', e); Sentry.captureException(e); } - - // Hide and disable tooltip temporarily to avoid distracting users after copying. - this.hideTooltip(); - this.tooltipDisabled = true; - clearTimeout(this.tooltipTimeout); - this.tooltipTimeout = setTimeout(() => { - this.tooltipDisabled = false; - }, 3000); }, onMouseout() { - // Tooltip can get stuck after clicking while it is animated, ensure it's hidden - this.hideTooltip(); + // Tooltip still appears after clicking focusing on the button, ensure it's hidden + this.$root.$emit('bv::hide::tooltip', this.id); }, }, }; @@ -138,11 +121,10 @@ export default {