diff --git a/package-lock.json b/package-lock.json index 3876eda9..4295f6e8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1372,6 +1372,24 @@ "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", "license": "MIT" }, + "node_modules/@microlink/react-json-view": { + "version": "1.31.23", + "resolved": "https://registry.npmjs.org/@microlink/react-json-view/-/react-json-view-1.31.23.tgz", + "integrity": "sha512-Qjt7uUpYPDAQiu0LgxyRSuwAgMBwIXtJZ6HSbjTAx6TXu0DdZ/BVIFD5A27z/NcXS2q2zVQgAIxut3WbUdDwRg==", + "license": "MIT", + "dependencies": { + "react-base16-styling": "~0.10.0", + "react-lifecycles-compat": "~3.0.4", + "react-textarea-autosize": "~8.5.9" + }, + "engines": { + "node": ">=17" + }, + "peerDependencies": { + "react": ">= 15", + "react-dom": ">= 15" + } + }, "node_modules/@mintlify/httpsnippet": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/@mintlify/httpsnippet/-/httpsnippet-1.0.10.tgz", @@ -2672,7 +2690,6 @@ "version": "4.17.21", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-FOvQ0YPD5NOfPgMzJihoT+Za5pdkDJWcbpuj1DjaKZIr/gxodQjY/uWEFlTNqW2ugXHUiL8lRQgw63dzKHZdeQ==", - "dev": true, "license": "MIT" }, "node_modules/@types/lodash-es": { @@ -3730,11 +3747,23 @@ "@codemirror/view": "^6.0.0" } }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -3747,9 +3776,18 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, "node_modules/comma-separated-tokens": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", @@ -5747,7 +5785,6 @@ "version": "4.17.22", "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.22.tgz", "integrity": "sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q==", - "dev": true, "license": "MIT" }, "node_modules/lodash.merge": { @@ -7300,6 +7337,18 @@ "node": ">=0.10.0" } }, + "node_modules/react-base16-styling": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/react-base16-styling/-/react-base16-styling-0.10.0.tgz", + "integrity": "sha512-H1k2eFB6M45OaiRru3PBXkuCcn2qNmx+gzLb4a9IPMR7tMH8oBRXU5jGbPDYG1Hz+82d88ED0vjR8BmqU3pQdg==", + "license": "MIT", + "dependencies": { + "@types/lodash": "^4.17.0", + "color": "^4.2.3", + "csstype": "^3.1.3", + "lodash-es": "^4.17.21" + } + }, "node_modules/react-dom": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", @@ -7319,6 +7368,12 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/react-lifecycles-compat": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", + "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==", + "license": "MIT" + }, "node_modules/react-markdown": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", @@ -7417,6 +7472,23 @@ "react-dom": ">=18" } }, + "node_modules/react-textarea-autosize": { + "version": "8.5.9", + "resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.5.9.tgz", + "integrity": "sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.13", + "use-composed-ref": "^1.3.0", + "use-latest": "^1.2.1" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/reactflow": { "version": "11.11.4", "resolved": "https://registry.npmjs.org/reactflow/-/reactflow-11.11.4.tgz", @@ -7860,6 +7932,21 @@ "dev": true, "license": "ISC" }, + "node_modules/simple-swizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/simple-swizzle/node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "license": "MIT" + }, "node_modules/source-map": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", @@ -8461,6 +8548,51 @@ "punycode": "^2.1.0" } }, + "node_modules/use-composed-ref": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/use-composed-ref/-/use-composed-ref-1.4.0.tgz", + "integrity": "sha512-djviaxuOOh7wkj0paeO1Q/4wMZ8Zrnag5H6yBvzN7AKKe8beOaED9SF5/ByLqsku8NP4zQqsvM2u3ew/tJK8/w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-isomorphic-layout-effect": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz", + "integrity": "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-latest": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/use-latest/-/use-latest-1.3.0.tgz", + "integrity": "sha512-mhg3xdm9NaM8q+gLT8KryJPnRFOz1/5XPBhmDEVZK1webPzDjrPk7f/mbpeLqTgB9msytYWANxgALOCJKnLvcQ==", + "license": "MIT", + "dependencies": { + "use-isomorphic-layout-effect": "^1.1.1" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/use-sync-external-store": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", @@ -9853,6 +9985,7 @@ "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", "@faker-js/faker": "^9.7.0", + "@microlink/react-json-view": "^1.31.22", "@mintlify/httpsnippet": "^1.0.10", "@monaco-editor/react": "^4.7.0", "@opencollection/types": "0.11.0", diff --git a/packages/oc-docs/package.json b/packages/oc-docs/package.json index 8cede56d..2fdc2fee 100644 --- a/packages/oc-docs/package.json +++ b/packages/oc-docs/package.json @@ -59,6 +59,7 @@ "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", "@faker-js/faker": "^9.7.0", + "@microlink/react-json-view": "^1.31.22", "@mintlify/httpsnippet": "^1.0.10", "@monaco-editor/react": "^4.7.0", "@opencollection/types": "0.11.0", diff --git a/packages/oc-docs/src/components/Playground/Content/Views/Common/ResponseBodyTab.tsx b/packages/oc-docs/src/components/Playground/Content/Views/Common/ResponseBodyTab.tsx index efdef736..9b56995a 100644 --- a/packages/oc-docs/src/components/Playground/Content/Views/Common/ResponseBodyTab.tsx +++ b/packages/oc-docs/src/components/Playground/Content/Views/Common/ResponseBodyTab.tsx @@ -1,47 +1,47 @@ import React from 'react'; import CodeEditor from '../../../../../ui/CodeEditor/CodeEditor'; +import { ResponseBodyFormat } from '../../../../../utils/response'; +import { RunRequestResponse } from '../../../../../runner'; +import { QueryResultPreview } from '../../../QueryResult/QueryResult'; +import { formatToPreviewMode } from '../../../QueryResult/QueryResultPreview/previewMode'; interface ResponseBodyTabProps { - response: any; + response: RunRequestResponse; + selectedFormat: ResponseBodyFormat; + showPreview: boolean; } -const ResponseBodyTab: React.FC = ({ response }) => { - const formatJson = (data: any) => { - try { - return JSON.stringify(data, null, 2); - } catch { - return String(data); - } - }; +const ResponseBodyTab: React.FC = ({ response, selectedFormat, showPreview }) => { + // The runner parses JSON responses into objects; the editor needs a string. + const editorValue = + response?.data == null + ? '' + : typeof response.data === 'string' + ? response.data + : JSON.stringify(response.data, null, 2); - const responseText = typeof response.data === 'string' ? response.data : formatJson(response.data); - const contentType = response.headers?.['content-type'] || response.headers?.['Content-Type'] || ''; - let language = 'text'; - - if (contentType.includes('application/json') || contentType.includes('text/json')) { - language = 'json'; - } else if (contentType.includes('text/html')) { - language = 'html'; - } else if (contentType.includes('text/xml') || contentType.includes('application/xml')) { - language = 'xml'; - } else if (contentType.includes('text/css')) { - language = 'css'; - } else if (contentType.includes('text/javascript') || contentType.includes('application/javascript')) { - language = 'javascript'; - } - + // The tab-panel is height-constrained; own the scroll here so a tall body + // (e.g. a large JSON tree) scrolls within the response area instead of + // overflowing and being clipped. return ( -
- {}} // Read-only - language={language} - height="100%" - readOnly={true} - /> +
+ {showPreview ? ( + + ) : ( + {}} // Read-only + language={selectedFormat} + height="100%" + readOnly={true} + /> + )}
); }; export default ResponseBodyTab; - diff --git a/packages/oc-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseFormatter/index.tsx b/packages/oc-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseFormatter/index.tsx new file mode 100644 index 00000000..b8261507 --- /dev/null +++ b/packages/oc-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseFormatter/index.tsx @@ -0,0 +1,100 @@ +import type { FC } from 'react'; +import MenuDropdown, { type MenuDropdownItem, type MenuDropdownItems } from '../../../../../../../ui/MenuDropdown'; +import { ResponseBodyFormat } from '../../../../../../../utils/response'; + +interface ResponseFormatSelectorProps { + handleSelection?: (value: ResponseBodyFormat) => void; + selectedFormat?: ResponseBodyFormat; + /** Whether the response is shown as a rendered preview vs the raw editor. */ + showPreview?: boolean; + /** Called when the preview toggle is flipped. */ + onPreviewToggle?: (next: boolean) => void; +} + +/** "Preview" label + switch rendered as the dropdown header (above the formats). */ +const PreviewToggleHeader: FC<{ checked: boolean; onChange: (next: boolean) => void }> = ({ + checked, + onChange +}) => ( +
+ Preview + +
+); + +const FORMAT_GROUPS: Array<{options: Array<{id: ResponseBodyFormat, value: ResponseBodyFormat, text: string}>}> = [ + { + options: [ + { id: 'json', value: 'json', text: 'JSON' }, + { id: 'html', value: 'html', text: 'HTML' }, + { id: 'xml', value: 'xml', text: 'XML' }, + { id: 'javascript', value: 'javascript', text: 'Javascript' } + ] + }, + { + options: [ + { id: 'raw', value: 'raw', text: 'Raw' }, + { id: 'hex', value: 'hex', text: 'Hex' }, + { id: 'base64', value: 'base64', text: 'Base64' } + ] + } +]; + +const ResponseFormatSelector: FC = ({ + handleSelection, + selectedFormat, + showPreview = false, + onPreviewToggle +}) => { + const items: MenuDropdownItems = FORMAT_GROUPS.map((group) => ({ + options: group.options.map(({ id, value, text }) => ({ + id, + label: text, + onClick: () => handleSelection?.(value) + })) + })); + + return ( + item.label} + placement="bottom-start" + header={ onPreviewToggle?.(next)} />} + testId="response-format-selector" + /> + ); +}; + +export default ResponseFormatSelector; diff --git a/packages/oc-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseFormatter/useResponseFormatter.ts b/packages/oc-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseFormatter/useResponseFormatter.ts new file mode 100644 index 00000000..109401ae --- /dev/null +++ b/packages/oc-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseFormatter/useResponseFormatter.ts @@ -0,0 +1,27 @@ +import { useCallback, useMemo, useState } from 'react'; +import { RunRequestResponse } from '../../../../../../../runner'; +import { ResponseBodyFormat, useInitialResponseFormat } from '../../../../../../../utils/response'; + +export function useResponseFormatter(response: RunRequestResponse) { + const { format, view } = useInitialResponseFormat(response?.headers); + const [userSelectedFormat, setUserSelectedFormat] = useState(); + const [showPreview, setShowPreview] = useState(view === 'preview'); + + const handleFormatChange = useCallback((format: ResponseBodyFormat) => { + setUserSelectedFormat(format); + }, []); + + const handleViewChange = useCallback((showPreview: boolean) => { + setShowPreview(showPreview); + }, []); + + return useMemo(() => { + const selectedFormat = userSelectedFormat ?? format; + return { + selectedFormat, + showPreview, + handleFormatChange, + handleViewChange + }; + }, [handleFormatChange, handleViewChange, format, userSelectedFormat, showPreview]); +} diff --git a/packages/oc-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponsePane.tsx b/packages/oc-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponsePane.tsx index 7eaabd34..63f8647d 100644 --- a/packages/oc-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponsePane.tsx +++ b/packages/oc-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponsePane.tsx @@ -4,15 +4,20 @@ import ResponseBodyTab from '../../Common/ResponseBodyTab'; import ResponseHeadersTab from '../../Common/ResponseHeadersTab'; import TestResultsTab from '../../Common/TestResultsTab'; import ErrorBanner from '../../../../../../ui/ErrorBanner/ErrorBanner'; +import ResponseFormatSelector from './ResponseFormatter'; +import { useResponseFormatter } from './ResponseFormatter/useResponseFormatter'; +import { RunRequestResponse } from '../../../../../../runner'; +import type { ResponseBodyFormat } from '../../../../../../utils/response'; import { StyledWrapper } from './StyledWrapper'; interface ResponsePaneProps { - response: any; + response: RunRequestResponse; isLoading: boolean; } const ResponsePane: React.FC = ({ response, isLoading }) => { const [activeTab, setActiveTab] = useState('response'); + const { selectedFormat, showPreview, handleFormatChange, handleViewChange } = useResponseFormatter(response); const getStatusColor = (status?: number) => { if (!status) return 'var(--oc-request-tab-panel-response-status)'; @@ -56,13 +61,15 @@ const ResponsePane: React.FC = ({ response, isLoading }) => {
); const renderResponseBody = () => - response.error ? renderErrorBanner() : ; + response.error ? renderErrorBanner() : ( + + ); const renderHeaders = () => ; const renderTestResults = () => ( = ({ response, isLoading }) => { const statusInfo = (
+ handleFormatChange(value)} + showPreview={showPreview} + onPreviewToggle={handleViewChange} + />
Status: ` so relative links/resources resolve. */ + baseUrl?: string; +} + +const escapeHtml = (value: string): string => + value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + +const toHtmlString = (data: unknown): string => { + if (data === null || data === undefined) return String(data); + if (typeof data === 'object') { + try { + return JSON.stringify(data); + } catch { + return String(data); + } + } + return String(data); +}; + +/** + * Renders an HTML response inside a sandboxed iframe. Unlike bruno-app (which + * uses an Electron ``), this runs in the browser, so a sandboxed + * iframe with `srcDoc` is used. Scripts are disabled (no `allow-scripts`) so + * untrusted response HTML cannot execute. + */ +const HtmlPreview = memo(({ data, baseUrl = '' }: HtmlPreviewProps) => { + const srcDoc = useMemo(() => { + const html = toHtmlString(data); + const baseTag = ``; + return html.includes('') + ? html.replace('', `${baseTag}`) + : `${baseTag}${html}`; + }, [data, baseUrl]); + + return ( +
+