mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-23 12:45:38 +00:00
Merge pull request #6266 from usebruno/feat/copy-response-to-clipboard-5409
added copy button to copy response (#5409)
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
button {
|
||||
color: var(--color-tab-inactive);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: var(--color-tab-active);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.cursor-pointer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--color-tab-inactive);
|
||||
|
||||
&:hover {
|
||||
color: var(--color-tab-active);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,35 @@
|
||||
import React, { useRef, forwardRef } from 'react';
|
||||
import { IconDots } from '@tabler/icons';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import ResponseClear from 'src/components/ResponsePane/ResponseClear';
|
||||
import ResponseSave from 'src/components/ResponsePane/ResponseSave';
|
||||
|
||||
const ResponseActions = ({ collection, item }) => {
|
||||
const menuDropdownTippyRef = useRef();
|
||||
|
||||
const onMenuDropdownCreate = (ref) => (menuDropdownTippyRef.current = ref);
|
||||
|
||||
const MenuIcon = forwardRef((_props, ref) => {
|
||||
return (
|
||||
<div ref={ref} className="cursor-pointer">
|
||||
<IconDots size={18} strokeWidth={1.5} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const handleClose = () => {
|
||||
menuDropdownTippyRef.current.hide();
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper className="ml-2 flex items-center">
|
||||
<Dropdown onCreate={onMenuDropdownCreate} icon={<MenuIcon />} placement="bottom-end">
|
||||
<ResponseClear item={item} collection={collection} asDropdownItem onClose={handleClose} />
|
||||
<ResponseSave item={item} asDropdownItem onClose={handleClose} />
|
||||
</Dropdown>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResponseActions;
|
||||
@@ -4,10 +4,11 @@ import { useDispatch } from 'react-redux';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { responseCleared } from 'providers/ReduxStore/slices/collections/index';
|
||||
|
||||
const ResponseClear = ({ collection, item }) => {
|
||||
const ResponseClear = ({ collection, item, asDropdownItem, onClose }) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const clearResponse = () =>
|
||||
const clearResponse = () => {
|
||||
if (onClose) onClose();
|
||||
dispatch(
|
||||
responseCleared({
|
||||
itemUid: item.uid,
|
||||
@@ -15,6 +16,16 @@ const ResponseClear = ({ collection, item }) => {
|
||||
response: null
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
if (asDropdownItem) {
|
||||
return (
|
||||
<div className="dropdown-item" onClick={clearResponse}>
|
||||
<IconEraser size={16} strokeWidth={1.5} className="icon mr-2" />
|
||||
Clear
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledWrapper className="ml-2 flex items-center">
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
font-size: 0.8125rem;
|
||||
color: ${(props) => props.theme.requestTabPanel.responseStatus};
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,46 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import toast from 'react-hot-toast';
|
||||
import { IconCopy, IconCheck } from '@tabler/icons';
|
||||
|
||||
const ResponseCopy = ({ item }) => {
|
||||
const response = item.response || {};
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (copied) {
|
||||
const timer = setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 2000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [copied]);
|
||||
|
||||
const copyResponse = async () => {
|
||||
try {
|
||||
const textToCopy = typeof response.data === 'string'
|
||||
? response.data
|
||||
: JSON.stringify(response.data, null, 2);
|
||||
|
||||
await navigator.clipboard.writeText(textToCopy);
|
||||
toast.success('Response copied to clipboard');
|
||||
setCopied(true);
|
||||
} catch (error) {
|
||||
toast.error('Failed to copy response');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper className="ml-2 flex items-center">
|
||||
<button onClick={copyResponse} disabled={!response.data} title="Copy response to clipboard">
|
||||
{copied ? (
|
||||
<IconCheck size={16} strokeWidth={1.5} />
|
||||
) : (
|
||||
<IconCopy size={16} strokeWidth={1.5} />
|
||||
)}
|
||||
</button>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResponseCopy;
|
||||
@@ -4,11 +4,13 @@ import toast from 'react-hot-toast';
|
||||
import get from 'lodash/get';
|
||||
import { IconDownload } from '@tabler/icons';
|
||||
|
||||
const ResponseSave = ({ item }) => {
|
||||
const ResponseSave = ({ item, asDropdownItem, onClose }) => {
|
||||
const { ipcRenderer } = window;
|
||||
const response = item.response || {};
|
||||
|
||||
const saveResponseToFile = () => {
|
||||
if (!response.dataBuffer) return;
|
||||
if (onClose) onClose();
|
||||
return new Promise((resolve, reject) => {
|
||||
ipcRenderer
|
||||
.invoke('renderer:save-response-to-file', response, item?.requestSent?.url, item.pathname)
|
||||
@@ -20,6 +22,20 @@ const ResponseSave = ({ item }) => {
|
||||
});
|
||||
};
|
||||
|
||||
if (asDropdownItem) {
|
||||
return (
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={saveResponseToFile}
|
||||
disabled={!response.dataBuffer}
|
||||
style={!response.dataBuffer ? { opacity: 0.5, cursor: 'not-allowed' } : {}}
|
||||
>
|
||||
<IconDownload size={16} strokeWidth={1.5} className="icon mr-2" />
|
||||
Download
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledWrapper className="ml-2 flex items-center">
|
||||
<button onClick={saveResponseToFile} disabled={!response.dataBuffer} title="Save response to file">
|
||||
|
||||
@@ -16,9 +16,9 @@ import TestResultsLabel from './TestResultsLabel';
|
||||
import ScriptError from './ScriptError';
|
||||
import ScriptErrorIcon from './ScriptErrorIcon';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import ResponseSave from 'src/components/ResponsePane/ResponseSave';
|
||||
import ResponseClear from 'src/components/ResponsePane/ResponseClear';
|
||||
import ResponseActions from 'src/components/ResponsePane/ResponseActions';
|
||||
import ResponseBookmark from 'src/components/ResponsePane/ResponseBookmark';
|
||||
import ResponseCopy from 'src/components/ResponsePane/ResponseCopy';
|
||||
import SkippedRequest from './SkippedRequest';
|
||||
import ClearTimeline from './ClearTimeline/index';
|
||||
import ResponseLayoutToggle from './ResponseLayoutToggle';
|
||||
@@ -187,9 +187,9 @@ const ResponsePane = ({ item, collection }) => {
|
||||
<ClearTimeline item={item} collection={collection} />
|
||||
) : (item?.response && !item?.response?.error) ? (
|
||||
<>
|
||||
<ResponseClear item={item} collection={collection} />
|
||||
<ResponseSave item={item} />
|
||||
<ResponseBookmark item={item} collection={collection} responseSize={responseSize} />
|
||||
<ResponseCopy item={item} />
|
||||
<ResponseActions item={item} collection={collection} />
|
||||
<StatusCode status={response.status} isStreaming={item.response?.stream?.running} />
|
||||
{item.response?.stream?.running
|
||||
? <ResponseStopWatch startMillis={response.duration} />
|
||||
|
||||
51
tests/response/response-actions.spec.ts
Normal file
51
tests/response/response-actions.spec.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { test, expect } from '../../playwright';
|
||||
import { closeAllCollections, createCollection } from '../utils/page/actions';
|
||||
|
||||
test.describe('Response Pane Actions', () => {
|
||||
test.afterAll(async ({ page }) => {
|
||||
// cleanup: close all collections
|
||||
await closeAllCollections(page);
|
||||
});
|
||||
|
||||
test('should copy response to clipboard', async ({ page, createTmpDir }) => {
|
||||
const collectionName = 'response-copy-test';
|
||||
|
||||
await test.step('Create collection and request', async () => {
|
||||
// Create collection
|
||||
await createCollection(page, collectionName, await createTmpDir(collectionName), { openWithSandboxMode: 'safe' });
|
||||
|
||||
// Create request
|
||||
const collection = page.locator('.collection-name').filter({ hasText: collectionName });
|
||||
await collection.locator('.collection-actions').hover();
|
||||
await collection.locator('.collection-actions .icon').click();
|
||||
await page.locator('.dropdown-item').filter({ hasText: 'New Request' }).click();
|
||||
|
||||
await page.getByPlaceholder('Request Name').fill('copy-test');
|
||||
await page.locator('#new-request-url .CodeMirror').click();
|
||||
// Using httpbin.org for a simple JSON response
|
||||
await page.locator('textarea').fill('https://httpbin.org/json');
|
||||
await page.getByRole('button', { name: 'Create' }).click();
|
||||
});
|
||||
|
||||
await test.step('Send request and wait for response', async () => {
|
||||
// Send request
|
||||
const sendButton = page.getByTestId('send-arrow-icon');
|
||||
await sendButton.click();
|
||||
|
||||
// Wait for response
|
||||
await expect(page.getByTestId('response-status-code')).toContainText('200', { timeout: 30000 });
|
||||
});
|
||||
|
||||
await test.step('should copy response to clipboard', async () => {
|
||||
// Find the copy button
|
||||
const copyButton = page.locator('button[title="Copy response to clipboard"]');
|
||||
await expect(copyButton).toBeVisible();
|
||||
|
||||
// Click the copy button
|
||||
await copyButton.click();
|
||||
|
||||
// Verify toast notification appears
|
||||
await expect(page.getByText('Response copied to clipboard')).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user