feat: Add button to copy environment variable from popover (#5416)

* feat: Add copy button to environment variable hover

* feat: Add success state

* feat: Clean up code

* feat: Add DOM test for popover and copy button functionality

* feat: Add more robust tests

* chore: reformat

---------

Co-authored-by: Siddharth Gelera <ahoy@barelyhuman.dev>
This commit is contained in:
Rudra Patel
2025-09-29 03:30:42 -04:00
committed by GitHub
parent 123fe7d542
commit 191a997b05
2 changed files with 234 additions and 18 deletions

View File

@@ -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 = `
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
`;
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 = `
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="20,6 9,17 4,12"></polyline>
</svg>
`;
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 };
};
};

View File

@@ -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');
});
});
});