diff --git a/packages/bruno-app/src/utils/codemirror/brunoVarInfo.js b/packages/bruno-app/src/utils/codemirror/brunoVarInfo.js index 52614703f..a91fa706e 100644 --- a/packages/bruno-app/src/utils/codemirror/brunoVarInfo.js +++ b/packages/bruno-app/src/utils/codemirror/brunoVarInfo.js @@ -12,31 +12,130 @@ let CodeMirror; const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true; const { get } = require('lodash'); -if (!SERVER_RENDERED) { - CodeMirror = require('codemirror'); +const COPY_ICON_SVG_TEXT = ` + + + + +`; - const renderVarInfo = (token, options, cm, pos) => { - // Extract variable name and value based on token - const { variableName, variableValue } = extractVariableInfo(token.string, options.variables); +const CHECKMARK_ICON_SVG_TEXT = ` + + + +`; - if (variableValue === undefined) { +const COPY_SUCCESS_COLOR = '#22c55e'; + +export const COPY_SUCCESS_TIMEOUT = 1000; + +const getCopyButton = variableValue => { + const copyButton = document.createElement('button'); + + copyButton.className = 'copy-button'; + copyButton.style.backgroundColor = 'transparent'; + copyButton.style.border = 'none'; + copyButton.style.color = 'inherit'; + copyButton.style.cursor = 'pointer'; + copyButton.style.padding = '2px'; + copyButton.style.opacity = '0.7'; + copyButton.style.transition = 'opacity 0.2s ease'; + copyButton.style.display = 'flex'; + copyButton.style.alignItems = 'center'; + copyButton.style.justifyContent = 'center'; + + copyButton.innerHTML = COPY_ICON_SVG_TEXT; + + let isCopied = false; + + copyButton.addEventListener('mouseenter', () => { + if (isCopied) { return; } - const into = document.createElement('div'); - const descriptionDiv = document.createElement('div'); - descriptionDiv.className = 'info-description'; + copyButton.style.opacity = '1'; + }); - if (options?.variables?.maskedEnvVariables?.includes(variableName)) { - descriptionDiv.appendChild(document.createTextNode('*****')); - } else { - descriptionDiv.appendChild(document.createTextNode(variableValue)); + copyButton.addEventListener('mouseleave', () => { + if (isCopied) { + return; } - into.appendChild(descriptionDiv); + copyButton.style.opacity = '0.7'; + }); - return into; - }; + copyButton.addEventListener('click', e => { + e.stopPropagation(); + + // Prevent clicking if showing success checkmark + if (isCopied) { + return; + } + + navigator.clipboard + .writeText(variableValue) + .then(() => { + isCopied = true; + copyButton.innerHTML = CHECKMARK_ICON_SVG_TEXT; + copyButton.style.opacity = '1'; + copyButton.style.color = COPY_SUCCESS_COLOR; + copyButton.style.cursor = 'default'; + copyButton.classList.add('copy-success'); + + setTimeout(() => { + isCopied = false; + copyButton.innerHTML = COPY_ICON_SVG_TEXT; + copyButton.style.opacity = '0.7'; + copyButton.style.color = 'inherit'; + copyButton.style.cursor = 'pointer'; + copyButton.classList.remove('copy-success'); + }, COPY_SUCCESS_TIMEOUT); + }) + .catch(err => { + console.error('Failed to copy to clipboard:', err.message); + }); + }); + + return copyButton; +}; + +export const renderVarInfo = (token, options, cm, pos) => { + // Extract variable name and value based on token + const { variableName, variableValue } = extractVariableInfo(token.string, options.variables); + + if (variableValue === undefined) { + return; + } + + const into = document.createElement('div'); + + const contentDiv = document.createElement('div'); + contentDiv.style.display = 'flex'; + contentDiv.style.alignItems = 'center'; + contentDiv.style.gap = '8px'; + contentDiv.className = 'info-content'; + + const descriptionDiv = document.createElement('div'); + descriptionDiv.className = 'info-description'; + descriptionDiv.style.flex = '1'; + + if (options?.variables?.maskedEnvVariables?.includes(variableName)) { + descriptionDiv.appendChild(document.createTextNode('*****')); + } else { + descriptionDiv.appendChild(document.createTextNode(variableValue)); + } + + const copyButton = getCopyButton(variableValue); + + contentDiv.appendChild(descriptionDiv); + contentDiv.appendChild(copyButton); + into.appendChild(contentDiv); + + return into; +}; + +if (!SERVER_RENDERED) { + CodeMirror = require('codemirror'); CodeMirror.defineOption('brunoVarInfo', false, function (cm, options, old) { if (old && old !== CodeMirror.Init) { @@ -214,4 +313,4 @@ export const extractVariableInfo = (str, variables) => { } return { variableName, variableValue }; -}; \ No newline at end of file +}; diff --git a/packages/bruno-app/src/utils/codemirror/brunoVarInfo.spec.js b/packages/bruno-app/src/utils/codemirror/brunoVarInfo.spec.js index 5002097c2..0a2a161dc 100644 --- a/packages/bruno-app/src/utils/codemirror/brunoVarInfo.spec.js +++ b/packages/bruno-app/src/utils/codemirror/brunoVarInfo.spec.js @@ -1,5 +1,5 @@ import { interpolate } from '@usebruno/common'; -import { extractVariableInfo } from './brunoVarInfo'; +import { COPY_SUCCESS_TIMEOUT, extractVariableInfo, renderVarInfo } from './brunoVarInfo'; // Mock the dependencies jest.mock('@usebruno/common', () => ({ @@ -225,3 +225,120 @@ describe('extractVariableInfo', () => { }); }); }); + +describe('renderVarInfo', () => { + let clipboardText = ''; + + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + + // setup mock clipboard + clipboardText = ''; + Object.defineProperty(navigator, 'clipboard', { + value: { + writeText: jest.fn(text => { + if (text === 'cause-clipboard-error') { + return Promise.reject(new Error('Clipboard error')); + } + + clipboardText = text; + + return Promise.resolve(); + }), + }, + configurable: true, + }); + + // mock console.error + console.error = jest.fn(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + function setupRender(variables) { + const result = renderVarInfo({ string: '{{apiKey}}' }, { variables }); + const contentDiv = result.querySelector('.info-content'); + const descriptionDiv = contentDiv.querySelector('.info-description'); + const copyButton = contentDiv.querySelector('.copy-button'); + + return { result, contentDiv, descriptionDiv, copyButton }; + } + + describe('popup functionality', () => { + it('should create a popup', () => { + const { result } = setupRender({ apiKey: 'test-value' }); + + expect(result).toBeDefined(); + }); + + it('should create a popup with the correct variable name and value', () => { + const { descriptionDiv } = setupRender({ apiKey: 'test-value' }); + + expect(descriptionDiv.textContent).toBe('test-value'); + }); + + it('should correctly mask the variable value in the popup', () => { + const { descriptionDiv } = setupRender({ + apiKey: 'test-value', + maskedEnvVariables: ['apiKey'], + }); + + expect(descriptionDiv.textContent).toBe('*****'); + }); + }); + + describe('copy button functionality', () => { + it('should create a copy button', () => { + const { copyButton } = setupRender({ apiKey: 'test-value' }); + + expect(copyButton).toBeDefined(); + }); + + it('should copy the variable value to the clipboard', async () => { + const { copyButton } = setupRender({ apiKey: 'test-value' }); + + await copyButton.click(); + + expect(clipboardText).toBe('test-value'); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith('test-value'); + }); + + it('should copy the variable value of masked variables to the clipboard', async () => { + const { copyButton } = setupRender({ apiKey: 'test-value', maskedEnvVariables: ['apiKey'] }); + + await copyButton.click(); + + expect(clipboardText).toBe('test-value'); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith('test-value'); + }); + + it('should show a success checkmark when the variable value is copied', async () => { + const { copyButton } = setupRender({ apiKey: 'test-value' }); + + expect(copyButton.classList.contains('copy-success')).toBe(false); + + await copyButton.click(); + + expect(copyButton.classList.contains('copy-success')).toBe(true); + + jest.advanceTimersByTime(COPY_SUCCESS_TIMEOUT); + + expect(copyButton.classList.contains('copy-success')).toBe(false); + }); + + it('should log to the console when the variable value is not copied', async () => { + const { copyButton } = setupRender({ apiKey: 'cause-clipboard-error' }); + + await copyButton.click(); + + // wait for .catch() microtask to run + await Promise.resolve(); + + expect(clipboardText).toBe(''); + expect(console.error).toHaveBeenCalledWith('Failed to copy to clipboard:', 'Clipboard error'); + }); + }); +});