mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-25 05:35:41 +00:00
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:
@@ -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 };
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user