diff --git a/packages/bruno-api-docs/e2e/components/playground/response-pane.component.ts b/packages/bruno-api-docs/e2e/components/playground/response-pane.component.ts index f5abb85d..01634c29 100644 --- a/packages/bruno-api-docs/e2e/components/playground/response-pane.component.ts +++ b/packages/bruno-api-docs/e2e/components/playground/response-pane.component.ts @@ -12,6 +12,8 @@ export class ResponsePaneComponent extends BaseComponent { readonly bodyEditor = new CodeEditorComponent(this.page, 'response-body-editor'); readonly sendButton = this.page.getByTestId('query-bar-send'); readonly formatSelector = this.page.getByTestId('response-format-selector'); + // The leading icon in the selector trigger (the eye when preview is on, else the format's icon). + readonly formatSelectorIcon = this.page.getByTestId('response-format-selector-trigger-icon'); // The response actions live in the tab bar's right slot and only render once a response exists // (and there is no request error). They are responsive: collapsed into a kebab menu when the slot @@ -87,7 +89,9 @@ export class ResponsePaneComponent extends BaseComponent { await expect(inlineTab).toHaveAttribute('aria-selected', 'true'); } - /** The inline Change-Layout button, shown when the actions are expanded (wide pane). */ + readonly inlineCopyButton = this.inlineButtons.getByRole('button', { name: 'Copy Response' }); + readonly inlineDownloadButton = this.inlineButtons.getByRole('button', { name: 'Download Response' }); + readonly inlineClearButton = this.inlineButtons.getByRole('button', { name: 'Clear Response' }); readonly inlineChangeLayoutButton = this.inlineButtons.getByRole('button', { name: 'Change Layout' }); async openFormatSelector(): Promise { diff --git a/packages/bruno-api-docs/e2e/tests/playground/response-actions.spec.ts b/packages/bruno-api-docs/e2e/tests/playground/response-actions.spec.ts index 173b0866..7a40a481 100644 --- a/packages/bruno-api-docs/e2e/tests/playground/response-actions.spec.ts +++ b/packages/bruno-api-docs/e2e/tests/playground/response-actions.spec.ts @@ -1,3 +1,4 @@ +import type { Page } from '@playwright/test'; import { test, expect } from '../../playwright'; // The response-pane actions (Copy, Download, Clear, Change Layout) only render once a response is @@ -6,6 +7,17 @@ import { test, expect } from '../../playwright'; // host = http://localhost:8081 in the Local env) rather than a proxy. const USERS_BODY = '{"data":[{"id":1,"name":"Alice"}]}'; +// Read and parse the clipboard, returning null while it is still empty/unparseable so `expect.poll` +// can retry until the async copy lands. +const readClipboardJson = (page: Page): Promise => + page.evaluate(async () => { + try { + return JSON.parse(await navigator.clipboard.readText()); + } catch { + return null; + } + }); + // At the default (narrow) playground width the actions collapse into a kebab menu; each action is a // menu item. test.describe('response pane actions — collapsed', () => { @@ -36,6 +48,16 @@ test.describe('response pane actions — collapsed', () => { await expect(responsePane.actions).toBeVisible(); }); + test('Copy writes the response body to the clipboard', async ({ page, responsePane }) => { + await page.context().grantPermissions(['clipboard-read', 'clipboard-write']); + + await responsePane.openActionsMenu(); + await responsePane.copyMenuItem.click(); + + // The copied text is pretty-printed JSON, so compare the parsed value rather than the raw string. + await expect.poll(() => readClipboardJson(page)).toEqual(JSON.parse(USERS_BODY)); + }); + test('Clear returns the pane to the empty "Click Send" state', async ({ responsePane }) => { await responsePane.openActionsMenu(); await responsePane.clearMenuItem.click(); @@ -50,7 +72,8 @@ test.describe('response pane actions — collapsed', () => { await responsePane.openActionsMenu(); await responsePane.downloadMenuItem.click(); const download = await downloadPromise; - expect(download.suggestedFilename().length).toBeGreaterThan(0); + // JSON body with no Content-Disposition and a `/api/users` path → the content-type extension. + expect(download.suggestedFilename()).toBe('response.json'); }); }); @@ -70,7 +93,32 @@ test.describe('response pane actions — expanded', () => { test('shows the actions as inline buttons instead of the kebab menu', async ({ responsePane }) => { await expect(responsePane.inlineButtons).toBeVisible(); await expect(responsePane.actionsMenuTrigger).toBeHidden(); - await expect(responsePane.inlineButtons.getByRole('button', { name: 'Copy Response' })).toBeVisible(); + await expect(responsePane.inlineCopyButton).toBeVisible(); + }); + + test('Copy writes the response body to the clipboard from the inline button', async ({ + page, + responsePane + }) => { + await page.context().grantPermissions(['clipboard-read', 'clipboard-write']); + + await responsePane.inlineCopyButton.click(); + + await expect.poll(() => readClipboardJson(page)).toEqual(JSON.parse(USERS_BODY)); + }); + + test('Download triggers a browser download from the inline button', async ({ page, responsePane }) => { + const downloadPromise = page.waitForEvent('download'); + await responsePane.inlineDownloadButton.click(); + const download = await downloadPromise; + expect(download.suggestedFilename()).toBe('response.json'); + }); + + test('Clear returns the pane to the empty state from the inline button', async ({ responsePane }) => { + await responsePane.inlineClearButton.click(); + + await expect(responsePane.emptyHint).toBeVisible(); + await expect(responsePane.actions).toHaveCount(0); }); test('Change Layout toggles the container orientation horizontal <-> vertical', async ({ diff --git a/packages/bruno-api-docs/e2e/tests/playground/response-body.spec.ts b/packages/bruno-api-docs/e2e/tests/playground/response-body.spec.ts index 078c040b..bcaa2ef8 100644 --- a/packages/bruno-api-docs/e2e/tests/playground/response-body.spec.ts +++ b/packages/bruno-api-docs/e2e/tests/playground/response-body.spec.ts @@ -49,6 +49,35 @@ test.describe('response body — editor formatting per format', () => { await responsePane.selectFormat('base64'); await expect.poll(() => responsePane.bodyText()).toBe(BASE64_BODY); }); + + test('the selector trigger shows the format icon, swapping to the eye icon in preview', async ({ + page, + playground, + responsePane + }) => { + await page.goto('/#/?pg=1&dock=bottom'); + await playground.openSidebarItem('get users'); + await responsePane.send(); + + // JSON default (preview off): the trigger carries the format's own icon (braces). + await expect(responsePane.formatSelectorIcon).toHaveClass(/icon-tabler-braces/); + + // Turning preview on swaps the trigger icon to the eye. + await responsePane.openFormatSelector(); + await responsePane.togglePreview(); + expect(await responsePane.isPreviewOn()).toBe(true); + await expect(responsePane.formatSelectorIcon).toHaveClass(/icon-tabler-eye/); + + // Turning it back off restores the format icon. + await responsePane.togglePreview(); + expect(await responsePane.isPreviewOn()).toBe(false); + await expect(responsePane.formatSelectorIcon).toHaveClass(/icon-tabler-braces/); + + // Changing the format updates the trigger icon accordingly (raw -> file-text). The dropdown is + // still open from toggling preview, so pick the option directly. + await responsePane.formatOption('raw').click(); + await expect(responsePane.formatSelectorIcon).toHaveClass(/icon-tabler-file-text/); + }); }); // A minimal 1x1 PNG — the magic-number sniffer classifies it as a byte format, so the diff --git a/packages/bruno-api-docs/src/components/Playground/Content/Views/Common/LargeResponseWarning/LargeResponseWarning.tsx b/packages/bruno-api-docs/src/components/Playground/Content/Views/Common/LargeResponseWarning/LargeResponseWarning.tsx index bd385110..ff20d1cb 100644 --- a/packages/bruno-api-docs/src/components/Playground/Content/Views/Common/LargeResponseWarning/LargeResponseWarning.tsx +++ b/packages/bruno-api-docs/src/components/Playground/Content/Views/Common/LargeResponseWarning/LargeResponseWarning.tsx @@ -1,10 +1,10 @@ import React, { useCallback } from 'react'; import { formatBytes } from '@/utils/exampleResponse'; -import CopyButton from '@/ui/CopyButton/CopyButton'; import type { RunRequestResponse } from '@/runner'; import { downloadResponse } from '@/utils/downloadResponse'; import { StyledWrapper } from './StyledWrapper'; -import { IconAlertTriangle } from '@tabler/icons'; +import { IconAlertTriangle, IconCheck, IconCopy, IconDownload, IconEye } from '@tabler/icons'; +import useCopy from '@/hooks/useCopy'; const LARGE_RESPONSE_THRESHOLD = 10 * 1024 * 1024; // 10 MB @@ -20,6 +20,10 @@ export const LargeResponseWarning: React.FC = ({ resp return typeof data === 'string' ? data : JSON.stringify(data ?? '', null, 2); }, [response?.data]); + const { copied, copyResponse } = useCopy({ + getText: dataToCopy + }); + return (
@@ -41,14 +45,23 @@ export const LargeResponseWarning: React.FC = ({ resp aria-label="View response" data-testid="large-response-view" > - View + + + + View + + -
diff --git a/packages/bruno-api-docs/src/components/Playground/Content/Views/Common/LargeResponseWarning/StyledWrapper.ts b/packages/bruno-api-docs/src/components/Playground/Content/Views/Common/LargeResponseWarning/StyledWrapper.ts index 0d18bf7d..36869169 100644 --- a/packages/bruno-api-docs/src/components/Playground/Content/Views/Common/LargeResponseWarning/StyledWrapper.ts +++ b/packages/bruno-api-docs/src/components/Playground/Content/Views/Common/LargeResponseWarning/StyledWrapper.ts @@ -16,7 +16,6 @@ export const StyledWrapper = styled.div` } .large-response-title { - color: var(--oc-status-warning-text); font-weight: 600; font-size: var(--oc-font-size-lg); } @@ -43,6 +42,7 @@ export const StyledWrapper = styled.div` } .large-response-view, + .large-response-copy, .large-response-download { display: inline-flex; align-items: center; @@ -55,6 +55,7 @@ export const StyledWrapper = styled.div` font-size: var(--oc-font-size-base); cursor: pointer; transition: color 0.15s ease, background-color 0.15s ease; + gap: 0.5rem; &:hover { background-color: var(--badge-bg); diff --git a/packages/bruno-api-docs/src/components/Playground/Content/Views/Common/ResponseBodyTab.tsx b/packages/bruno-api-docs/src/components/Playground/Content/Views/Common/ResponseBodyTab.tsx index 2a4ab58b..ad2aefec 100644 --- a/packages/bruno-api-docs/src/components/Playground/Content/Views/Common/ResponseBodyTab.tsx +++ b/packages/bruno-api-docs/src/components/Playground/Content/Views/Common/ResponseBodyTab.tsx @@ -52,6 +52,7 @@ const ResponseBodyTab: React.FC = ({ response, selectedFor height="100%" readOnly={true} testId="response-body-editor" + showCopy={false} /> )} diff --git a/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseActions/ChangeLayout/ChangeLayout.tsx b/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseActions/ChangeLayout/ChangeLayout.tsx index 3fe4421b..295fe43c 100644 --- a/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseActions/ChangeLayout/ChangeLayout.tsx +++ b/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseActions/ChangeLayout/ChangeLayout.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import ActionIcon from '@/ui/ActionIcon/ActionIcon'; +import IconButton from '@/ui/IconButton/IconButton'; import { IconLayoutColumns, IconLayoutRows } from '@tabler/icons'; interface ChangeLayoutProps { @@ -9,13 +9,13 @@ interface ChangeLayoutProps { const ChangeLayout: React.FC = ({ orientation, handleChangeLayout }) => { return ( - + {orientation === 'vertical' ? ( - + ) : ( - + )} - + ); }; diff --git a/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseActions/ClearResponse/ClearResponse.tsx b/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseActions/ClearResponse/ClearResponse.tsx index e2798357..e983abb8 100644 --- a/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseActions/ClearResponse/ClearResponse.tsx +++ b/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseActions/ClearResponse/ClearResponse.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import ActionIcon from '@/ui/ActionIcon/ActionIcon'; +import IconButton from '@/ui/IconButton/IconButton'; import { IconEraser } from '@tabler/icons'; interface ClearResponseProps { @@ -8,9 +8,9 @@ interface ClearResponseProps { const ClearResponse: React.FC = ({ onClick }) => { return ( - - - + + + ); }; diff --git a/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseActions/CopyResponse/CopyResponse.tsx b/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseActions/CopyResponse/CopyResponse.tsx index 7dbf717d..f70c9c58 100644 --- a/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseActions/CopyResponse/CopyResponse.tsx +++ b/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseActions/CopyResponse/CopyResponse.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { IconCheck, IconCopy } from '@tabler/icons'; -import ActionIcon from '@/ui/ActionIcon/ActionIcon'; +import IconButton from '@/ui/IconButton/IconButton'; interface CopyResponseProps { copied: boolean; @@ -10,9 +10,9 @@ interface CopyResponseProps { const CopyResponse: React.FC = ({ copied, onClick, disabled }) => { return ( - - {copied ? : } - + + {copied ? : } + ); }; diff --git a/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseActions/CopyResponse/hooks/useCopyResponse.ts b/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseActions/CopyResponse/hooks/useCopyResponse.ts deleted file mode 100644 index 03b9ff2a..00000000 --- a/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseActions/CopyResponse/hooks/useCopyResponse.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; -import type { RunRequestResponse } from '@/runner'; -import type { ResponseBodyFormat } from '@/constants'; -import { formatResponse } from '@/utils/dataFormatter'; - -const getCopyText = ( - response: RunRequestResponse, - selectedFormat: ResponseBodyFormat, - showPreview: boolean -): string => { - const data = response?.data; - const dataBuffer = response?.base64Data; - // Preview shows the raw data, so copy that as-is. - if (showPreview) { - return typeof data === 'string' ? data : JSON.stringify(data, null, 2); - } - // The editor shows the body formatted for the selected format; mirror it when a buffer is present. - if (selectedFormat && data && dataBuffer) { - return formatResponse(data, dataBuffer, selectedFormat); - } - return typeof data === 'string' ? data : JSON.stringify(data, null, 2); -}; - -export const useCopyResponse = ( - response: RunRequestResponse, - selectedFormat: ResponseBodyFormat, - showPreview: boolean -) => { - const [copied, setCopied] = useState(false); - const timeoutRef = useRef | undefined>(undefined); - - useEffect(() => () => { if (timeoutRef.current) clearTimeout(timeoutRef.current); }, []); - - const disabled = !response?.data; - - const copyResponse = useCallback(async () => { - if (disabled || !navigator.clipboard) return; - try { - await navigator.clipboard.writeText(getCopyText(response, selectedFormat, showPreview)); - setCopied(true); - if (timeoutRef.current) clearTimeout(timeoutRef.current); - timeoutRef.current = setTimeout(() => setCopied(false), 2000); - } catch { - // Clipboard unavailable (e.g. insecure context) — fail silently. - } - }, [response, selectedFormat, showPreview, disabled]); - - return { copied, copyResponse, disabled }; -}; diff --git a/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseActions/DownloadResponse/DownloadResponse.tsx b/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseActions/DownloadResponse/DownloadResponse.tsx index a5c9f269..1823bb00 100644 --- a/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseActions/DownloadResponse/DownloadResponse.tsx +++ b/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseActions/DownloadResponse/DownloadResponse.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import ActionIcon from '@/ui/ActionIcon/ActionIcon'; +import IconButton from '@/ui/IconButton/IconButton'; import { IconDownload } from '@tabler/icons'; interface DownloadResponseProps { @@ -9,9 +9,9 @@ interface DownloadResponseProps { const DownloadResponse: React.FC = ({ onClick, disabled }) => { return ( - - - + + + ); }; diff --git a/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseActions/ResponseActions.spec.tsx b/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseActions/ResponseActions.spec.tsx new file mode 100644 index 00000000..04db6996 --- /dev/null +++ b/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseActions/ResponseActions.spec.tsx @@ -0,0 +1,130 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { parse } from 'node-html-parser'; +import { Provider } from 'react-redux'; +import { describe, it, expect } from 'vitest'; +import type { RunRequestResponse } from '@/runner'; +import { createOpenCollectionStore } from '@/store/store'; +import { query, getByTestId } from '@/test-utils/dom'; +import ResponseActions from './ResponseActions'; + +const baseResponse: RunRequestResponse = { + status: 200, + headers: { 'content-type': 'application/json' }, + data: { hello: 'world' }, + base64Data: Buffer.from('{"hello":"world"}').toString('base64'), + url: 'http://localhost:8081/api/users' +}; + +const render = (props: Partial> = {}) => { + const store = createOpenCollectionStore(); + const root = parse( + renderToStaticMarkup( + + + + ) + ); + root.querySelectorAll('style').forEach((style) => style.remove()); + return root; +}; + +// Buttons render in two mirrored groups: an inline `actions-buttons` block (shown when the pane is +// wide) and a collapsed kebab `actions-dropdown` (shown when narrow). CSS toggles which is visible; +// both exist in the static markup. The inline group is the one carrying the individual buttons. +const inlineButton = (root: ReturnType, label: string) => + query(getByTestId(root, 'actions-buttons'), `[aria-label="${label}"]`); + +describe('ResponseActions', () => { + it('renders the actions wrapper with both the inline and collapsed groups', () => { + const root = render(); + expect(getByTestId(root, 'response-pane-actions-wrapper')).toBeTruthy(); + expect(getByTestId(root, 'actions-buttons')).toBeTruthy(); + expect(getByTestId(root, 'actions-dropdown')).toBeTruthy(); + }); + + it('exposes Copy, Download, Clear and Change Layout as inline icon buttons', () => { + const root = render(); + expect(inlineButton(root, 'Copy Response')).toBeTruthy(); + expect(inlineButton(root, 'Download Response')).toBeTruthy(); + expect(inlineButton(root, 'Clear Response')).toBeTruthy(); + expect(inlineButton(root, 'Change Layout')).toBeTruthy(); + }); + + describe('Copy Response', () => { + it('shows the copy icon and is enabled when the response has data', () => { + const button = inlineButton(render(), 'Copy Response'); + expect(button.innerHTML).toContain('icon-tabler-copy'); + expect(button.innerHTML).not.toContain('icon-tabler-check'); + expect(button.hasAttribute('disabled')).toBe(false); + }); + + it('is disabled when the response has no data', () => { + const button = inlineButton(render({ response: { ...baseResponse, data: undefined } }), 'Copy Response'); + expect(button.hasAttribute('disabled')).toBe(true); + }); + }); + + describe('Download Response', () => { + it('shows the download icon and is enabled when raw bytes are present', () => { + const button = inlineButton(render(), 'Download Response'); + expect(button.innerHTML).toContain('icon-tabler-download'); + expect(button.hasAttribute('disabled')).toBe(false); + }); + + it('is disabled when the response carries no base64 bytes', () => { + const button = inlineButton(render({ response: { ...baseResponse, base64Data: undefined } }), 'Download Response'); + expect(button.hasAttribute('disabled')).toBe(true); + }); + }); + + describe('Clear Response', () => { + it('shows the eraser icon and is always enabled', () => { + const button = inlineButton(render(), 'Clear Response'); + expect(button.innerHTML).toContain('icon-tabler-eraser'); + expect(button.hasAttribute('disabled')).toBe(false); + }); + + it('stays enabled even when there is nothing to copy or download', () => { + const button = inlineButton( + render({ response: { ...baseResponse, data: undefined, base64Data: undefined } }), + 'Clear Response' + ); + expect(button.hasAttribute('disabled')).toBe(false); + }); + }); + + describe('Change Layout', () => { + it('shows the columns icon in the vertical orientation', () => { + const button = inlineButton(render({ orientation: 'vertical' }), 'Change Layout'); + expect(button.innerHTML).toContain('icon-tabler-layout-columns'); + expect(button.innerHTML).not.toContain('icon-tabler-layout-rows'); + }); + + it('shows the rows icon in the horizontal orientation', () => { + const button = inlineButton(render({ orientation: 'horizontal' }), 'Change Layout'); + expect(button.innerHTML).toContain('icon-tabler-layout-rows'); + expect(button.innerHTML).not.toContain('icon-tabler-layout-columns'); + }); + }); + + describe('collapsed kebab menu', () => { + it('renders the "More actions" dropdown trigger, closed by default', () => { + const root = render(); + const trigger = query(getByTestId(root, 'actions-dropdown'), '[data-testid="response-actions-menu"]'); + expect(trigger.getAttribute('aria-label')).toBe('More actions'); + expect(trigger.getAttribute('aria-haspopup')).toBe('menu'); + expect(trigger.getAttribute('aria-expanded')).toBe('false'); + expect(trigger.innerHTML).toContain('icon-tabler-dots'); + // Closed: the menu surface and its items are not in the static markup. + expect(root.querySelector('[role="menu"]')).toBeNull(); + }); + }); +}); diff --git a/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseActions/ResponseActions.tsx b/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseActions/ResponseActions.tsx index ad40c8da..d8746d8c 100644 --- a/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseActions/ResponseActions.tsx +++ b/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseActions/ResponseActions.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useCallback } from 'react'; import { IconCopy, IconDownload, @@ -11,16 +11,17 @@ import CopyResponse from './CopyResponse/CopyResponse'; import ClearResponse from './ClearResponse/ClearResponse'; import DownloadResponse from './DownloadResponse/DownloadResponse'; import ChangeLayout from './ChangeLayout/ChangeLayout'; -import { useCopyResponse } from './CopyResponse/hooks/useCopyResponse'; import { StyledWrapper } from './StyledWrapper'; import MenuDropdown from '@/ui/MenuDropdown'; import type { MenuDropdownItem } from '@/ui/MenuDropdown'; -import ActionIcon from '@/ui/ActionIcon/ActionIcon'; +import IconButton from '@/ui/IconButton/IconButton'; import type { RunRequestResponse } from '@/runner'; import type { ResponseBodyFormat } from '@/constants'; import { useAppDispatch } from '@/store/hooks'; import { clearPlaygroundResponse, setResponsePaneOrientation } from '@/store/slices/playground'; import { downloadResponse } from '@/utils/downloadResponse'; +import useCopy from '@/hooks/useCopy'; +import { formatResponse } from '@/utils/dataFormatter'; interface ResponseActionsProps { orientation: 'vertical' | 'horizontal'; @@ -37,7 +38,29 @@ const ResponseActions: React.FC = ({ selectedFormat, showPreview }) => { - const { copied, copyResponse, disabled: copyDisabled } = useCopyResponse(response, selectedFormat, showPreview); + const getCopyText = useCallback(( + ): string => { + const data = response?.data; + const dataBuffer = response?.base64Data; + // Preview shows the raw data, so copy that as-is. + if (showPreview) { + return typeof data === 'string' ? data : JSON.stringify(data, null, 2); + } + if (selectedFormat && data && dataBuffer) { + return formatResponse(data, dataBuffer, selectedFormat); + } + return typeof data === 'string' ? data : JSON.stringify(data, null, 2); + }, [ + response?.data, + response?.base64Data, + selectedFormat, + showPreview + ]); + const copyDisabled = !response.data; + const { copied, copyResponse } = useCopy({ + getText: getCopyText, + disabled: copyDisabled + }); const dispatch = useAppDispatch(); const downloadDisabled = !response?.base64Data; @@ -62,9 +85,9 @@ const ResponseActions: React.FC = ({
- - - + + +
diff --git a/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseFormatter/PreviewToggleHeader/PreviewToggleHeader.tsx b/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseFormatter/PreviewToggleHeader/PreviewToggleHeader.tsx new file mode 100644 index 00000000..8a5f5f10 --- /dev/null +++ b/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseFormatter/PreviewToggleHeader/PreviewToggleHeader.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { StyledWrapper } from './StyledWrapper'; + +const PreviewToggleHeader: React.FC<{ checked: boolean; onChange: () => void }> = ({ + checked, + onChange +}) => ( + + Preview + + +); + +export default PreviewToggleHeader; diff --git a/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseFormatter/StyledWrapper.ts b/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseFormatter/PreviewToggleHeader/StyledWrapper.ts similarity index 100% rename from packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseFormatter/StyledWrapper.ts rename to packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseFormatter/PreviewToggleHeader/StyledWrapper.ts diff --git a/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseFormatter/ResponseFormatter.spec.tsx b/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseFormatter/ResponseFormatter.spec.tsx new file mode 100644 index 00000000..d7976bc3 --- /dev/null +++ b/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseFormatter/ResponseFormatter.spec.tsx @@ -0,0 +1,103 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, it, expect } from 'vitest'; +import type { ResponseBodyFormat } from '@/constants'; +import { FORMAT_LABELS } from '@/constants'; +import ResponseFormatSelector from './ResponseFormatter'; + +const ICON_TESTID = 'response-format-selector-trigger-icon'; + +// The trigger renders the selected format's tabler icon; each format maps to a distinct icon class. +const FORMAT_ICON_CLASS: Record = { + json: 'icon-tabler-braces', + html: 'icon-tabler-code', + xml: 'icon-tabler-file-code', + javascript: 'icon-tabler-brand-javascript', + raw: 'icon-tabler-file-text', + hex: 'icon-tabler-hexagons', + base64: 'icon-tabler-binary-tree' +}; + +const render = (props: Partial> = {}) => + renderToStaticMarkup( {}} {...props} />); + +// Vitest runs in a node environment and asserts on SSR markup; Tippy only mounts the menu surface +// (items, preview toggle) when the dropdown opens, so unit coverage is the always-rendered trigger. +// The menu items, group collapsing, and preview toggle are exercised by the Playwright e2e specs. +describe('ResponseFormatSelector', () => { + describe('trigger icon — format icon when preview is off', () => { + (Object.keys(FORMAT_ICON_CLASS) as ResponseBodyFormat[]).forEach((format) => { + it(`shows the ${format} icon and its label`, () => { + const html = render({ selectedFormat: format, showPreview: false }); + expect(html).toContain(`data-testid="${ICON_TESTID}"`); + expect(html).toContain(FORMAT_ICON_CLASS[format]); + expect(html).toContain(FORMAT_LABELS[format]); + expect(html).not.toContain('icon-tabler-eye'); + }); + }); + }); + + describe('trigger icon — eye icon when preview is on', () => { + it('swaps the format icon for the eye when a format is selected', () => { + const html = render({ selectedFormat: 'json', showPreview: true }); + expect(html).toContain(`data-testid="${ICON_TESTID}"`); + expect(html).toContain('icon-tabler-eye'); + expect(html).not.toContain('icon-tabler-braces'); + // The format label still accompanies the eye icon. + expect(html).toContain(FORMAT_LABELS.json); + }); + + it('shows the eye for every format while preview is on', () => { + (Object.keys(FORMAT_ICON_CLASS) as ResponseBodyFormat[]).forEach((format) => { + const html = render({ selectedFormat: format, showPreview: true }); + expect(html).toContain('icon-tabler-eye'); + expect(html).not.toContain(FORMAT_ICON_CLASS[format]); + }); + }); + }); + + describe('trigger icon — absent cases', () => { + it('renders no icon when no format is selected and preview is off', () => { + const html = render({ showPreview: false }); + expect(html).not.toContain(ICON_TESTID); + expect(html).not.toContain('icon-tabler-eye'); + }); + + // The trigger content is produced by `itemToText`, which MenuDropdown only invokes for a + // selected item. Preview is always a view of a selected format, so this degenerate combination + // does not occur in practice — the trigger simply renders empty. + it('renders no icon when preview is on but no format is selected', () => { + const html = render({ showPreview: true }); + expect(html).not.toContain(ICON_TESTID); + expect(html).not.toContain('icon-tabler-eye'); + }); + + it('treats an omitted showPreview as off (format icon, not eye)', () => { + const html = render({ selectedFormat: 'xml' }); + expect(html).toContain(FORMAT_ICON_CLASS.xml); + expect(html).not.toContain('icon-tabler-eye'); + }); + }); + + describe('trigger element', () => { + it('renders the default trigger with the selector testId, closed by default', () => { + const html = render({ selectedFormat: 'json' }); + expect(html).toContain('data-testid="response-format-selector"'); + expect(html).toContain('menu-dropdown-trigger'); + expect(html).toContain('aria-expanded="false"'); + // Closed: the menu surface (and its items) is not rendered server-side. + expect(html).not.toContain('role="menu"'); + expect(html).not.toContain('response-format-selector-json'); + }); + + it('exposes a menu-style trigger for assistive tech', () => { + const html = render({ selectedFormat: 'json' }); + expect(html).toContain('aria-haspopup="menu"'); + }); + + it('marks the trigger icon aria-hidden so only the label is announced', () => { + const html = render({ selectedFormat: 'json' }); + expect(html).toMatch(/aria-hidden[^>]*data-testid="response-format-selector-trigger-icon"|data-testid="response-format-selector-trigger-icon"[^>]*aria-hidden/); + }); + }); +}); diff --git a/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseFormatter/ResponseFormatter.tsx b/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseFormatter/ResponseFormatter.tsx index a9d79778..0860e2fc 100644 --- a/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseFormatter/ResponseFormatter.tsx +++ b/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseFormatter/ResponseFormatter.tsx @@ -1,4 +1,4 @@ -import type { FC } from 'react'; +import { IconEye } from '@tabler/icons'; import MenuDropdown, { type MenuDropdownItem, type MenuDropdownItems } from '@/ui/MenuDropdown'; import type { ResponseBodyFormat } from '@/constants'; @@ -9,7 +9,7 @@ import { FORMAT_LABELS, FORMAT_ICONS } from '@/constants'; -import { StyledWrapper } from './StyledWrapper'; +import PreviewToggleHeader from './PreviewToggleHeader/PreviewToggleHeader'; interface ResponseFormatSelectorProps { handleSelection?: (value: ResponseBodyFormat) => void; @@ -18,34 +18,15 @@ interface ResponseFormatSelectorProps { allowedFormats?: ResponseBodyFormat[]; /** Whether the response is shown as a rendered preview vs the raw editor. */ showPreview?: boolean; - onPreviewToggle?: (next: boolean) => void; + toggleView: () => void; } -const PreviewToggleHeader: FC<{ checked: boolean; onChange: (next: boolean) => void }> = ({ - checked, - onChange -}) => ( - - Preview - - -); - -const ResponseFormatSelector: FC = ({ +const ResponseFormatSelector: React.FC = ({ handleSelection, selectedFormat, allowedFormats = ALL_FORMAT_OPTIONS, showPreview = false, - onPreviewToggle + toggleView }) => { // Preserve the two-group visual layout, but drop a group entirely when none of its // formats are allowed (binary responses collapse to the byte-format group only). @@ -62,15 +43,23 @@ const ResponseFormatSelector: FC = ({ })) })); + const TriggerIcon = showPreview ? IconEye : selectedFormat ? FORMAT_ICONS[selectedFormat] : undefined; + return ( item.label} + itemToText={(item: MenuDropdownItem) => ( + + {TriggerIcon && ( + + )} + {item.label} + + )} placement="bottom-start" - header={ onPreviewToggle?.(next)} />} + header={} testId="response-format-selector" - size="sm" /> ); }; diff --git a/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseFormatter/hooks/useResponseFormatter.ts b/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseFormatter/hooks/useResponseFormatter.ts index 1ff22d90..2b0215b3 100644 --- a/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseFormatter/hooks/useResponseFormatter.ts +++ b/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseFormatter/hooks/useResponseFormatter.ts @@ -1,49 +1,72 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useMemo } from 'react'; import type { ResponseBodyFormat } from '../../../../../../../../constants'; import { useInitialResponseFormat } from './useInitialResponseFormat'; import type { RunRequestResponse } from '../../../../../../../../runner'; import { getResponseFormatOptions } from '../../../../../../../../utils/response'; +import { useAppDispatch, useAppSelector } from '@/store/hooks'; +import { + selectResponseFormat, + selectShowResponsePreview, + selectSelectedItemId, + setResponseFormat, + setShowResponsePreview +} from '@/store/slices/playground'; export function useResponseFormatter( response: RunRequestResponse ) { + const selectedItemId = useAppSelector(selectSelectedItemId); + const selectedResponseFormat = useAppSelector(selectResponseFormat(selectedItemId)); + const showResponsePreview = useAppSelector(selectShowResponsePreview(selectedItemId)); + const dispatch = useAppDispatch(); const { format, view, detectedContentType, headerContentType, contentType } = useInitialResponseFormat(response); + const allowedFormats = useMemo( () => getResponseFormatOptions(detectedContentType, headerContentType), [detectedContentType, headerContentType] ); - const [userSelectedFormat, setUserSelectedFormat] = useState(); - const [showPreview, setShowPreview] = useState(view === 'preview'); - - const previousViewRef = useRef(view); - useEffect(() => { - if (previousViewRef.current !== view) { - previousViewRef.current = view; - setShowPreview(view === 'preview'); - } - }, [view]); const handleFormatChange = useCallback((format: ResponseBodyFormat) => { - setUserSelectedFormat(format); - }, []); + dispatch(setResponseFormat({ + uuid: selectedItemId, + format + })); + }, [dispatch, selectedItemId]); - const handleViewChange = useCallback((showPreview: boolean) => { - setShowPreview(showPreview); - }, []); + const toggleView = useCallback(() => { + const currentlyPreview = showResponsePreview != null ? showResponsePreview : view === 'preview'; + dispatch(setShowResponsePreview({ + uuid: selectedItemId, + showResponsePreview: !currentlyPreview + })); + }, [dispatch, selectedItemId, view, showResponsePreview]); return useMemo(() => { // A user's chosen format wins, but a stale choice that no longer applies to the current // body (e.g. 'json' held while the content-type turned binary) is ignored in favour of // the detected default. const selectedFormat - = userSelectedFormat && allowedFormats.includes(userSelectedFormat) ? userSelectedFormat : format; + = selectedResponseFormat && allowedFormats.includes(selectedResponseFormat) ? selectedResponseFormat : format; return { selectedFormat, - showPreview, + showPreview: ( + showResponsePreview != null + ? showResponsePreview + : view === 'preview' + ), handleFormatChange, - handleViewChange, + toggleView, contentType, allowedFormats }; - }, [handleFormatChange, handleViewChange, format, userSelectedFormat, allowedFormats, showPreview, contentType]); + }, [ + handleFormatChange, + toggleView, + format, + selectedResponseFormat, + allowedFormats, + showResponsePreview, + contentType, + view + ]); } diff --git a/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseInfo/ResponseSize/ResponseSize.tsx b/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseInfo/ResponseSize/ResponseSize.tsx index 00a3fc6c..649a84cb 100644 --- a/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseInfo/ResponseSize/ResponseSize.tsx +++ b/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseInfo/ResponseSize/ResponseSize.tsx @@ -1,18 +1,27 @@ import React from 'react'; +const MB = 1024 * 1024; +const KB = 1024; interface ResponseSizeProps { size: number | null | undefined; } + const ResponseSize: React.FC = ({ size }) => { if (size == null) { return null; } + const sizeToDisplay = ( + size > MB + ? `${(size / MB).toFixed(2)} MB` + : size > KB + ? `${(size / KB).toFixed(2)} KB` + : `${size}B` + ); + return ( -
- - {(size / 1024).toFixed(2)} KB - +
+ {sizeToDisplay}
); }; diff --git a/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseInfo/ResponseStatus/ResponseStatus.tsx b/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseInfo/ResponseStatus/ResponseStatus.tsx index ac41fcc1..4ea9df45 100644 --- a/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseInfo/ResponseStatus/ResponseStatus.tsx +++ b/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseInfo/ResponseStatus/ResponseStatus.tsx @@ -12,15 +12,13 @@ const ResponseStatus: React.FC = ({ status, statusText }) = } return ( -
- - {status} {statusText} - +
+ {status} {statusText}
); }; diff --git a/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponsePane.tsx b/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponsePane.tsx index 841e3022..2373d9ab 100644 --- a/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponsePane.tsx +++ b/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponsePane.tsx @@ -29,7 +29,7 @@ const ResponsePane: React.FC = ({ response, isLoading, orient selectedFormat, showPreview, handleFormatChange, - handleViewChange, + toggleView, contentType, allowedFormats } = useResponseFormatter(response); @@ -122,7 +122,7 @@ const ResponsePane: React.FC = ({ response, isLoading, orient allowedFormats={allowedFormats} handleSelection={(value: ResponseBodyFormat) => handleFormatChange(value)} showPreview={showPreview} - onPreviewToggle={handleViewChange} + toggleView={toggleView} /> )} diff --git a/packages/bruno-api-docs/src/components/Playground/QueryResultPreview/StyledWrapper.ts b/packages/bruno-api-docs/src/components/Playground/QueryResultPreview/StyledWrapper.ts index d87f1398..39bdb617 100644 --- a/packages/bruno-api-docs/src/components/Playground/QueryResultPreview/StyledWrapper.ts +++ b/packages/bruno-api-docs/src/components/Playground/QueryResultPreview/StyledWrapper.ts @@ -4,4 +4,22 @@ export const StyledWrapper = styled.div` height: 100%; overflow: auto; max-height: calc(100vh - 13.75rem); + + .react-pdf__Document { + display: flex; + flex-direction: column; + align-items: center; + } + + .react-pdf__Page { + max-width: 100%; + margin-bottom: 0.75rem; + background-color: transparent; + } + + .react-pdf__Page__canvas { + display: block; + max-width: 100%; + height: auto !important; + } `; diff --git a/packages/bruno-api-docs/src/components/Playground/docks/ModalDock/StyledWrapper.ts b/packages/bruno-api-docs/src/components/Playground/docks/ModalDock/StyledWrapper.ts index 4dbd599c..8e67bde1 100644 --- a/packages/bruno-api-docs/src/components/Playground/docks/ModalDock/StyledWrapper.ts +++ b/packages/bruno-api-docs/src/components/Playground/docks/ModalDock/StyledWrapper.ts @@ -7,6 +7,7 @@ export const StyledWrapper = styled.div` display: flex; align-items: center; justify-content: center; + color: var(--text-primary); .modal-backdrop { position: absolute; diff --git a/packages/bruno-api-docs/src/hooks/useCopy.ts b/packages/bruno-api-docs/src/hooks/useCopy.ts new file mode 100644 index 00000000..46d314f9 --- /dev/null +++ b/packages/bruno-api-docs/src/hooks/useCopy.ts @@ -0,0 +1,38 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +interface useCopyArg { + text?: string; + getText?: () => string; + disabled?: boolean; + resetAfterMs?: number; +} + +function useCopy({ + text, getText, disabled, resetAfterMs = 2000 +}: useCopyArg) { + const [copied, setCopied] = useState(false); + const timeoutRef = useRef | undefined>(undefined); + + useEffect(() => { + return () => { + if (timeoutRef.current) + clearTimeout(timeoutRef.current); + }; + }, []); + + const copyResponse = useCallback(async () => { + if (disabled || !navigator.clipboard || !(text || getText)) return; + try { + await navigator.clipboard.writeText(text ? text : getText ? getText() : ''); + setCopied(true); + if (timeoutRef.current) clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(() => setCopied(false), resetAfterMs); + } catch { + // Clipboard unavailable (e.g. insecure context) — fail silently. + } + }, [disabled, text, getText, resetAfterMs]); + + return { copied, copyResponse }; +}; + +export default useCopy; diff --git a/packages/bruno-api-docs/src/runner/RequestExecutor.ts b/packages/bruno-api-docs/src/runner/RequestExecutor.ts index 4f98d5a1..7bebcb2b 100644 --- a/packages/bruno-api-docs/src/runner/RequestExecutor.ts +++ b/packages/bruno-api-docs/src/runner/RequestExecutor.ts @@ -7,6 +7,7 @@ import { classifyRequestError, DEFAULT_TIMEOUT_MS } from './classifyRequestError import { detectContentTypeFromBytes, isByteFormatContentType } from '../utils/response'; import { RESPONSE_LARGE_THRESHOLD } from '../constants'; import stripJsonComments from 'strip-json-comments'; +import { statusCodePhrase } from '@/utils/exampleResponse'; /** Methods `fetch` refuses to attach a request body to. */ const BODYLESS_METHODS = ['GET', 'HEAD']; @@ -44,7 +45,7 @@ export class RequestExecutor { return { status: response.status, - statusText: response.statusText, + statusText: response.statusText ? response.statusText : statusCodePhrase(response.status), headers: responseHeaders, data: responseData.data, base64Data: responseData.base64Data, diff --git a/packages/bruno-api-docs/src/store/slices/playground.ts b/packages/bruno-api-docs/src/store/slices/playground.ts index 8bd608e4..c433c8d4 100644 --- a/packages/bruno-api-docs/src/store/slices/playground.ts +++ b/packages/bruno-api-docs/src/store/slices/playground.ts @@ -9,6 +9,7 @@ import type { RootState } from '../store'; import { hydrateWithUUIDs, findAndUpdateItem } from '../../utils/fileUtils'; import { isFolder, getRequestVariables } from '../../utils/schemaHelpers'; import { isSecretVariable } from '../../utils/variableResolution'; +import type { ResponseBodyFormat } from '@/constants'; export type ViewMode = 'playground' | 'environments' | 'folder-settings' | 'collection-settings' | 'example'; @@ -22,6 +23,8 @@ export interface PlaygroundState { selectedItemId: string | null; selectedExampleIndex: number | null; responsePaneOrientation: 'horizontal' | 'vertical' | null; + selectedResponseFormat: Record; + showResponsePreview: Record; } const initialState: PlaygroundState = { @@ -33,7 +36,9 @@ const initialState: PlaygroundState = { viewMode: 'playground', selectedItemId: null, selectedExampleIndex: null, - responsePaneOrientation: null + responsePaneOrientation: null, + selectedResponseFormat: {}, + showResponsePreview: {} }; const readEnvironments = (collection: OpenCollectionCollection): Environment[] | null => @@ -262,6 +267,24 @@ const playgroundSlice = createSlice({ }; apply(state.hydratedCollection); apply(state.collection); + }, + setResponseFormat: (state: PlaygroundState, action: PayloadAction<{ + uuid: PlaygroundState['selectedItemId']; + format: ResponseBodyFormat; + }>) => { + if (action.payload.uuid != null) + state.selectedResponseFormat[action.payload.uuid] = action.payload.format; + }, + setShowResponsePreview: ( + state: PlaygroundState, + action: PayloadAction<{ + uuid: PlaygroundState['selectedItemId']; + showResponsePreview: boolean; + }> + ) => { + const { uuid, showResponsePreview } = action.payload; + if (uuid != null) + state.showResponsePreview[uuid] = showResponsePreview; } } }); @@ -282,17 +305,25 @@ export const { updateCollectionEnvironments, updateFolderInCollection, resetPlaygroundEnvironments, - setPlaygroundVariable + setPlaygroundVariable, + setResponseFormat, + setShowResponsePreview } = playgroundSlice.actions; // Selectors export const selectPlaygroundCollection = (state: RootState) => state.playground.collection; export const selectHydratedCollection = (state: RootState) => state.playground.hydratedCollection; -export const selectPlaygroundResponse = (state: RootState, uuid: string) => state.playground.responses[uuid]; export const selectPlaygroundResponses = (state: RootState) => state.playground.responses; +export const selectPlaygroundResponse = (state: RootState, uuid: string) => state.playground.responses[uuid]; export const selectViewMode = (state: RootState) => state.playground.viewMode; export const selectSelectedItemId = (state: RootState) => state.playground.selectedItemId; export const selectSelectedExampleIndex = (state: RootState) => state.playground.selectedExampleIndex; export const selectResponsePaneOrientation = (state: RootState) => state.playground.responsePaneOrientation; +export const selectResponseFormat + = (uuid: PlaygroundState['selectedItemId']) => + (state: RootState) => uuid ? state.playground.selectedResponseFormat[uuid] : null; +export const selectShowResponsePreview + = (uuid: PlaygroundState['selectedItemId']) => + (state: RootState) => uuid ? state.playground.showResponsePreview[uuid] : null; export default playgroundSlice.reducer; diff --git a/packages/bruno-api-docs/src/types/tabler-icons.d.ts b/packages/bruno-api-docs/src/types/tabler-icons.d.ts index 523f9db4..38b972c4 100644 --- a/packages/bruno-api-docs/src/types/tabler-icons.d.ts +++ b/packages/bruno-api-docs/src/types/tabler-icons.d.ts @@ -39,4 +39,5 @@ declare module '@tabler/icons' { export const IconHexagons: TablerIcon; export const IconBinaryTree: TablerIcon; export const IconAlertTriangle: TablerIcon; + export const IconEye: TablerIcon; } diff --git a/packages/bruno-api-docs/src/ui/ActionIcon/ActionIcon.tsx b/packages/bruno-api-docs/src/ui/ActionIcon/ActionIcon.tsx deleted file mode 100644 index be6819c8..00000000 --- a/packages/bruno-api-docs/src/ui/ActionIcon/ActionIcon.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import React, { forwardRef } from 'react'; -import StyledWrapper from './StyledWrapper'; -import { cx } from '@/utils/cx'; - -interface ActionIconProps { - children: React.ReactNode; - variant?: 'subtle' | 'filled' | 'outline'; - size?: 'sm' | 'md' | 'lg'; - disabled?: boolean; - className?: string; - component?: React.ElementType; - label: string; - title?: string; - ariaLabel?: string; - colorOnHover?: string; - color?: string; - style?: React.CSSProperties; - onClick?: () => void; -} - -const ActionIcon = forwardRef(({ - children, - variant = 'subtle', - size = 'md', - disabled = false, - className = '', - component: Component = 'button', - label, - ariaLabel, - colorOnHover, - color, - style, - ...rest -}, ref) => { - return ( - - {children} - - ); -}); - -export default ActionIcon; diff --git a/packages/bruno-api-docs/src/ui/ActionIcon/StyledWrapper.ts b/packages/bruno-api-docs/src/ui/ActionIcon/StyledWrapper.ts deleted file mode 100644 index 3590420d..00000000 --- a/packages/bruno-api-docs/src/ui/ActionIcon/StyledWrapper.ts +++ /dev/null @@ -1,63 +0,0 @@ -import styled from '@emotion/styled'; -import { css } from '@emotion/react'; - -type ActionIconSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl'; - -const sizeMap: Record = { - xs: 1.25, - sm: 1.375, - md: 1.5, - lg: 1.75, - xl: 2 -}; - -const variants = { - subtle: css` - color: var(--text-muted); - background: transparent; - &:hover:not(:disabled) { - color: var(--oc-text); - background: var(--oc-dropdown-hover-bg); - } - ` -}; - -interface ActionIconStyleProps { - $size?: ActionIconSize | number; - $variant?: string; - $color?: string; - $colorOnHover?: string; -} - -const StyledWrapper = styled.button` - display: flex; - align-items: center; - justify-content: center; - border: none; - border-radius: 0.25rem; - cursor: pointer; - transition: all 0.15s ease; - padding: 0; - - width: ${(props) => sizeMap[props.$size as ActionIconSize] || props.$size}rem; - height: ${(props) => sizeMap[props.$size as ActionIconSize] || props.$size}rem; - - ${(props) => variants[props.$variant as keyof typeof variants] || variants.subtle} - - ${(props) => props.$color && css` - color: ${props.$color}; - `} - - &:disabled { - opacity: 0.5; - cursor: not-allowed; - } - - ${(props) => props.$colorOnHover && css` - &:hover:not(:disabled) { - color: ${props.$colorOnHover}; - } - `} -`; - -export default StyledWrapper; diff --git a/packages/bruno-api-docs/src/ui/CopyButton/CopyButton.tsx b/packages/bruno-api-docs/src/ui/CopyButton/CopyButton.tsx index a15b4e63..86b63488 100644 --- a/packages/bruno-api-docs/src/ui/CopyButton/CopyButton.tsx +++ b/packages/bruno-api-docs/src/ui/CopyButton/CopyButton.tsx @@ -1,5 +1,8 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { StyledWrapper } from './StyledWrapper'; +import { IconCheck, IconCopy } from '@tabler/icons'; +import useCopy from '@/hooks/useCopy'; +import cx from '@/utils/cx'; interface CopyButtonProps { text?: string; @@ -12,19 +15,6 @@ interface CopyButtonProps { className?: string; } -const CopyGlyph: React.FC = () => ( - -); - -const CheckGlyph: React.FC = () => ( - -); - export const CopyButton: React.FC = ({ text, getText, @@ -35,40 +25,22 @@ export const CopyButton: React.FC = ({ style, className }) => { - const [copied, setCopied] = useState(false); - const timeoutRef = useRef | undefined>(undefined); - - useEffect( - () => () => { - if (timeoutRef.current) clearTimeout(timeoutRef.current); - }, - [] - ); - - const handleCopy = useCallback(async () => { - if (!navigator.clipboard) return; - const value = getText ? getText() : text; - if (!value) return; - try { - await navigator.clipboard.writeText(value); - setCopied(true); - if (timeoutRef.current) clearTimeout(timeoutRef.current); - timeoutRef.current = setTimeout(() => setCopied(false), resetAfterMs); - } catch { - // Clipboard unavailable (e.g. insecure context) — fail silently. - } - }, [text, getText, resetAfterMs]); + const { copied, copyResponse } = useCopy({ + text, + getText, + resetAfterMs + }); return ( - {copied ? : } + {copied ? : } ); }; diff --git a/packages/bruno-api-docs/src/ui/IconButton/IconButton.tsx b/packages/bruno-api-docs/src/ui/IconButton/IconButton.tsx index dab5ef2f..28294e1c 100644 --- a/packages/bruno-api-docs/src/ui/IconButton/IconButton.tsx +++ b/packages/bruno-api-docs/src/ui/IconButton/IconButton.tsx @@ -1,20 +1,22 @@ import React from 'react'; import { StyledWrapper } from './StyledWrapper'; +import Tooltip from '../Tooltip/Tooltip'; export interface IconButtonProps extends React.ButtonHTMLAttributes { /** Accessible label — icon buttons have no visible text. */ label: string; + /** Show the hover/focus tooltip carrying `label`. Defaults to `true`. */ + showTooltip?: boolean; } -/** - * Icon-only button primitive (aria-label required, no text). Shared across the - * app — used for the Topbar hamburger, search toggle, overflow ⋯, etc. Extra - * ARIA / button props (aria-expanded, aria-haspopup, onClick…) pass through. - */ -const IconButton: React.FC = ({ label, children, type = 'button', ...rest }) => ( - - {children} - +const IconButton = React.forwardRef( + ({ label, children, type = 'button', showTooltip = true, ...rest }, ref) => ( + + + {children} + + + ) ); export default IconButton; diff --git a/packages/bruno-api-docs/src/ui/IconButton/StyledWrapper.ts b/packages/bruno-api-docs/src/ui/IconButton/StyledWrapper.ts index 9c66fc12..41375dce 100644 --- a/packages/bruno-api-docs/src/ui/IconButton/StyledWrapper.ts +++ b/packages/bruno-api-docs/src/ui/IconButton/StyledWrapper.ts @@ -4,8 +4,8 @@ export const StyledWrapper = styled.button` display: inline-flex; align-items: center; justify-content: center; - width: 28px; - height: 28px; + width: 1.75rem; + height: 1.75rem; padding: 0; flex: none; background: transparent; diff --git a/packages/bruno-api-docs/src/ui/MenuDropdown/StyledWrapper.ts b/packages/bruno-api-docs/src/ui/MenuDropdown/StyledWrapper.ts index ff6513f0..8dd90b92 100644 --- a/packages/bruno-api-docs/src/ui/MenuDropdown/StyledWrapper.ts +++ b/packages/bruno-api-docs/src/ui/MenuDropdown/StyledWrapper.ts @@ -26,17 +26,21 @@ export const StyledWrapper = styled.div` &.menu-dropdown-sm { min-width: 6.875rem; - max-width: 9.375rem !important; - padding: 0.125rem; + padding: 0.25rem; background-color: var(--oc-background-base); border-color: var(--border-color); margin-top: 2px; .dropdown-item { - padding: 0.25rem 0.6rem 0.25rem 0.25rem; + padding: 0.375rem 0.6rem; margin: 1px 0; line-height: 1; } + + .dropdown-icon { + width: 0.8125rem; + height: 0.8125rem; + } } .menu-dropdown-list {