mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-29 15:44:13 +00:00
feat: add response data type selector in response viewer (#6100)
* feat: add response data type selector in response viewer * chore: fixed lint issue * test: add test for resonse format change and preview. * refactor: streamline response format tests with utility functions for navigation and format switching * refactor: simplify ButtonDropdown component and enhance QueryResultTypeSelector with header and toggle switch * feat: enhance ButtonDropdown with prefix and suffix props; implement content type detection and update QueryResult for improved format handling * fix: lint errors resolved * fix: remove unnecessary blank line to resolve lint issues * fix: update response format tests * refactor: remove preview tab locator from response format tests * fix: update dependency in useEffect to include previewFormatOptions for accurate format handling * refactor: reorganize imports and enhance QueryResult component for improved format handling and error display * fix: update error messages in response format preview tests and adjust version in JSON fixture * feat: add drag detection to HtmlPreview component and update structure for improved user interaction * refactor: update ResponsePane components for improved structure and functionality; replace QueryResult with QueryResponse, enhance layout handling, and streamline response actions * refactor: remove ButtonDropdown component and associated styles; * refactor: moved ErrorAlert to ui folder * fix: lint error * feat: add data-testid attributes to Collection and CollectionItem components for improved testability * feat: hide dropdown on select in response selector * fix: update QueryResult component to use detectedContentType for format handling * test: update ResponseLayoutToggle tests to use data-testid for button selection * feat: add data-testid attribute to ResponseClear component for improved testability * refactor: implement clickResponseAction utility for streamlined response action handling in tests * feat: add data-testid attribute to ResponseCopy component for enhanced testability * fix: unwanted code in test
This commit is contained in:
@@ -168,7 +168,7 @@ const StyledWrapper = styled.div`
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
|
||||
|
||||
td {
|
||||
padding: 8px 12px;
|
||||
font-weight: 500;
|
||||
@@ -256,10 +256,8 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
|
||||
.response-body-container {
|
||||
border: 1px solid ${(props) => props.theme.console.border};
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background: ${(props) => props.theme.console.headerBg};
|
||||
height: 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -267,13 +265,11 @@ const StyledWrapper = styled.div`
|
||||
.w-full.h-full.relative.flex {
|
||||
height: 100% !important;
|
||||
width: 100% !important;
|
||||
background: ${(props) => props.theme.console.headerBg} !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
}
|
||||
|
||||
div[role="tablist"] {
|
||||
background: ${(props) => props.theme.console.dropdownHeaderBg};
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid ${(props) => props.theme.console.border};
|
||||
display: flex !important;
|
||||
@@ -282,28 +278,17 @@ const StyledWrapper = styled.div`
|
||||
align-items: center !important;
|
||||
min-height: 40px !important;
|
||||
flex-shrink: 0 !important;
|
||||
|
||||
|
||||
> div {
|
||||
color: ${(props) => props.theme.console.buttonColor};
|
||||
font-size: ${(props) => props.theme.font.size.sm} !important;
|
||||
padding: 6px 12px !important;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
border: 1px solid ${(props) => props.theme.console.border};
|
||||
background: ${(props) => props.theme.console.contentBg};
|
||||
white-space: nowrap !important;
|
||||
min-width: auto !important;
|
||||
height: auto !important;
|
||||
line-height: 1.2 !important;
|
||||
font-weight: 500 !important;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.console.buttonHoverBg};
|
||||
color: ${(props) => props.theme.console.buttonHoverColor};
|
||||
border-color: ${(props) => props.theme.console.buttonHoverBg};
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: ${(props) => props.theme.console.checkboxColor};
|
||||
color: white;
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
IconNetwork
|
||||
} from '@tabler/icons';
|
||||
import { clearSelectedRequest } from 'providers/ReduxStore/slices/logs';
|
||||
import QueryResult from 'components/ResponsePane/QueryResult';
|
||||
import QueryResponse from 'components/ResponsePane/QueryResponse/index';
|
||||
import Network from 'components/ResponsePane/Timeline/TimelineItem/Network';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { uuid } from 'utils/common/index';
|
||||
@@ -116,7 +116,7 @@ const ResponseTab = ({ response, request, collection }) => {
|
||||
<h4>Response Body</h4>
|
||||
<div className="response-body-container">
|
||||
{response?.data || response?.dataBuffer ? (
|
||||
<QueryResult
|
||||
<QueryResponse
|
||||
item={{ uid: uuid() }}
|
||||
collection={collection}
|
||||
data={response.data}
|
||||
|
||||
@@ -15,7 +15,7 @@ const ClearTimeline = ({ collection, item }) => {
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledWrapper className="ml-2 flex items-center">
|
||||
<StyledWrapper className="flex items-center">
|
||||
<button onClick={clearResponse} className="text-link hover:underline" title="Clear Timeline">
|
||||
Clear Timeline
|
||||
</button>
|
||||
|
||||
@@ -10,7 +10,6 @@ import GrpcStatusCode from './GrpcStatusCode';
|
||||
import ResponseTime from '../ResponseTime/index';
|
||||
import Timeline from '../Timeline';
|
||||
import ClearTimeline from '../ClearTimeline';
|
||||
import ResponseSave from '../ResponseSave';
|
||||
import ResponseClear from '../ResponseClear';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import ResponseTrailers from './ResponseTrailers';
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
border-radius: 4px;
|
||||
border: 1px solid ${(props) => props.theme.console.border};
|
||||
|
||||
.query-response-content {
|
||||
border-top: 1px solid ${(props) => props.theme.console.border};
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,60 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import QueryResult from '../QueryResult';
|
||||
import { useInitialResponseFormat, useResponsePreviewFormatOptions } from '../QueryResult/index';
|
||||
import QueryResultTypeSelector from '../QueryResult/QueryResultTypeSelector/index';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import classnames from 'classnames';
|
||||
|
||||
const QueryResponse = ({
|
||||
item,
|
||||
collection,
|
||||
data,
|
||||
dataBuffer,
|
||||
disableRunEventListener,
|
||||
headers,
|
||||
error
|
||||
}) => {
|
||||
const { initialFormat, initialTab } = useInitialResponseFormat(dataBuffer, headers);
|
||||
const previewFormatOptions = useResponsePreviewFormatOptions(dataBuffer, headers);
|
||||
const [selectedFormat, setSelectedFormat] = useState('raw');
|
||||
const [selectedTab, setSelectedTab] = useState('editor');
|
||||
|
||||
useEffect(() => {
|
||||
if (initialFormat !== null && initialTab !== null) {
|
||||
setSelectedFormat(initialFormat);
|
||||
setSelectedTab(initialTab);
|
||||
}
|
||||
}, [initialFormat, initialTab]);
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="flex items-center justify-end p-2">
|
||||
<QueryResultTypeSelector
|
||||
formatOptions={previewFormatOptions}
|
||||
formatValue={selectedFormat}
|
||||
onFormatChange={(newFormat) => {
|
||||
setSelectedFormat(newFormat);
|
||||
}}
|
||||
onPreviewTabSelect={() => {
|
||||
setSelectedTab((prev) => prev === 'editor' ? 'preview' : 'editor');
|
||||
}}
|
||||
selectedTab={selectedTab}
|
||||
/>
|
||||
</div>
|
||||
<div className={classnames('flex-1 query-response-content', selectedTab === 'editor' ? 'px-2 py-1' : '')}>
|
||||
<QueryResult
|
||||
item={item}
|
||||
collection={collection}
|
||||
data={data}
|
||||
dataBuffer={dataBuffer}
|
||||
disableRunEventListener={disableRunEventListener}
|
||||
headers={headers}
|
||||
error={error}
|
||||
selectedFormat={selectedFormat}
|
||||
selectedTab={selectedTab}
|
||||
/>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default QueryResponse;
|
||||
@@ -0,0 +1,78 @@
|
||||
import React, { useRef, useState, useEffect } from 'react';
|
||||
import { isValidHtml } from 'utils/common/index';
|
||||
import { escapeHtml, isValidHtmlSnippet } from 'utils/response/index';
|
||||
|
||||
const HtmlPreview = React.memo(({ data, baseUrl }) => {
|
||||
const webviewContainerRef = useRef(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!webviewContainerRef.current) return;
|
||||
|
||||
const checkDragging = () => {
|
||||
const hasDraggingParent = webviewContainerRef.current?.closest('.dragging');
|
||||
setIsDragging(!!hasDraggingParent);
|
||||
};
|
||||
|
||||
// Watch from a common ancestor where .dragging gets added
|
||||
const watchTarget = webviewContainerRef.current.closest('.main-section')
|
||||
|| document.body;
|
||||
|
||||
const mutationObserver = new MutationObserver(checkDragging);
|
||||
mutationObserver.observe(watchTarget, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class'],
|
||||
subtree: true
|
||||
});
|
||||
|
||||
// Check initial state
|
||||
checkDragging();
|
||||
|
||||
return () => mutationObserver.disconnect();
|
||||
}, []);
|
||||
|
||||
if (isValidHtml(data) || isValidHtmlSnippet(data)) {
|
||||
const htmlContent = data.includes('<head>')
|
||||
? data.replace('<head>', `<head><base href="${escapeHtml(baseUrl)}">`)
|
||||
: `<head><base href="${escapeHtml(baseUrl)}"></head>${data}`;
|
||||
|
||||
const dragStyles = isDragging ? { pointerEvents: 'none', userSelect: 'none' } : {};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={webviewContainerRef}
|
||||
className="h-full bg-white webview-container"
|
||||
style={dragStyles}
|
||||
>
|
||||
<webview
|
||||
src={`data:text/html; charset=utf-8,${encodeURIComponent(htmlContent)}`}
|
||||
webpreferences="disableDialogs=true, javascript=yes"
|
||||
className="h-full bg-white"
|
||||
style={dragStyles}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// For all other data types, render safely as formatted text
|
||||
let displayContent = '';
|
||||
if (data === null || data === undefined) {
|
||||
displayContent = String(data);
|
||||
} else if (typeof data === 'object') {
|
||||
displayContent = JSON.stringify(data, null);
|
||||
} else if (typeof data === 'string') {
|
||||
displayContent = data;
|
||||
} else {
|
||||
displayContent = String(data);
|
||||
}
|
||||
|
||||
return (
|
||||
<pre
|
||||
className="bg-white font-mono text-[13px] whitespace-pre-wrap break-words overflow-auto overflow-x-hidden p-4 text-[#24292f] w-full max-w-full h-full box-border relative"
|
||||
>
|
||||
{displayContent}
|
||||
</pre>
|
||||
);
|
||||
});
|
||||
|
||||
export default HtmlPreview;
|
||||
@@ -0,0 +1,63 @@
|
||||
import React from 'react';
|
||||
import ReactJson from 'react-json-view';
|
||||
import ErrorAlert from 'ui/ErrorAlert/index';
|
||||
|
||||
const JsonPreview = ({ data, displayedTheme }) => {
|
||||
// Helper function to validate and parse JSON data
|
||||
const validateJsonData = (data) => {
|
||||
// If data is already an object or array, use it directly
|
||||
if (typeof data === 'object' && data !== null) {
|
||||
return { data, error: null };
|
||||
}
|
||||
|
||||
// If data is a string, try to parse it
|
||||
if (typeof data === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
return { data: parsed, error: null };
|
||||
} catch (e) {
|
||||
return { data: null, error: `Invalid JSON format: ${e.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
// For other types, return error
|
||||
return { data: null, error: 'Invalid input. Expected a JSON object, array, or valid JSON string.' };
|
||||
};
|
||||
|
||||
// Validate and parse JSON data
|
||||
const jsonData = validateJsonData(data);
|
||||
|
||||
// Show error if parsing failed
|
||||
if (jsonData.error) {
|
||||
return <ErrorAlert title="Cannot preview as JSON" message={jsonData.error} />;
|
||||
}
|
||||
|
||||
// Validate that data can be rendered as JSON tree
|
||||
if (jsonData.data === null || jsonData.data === undefined) {
|
||||
return <ErrorAlert title="Cannot preview as JSON" message="Data is null or undefined. Expected a valid JSON object or array." />;
|
||||
}
|
||||
|
||||
if (typeof jsonData.data !== 'object') {
|
||||
return <ErrorAlert title="Cannot preview as JSON" message="Data cannot be rendered as a JSON tree. Expected a JSON object or array." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ReactJson
|
||||
src={jsonData.data}
|
||||
theme={displayedTheme === 'light' ? 'rjv-default' : 'monokai'}
|
||||
collapsed={1}
|
||||
displayDataTypes={false}
|
||||
displayObjectSize={true}
|
||||
enableClipboard={true}
|
||||
name={false}
|
||||
style={{
|
||||
backgroundColor: 'transparent',
|
||||
fontSize: '12px',
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
|
||||
padding: '16px'
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default JsonPreview;
|
||||
@@ -0,0 +1,25 @@
|
||||
import React, { memo, useMemo } from 'react';
|
||||
|
||||
const TextPreview = memo(({ data }) => {
|
||||
const displayData = useMemo(() => {
|
||||
if (data === null || data === undefined) {
|
||||
return String(data);
|
||||
}
|
||||
if (typeof data === 'object') {
|
||||
try {
|
||||
return JSON.stringify(data);
|
||||
} catch {
|
||||
return String(data);
|
||||
}
|
||||
}
|
||||
return String(data);
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<div className="p-4 font-mono text-[13px] whitespace-pre-wrap break-words overflow-auto overflow-x-hidden w-full max-w-full h-full">
|
||||
{displayData}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default TextPreview;
|
||||
@@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import ReactPlayer from 'react-player';
|
||||
|
||||
const VideoPreview = React.memo(({ contentType, dataBuffer }) => {
|
||||
const [videoUrl, setVideoUrl] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const videoType = contentType.split(';')[0];
|
||||
const byteArray = Buffer.from(dataBuffer, 'base64');
|
||||
const blob = new Blob([byteArray], { type: videoType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
setVideoUrl(url);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
}, [contentType, dataBuffer]);
|
||||
|
||||
if (!videoUrl) return <div>Loading video...</div>;
|
||||
|
||||
return (
|
||||
<ReactPlayer
|
||||
url={videoUrl}
|
||||
controls
|
||||
muted={true}
|
||||
width="100%"
|
||||
height="100%"
|
||||
onError={(e) => console.error('Error loading video:', e)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export default VideoPreview;
|
||||
@@ -0,0 +1,77 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
padding: 16px;
|
||||
overflow: auto;
|
||||
color: ${(props) => props.theme.text};
|
||||
|
||||
.xml-container {
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
.xml-node-name {
|
||||
color: ${(props) => props.theme.codemirror.tokens.property};
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.xml-separator {
|
||||
color: ${(props) => props.theme.codemirror.tokens.operator};
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
.xml-value {
|
||||
color: ${(props) => props.theme.codemirror.tokens.string};
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.xml-empty-value {
|
||||
color: ${(props) => props.theme.codemirror.tokens.comment};
|
||||
}
|
||||
|
||||
.xml-count {
|
||||
color: ${(props) => props.theme.codemirror.tokens.comment};
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.xml-toggle-button {
|
||||
margin-right: 8px;
|
||||
cursor: pointer;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: ${(props) => props.theme.codemirror.tokens.atom};
|
||||
flex-shrink: 0;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: ${(props) => props.theme.console.buttonHoverBg};
|
||||
}
|
||||
}
|
||||
|
||||
.xml-array-toggle-button {
|
||||
margin-right: 8px;
|
||||
cursor: pointer;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: ${(props) => props.theme.codemirror.tokens.atom};
|
||||
flex-shrink: 0;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: ${(props) => props.theme.console.buttonHoverBg};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,396 @@
|
||||
import ErrorAlert from 'ui/ErrorAlert/index';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
// The expected "data" prop must be an XML string.
|
||||
export default function XmlPreview({ data, defaultExpanded = true }) {
|
||||
// Parse XML string
|
||||
const parsedData = useMemo(() => {
|
||||
if (typeof data !== 'string') {
|
||||
return { error: 'Invalid input. Expected an XML string.' };
|
||||
}
|
||||
|
||||
const parsed = parseXMLString(data);
|
||||
if (parsed === null) {
|
||||
return { error: 'Failed to parse XML string. Invalid XML format.' };
|
||||
}
|
||||
return parsed;
|
||||
}, [data]);
|
||||
|
||||
// Check for parsing error
|
||||
if (parsedData && typeof parsedData === 'object' && parsedData.error) {
|
||||
return (
|
||||
<ErrorAlert title="Cannot preview as XML" message={parsedData.error} />
|
||||
);
|
||||
}
|
||||
|
||||
// Validate that data can be rendered as a tree
|
||||
const isValidTreeData = (data) => {
|
||||
if (data === null || data === undefined) return false;
|
||||
if (typeof data === 'object' && !Array.isArray(data)) return true;
|
||||
if (Array.isArray(data)) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
if (!isValidTreeData(parsedData)) {
|
||||
return (
|
||||
<ErrorAlert title="Cannot preview as XML" message="Data cannot be rendered as a tree. Expected a valid XML string." />
|
||||
);
|
||||
}
|
||||
|
||||
// If root is an object with a single key, unwrap it to show the actual root element
|
||||
let rootNode = parsedData;
|
||||
let rootNodeName = '';
|
||||
|
||||
if (typeof parsedData === 'object' && !Array.isArray(parsedData) && parsedData !== null) {
|
||||
const keys = Object.keys(parsedData).filter((k) => k !== '$' && k !== '@_' && k !== '#text');
|
||||
if (keys.length === 1) {
|
||||
rootNodeName = keys[0];
|
||||
rootNode = parsedData[keys[0]];
|
||||
} else if (keys.length === 0) {
|
||||
// Empty object with no children
|
||||
return (
|
||||
<ErrorAlert title="Cannot preview as XML" message="Cannot render XML tree. Root object is empty." />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="xml-container">
|
||||
<XmlNode
|
||||
node={rootNode}
|
||||
nodeName={rootNodeName}
|
||||
isRoot={true}
|
||||
isLast={true}
|
||||
defaultExpanded={defaultExpanded}
|
||||
/>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
// Component for rendering array entries with expand/collapse functionality
|
||||
const XmlArrayNode = ({ arrayKey, items, depth, defaultExpanded = true }) => {
|
||||
const [expanded, setExpanded] = useState(defaultExpanded);
|
||||
|
||||
const toggle = (e) => {
|
||||
e.stopPropagation();
|
||||
setExpanded((v) => !v);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ paddingLeft: `${(depth + 1) * 20}px` }}>
|
||||
<div className="flex items-center mb-1">
|
||||
<button
|
||||
onClick={toggle}
|
||||
className="xml-array-toggle-button"
|
||||
tabIndex={-1}
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
{expanded ? '▼' : '▶'}
|
||||
</button>
|
||||
<span className="xml-node-name">{arrayKey}</span>
|
||||
<span className="xml-count">[{items.length}]</span>
|
||||
</div>
|
||||
{expanded && (
|
||||
<div className="array-content">
|
||||
{items.map((item, itemIdx) => (
|
||||
<XmlNode
|
||||
key={`${arrayKey}-${itemIdx}`}
|
||||
node={item}
|
||||
nodeName={`${itemIdx}`}
|
||||
isLast={itemIdx === items.length - 1}
|
||||
defaultExpanded={false}
|
||||
depth={depth + 2}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const XmlNode = ({
|
||||
node,
|
||||
nodeName = '',
|
||||
isRoot = false,
|
||||
isLast = true,
|
||||
defaultExpanded = true,
|
||||
depth = 0
|
||||
}) => {
|
||||
const [expanded, setExpanded] = useState(defaultExpanded);
|
||||
|
||||
let displayNodeName = nodeName;
|
||||
|
||||
if (Array.isArray(node)) {
|
||||
// For repeated XML elements with same name (e.g. <item>...</item><item>...</item>)
|
||||
return (
|
||||
<>
|
||||
{node.map((item, idx) => (
|
||||
<XmlNode
|
||||
key={idx}
|
||||
node={item}
|
||||
nodeName={displayNodeName}
|
||||
isRoot={false}
|
||||
isLast={idx === node.length - 1}
|
||||
defaultExpanded={false}
|
||||
depth={depth}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const childEntries = getChildrenEntries(node);
|
||||
const childCount = getChildCount(node);
|
||||
const isLeaf = isTextNode(node) || (typeof node === 'object' && childCount === 0);
|
||||
|
||||
const toggle = (e) => {
|
||||
e.stopPropagation();
|
||||
setExpanded((v) => !v);
|
||||
};
|
||||
|
||||
// For leaf nodes with text content or attributes with empty values
|
||||
if (isLeaf && isTextNode(node)) {
|
||||
const value = String(node);
|
||||
|
||||
return (
|
||||
<div className="flex items-start mb-1" style={{ paddingLeft: `${depth * 20}px` }}>
|
||||
{displayNodeName && (
|
||||
<>
|
||||
<span className="xml-node-name">{displayNodeName}</span>
|
||||
<span className="xml-separator">:</span>
|
||||
</>
|
||||
)}
|
||||
<span className="xml-value">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// For empty leaf nodes (attributes without values, etc)
|
||||
if (isLeaf && !isTextNode(node)) {
|
||||
// Check if this is an attribute-only node with _text
|
||||
if (typeof node === 'object' && node !== null && '_text' in node) {
|
||||
// This node has both attributes and text, handle in expandable section
|
||||
// Fall through to expandable node rendering
|
||||
} else {
|
||||
return (
|
||||
<div className="flex items-center mb-1" style={{ paddingLeft: `${depth * 20}px` }}>
|
||||
{displayNodeName && (
|
||||
<>
|
||||
<span className="xml-node-name">{displayNodeName}</span>
|
||||
<span className="xml-separator">:</span>
|
||||
<span className="xml-empty-value">{'{}'}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// For expandable nodes - show as tree structure
|
||||
// If no node name at root level, render children directly
|
||||
if (!displayNodeName && depth === 0) {
|
||||
if (childEntries.length > 0) {
|
||||
return (
|
||||
<div>
|
||||
{childEntries.map(([key, value], idx) => (
|
||||
<XmlNode
|
||||
key={key + idx}
|
||||
node={value}
|
||||
nodeName={key}
|
||||
isLast={idx === childEntries.length - 1}
|
||||
defaultExpanded={defaultExpanded}
|
||||
depth={0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// If no display name at non-root level, use a fallback
|
||||
if (!displayNodeName) {
|
||||
displayNodeName = '(unnamed)';
|
||||
}
|
||||
|
||||
// Determine if this node's value is an array
|
||||
const hasArrayValue = Array.isArray(node);
|
||||
const arrayLength = hasArrayValue ? node.length : 0;
|
||||
|
||||
return (
|
||||
<div style={{ paddingLeft: `${depth * 20}px` }}>
|
||||
<div className="flex items-center mb-1">
|
||||
<button
|
||||
onClick={toggle}
|
||||
className="xml-toggle-button"
|
||||
tabIndex={-1}
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
{expanded ? '▼' : '▶'}
|
||||
</button>
|
||||
|
||||
<span className="xml-node-name">
|
||||
{displayNodeName}
|
||||
</span>
|
||||
|
||||
{childCount > 0 && (
|
||||
<span className="xml-count">
|
||||
{`{${childCount}}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{expanded && childEntries.length > 0 && (
|
||||
<div>
|
||||
{childEntries.map(([key, value], idx) => {
|
||||
// Check if this is an attribute (starts with _)
|
||||
const isAttribute = key.startsWith('_');
|
||||
|
||||
// Handle attributes
|
||||
if (isAttribute) {
|
||||
const displayValue = value === '' ? 'value' : value;
|
||||
|
||||
return (
|
||||
<div key={key + idx} className="flex items-start mb-1" style={{ paddingLeft: `${(depth + 1) * 20}px` }}>
|
||||
<span className="xml-node-name">{key}</span>
|
||||
<span className="xml-separator">:</span>
|
||||
<span className={value === '' ? 'xml-empty-value' : 'xml-value'}>{displayValue}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Check if this child is an array
|
||||
const isArrayChild = Array.isArray(value);
|
||||
|
||||
if (isArrayChild) {
|
||||
return (
|
||||
<XmlArrayNode
|
||||
key={`${key}-${idx}`}
|
||||
arrayKey={key}
|
||||
items={value}
|
||||
depth={depth}
|
||||
defaultExpanded={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<XmlNode
|
||||
key={key + idx}
|
||||
node={value}
|
||||
nodeName={key}
|
||||
isLast={idx === childEntries.length - 1}
|
||||
defaultExpanded={false}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Helper function to parse XML string to object
|
||||
function parseXMLString(xmlString) {
|
||||
if (typeof xmlString !== 'string') return null;
|
||||
|
||||
try {
|
||||
const parser = new DOMParser();
|
||||
// Parse as XML only
|
||||
const xmlDoc = parser.parseFromString(xmlString, 'text/xml');
|
||||
|
||||
// Check for parsing errors
|
||||
const parserError = xmlDoc.querySelector('parsererror');
|
||||
if (parserError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Convert XML DOM to object
|
||||
function xmlToObject(node) {
|
||||
if (node.nodeType !== 1) return null; // Not an element node
|
||||
|
||||
const result = {};
|
||||
|
||||
// Get attributes - store them directly with underscore prefix
|
||||
if (node.attributes && node.attributes.length > 0) {
|
||||
for (let i = 0; i < node.attributes.length; i++) {
|
||||
const attr = node.attributes[i];
|
||||
result[`_${attr.name}`] = attr.value || '';
|
||||
}
|
||||
}
|
||||
|
||||
// Get child nodes
|
||||
const childNodes = Array.from(node.childNodes);
|
||||
const elementChildren = childNodes.filter((child) => child.nodeType === 1);
|
||||
const textChildren = childNodes.filter((child) => child.nodeType === 3 && child.textContent.trim());
|
||||
|
||||
// If only text children and no element children, return text content
|
||||
if (elementChildren.length === 0 && textChildren.length > 0) {
|
||||
const textContent = textChildren.map((t) => t.textContent.trim()).join(' ').trim();
|
||||
// If has attributes, store text as a special property
|
||||
if (Object.keys(result).length > 0) {
|
||||
result['_text'] = textContent;
|
||||
return result;
|
||||
}
|
||||
return textContent || null;
|
||||
}
|
||||
|
||||
// Process element children
|
||||
if (elementChildren.length > 0) {
|
||||
const childMap = {};
|
||||
elementChildren.forEach((child) => {
|
||||
const childName = child.nodeName; // Preserve original casing
|
||||
const childValue = xmlToObject(child);
|
||||
|
||||
if (childValue !== null || elementChildren.filter((c) => c.nodeName.toLowerCase() === childName).length > 1) {
|
||||
if (childMap[childName]) {
|
||||
// Multiple children with same name - convert to array
|
||||
if (!Array.isArray(childMap[childName])) {
|
||||
childMap[childName] = [childMap[childName]];
|
||||
}
|
||||
childMap[childName].push(childValue);
|
||||
} else {
|
||||
childMap[childName] = childValue;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Merge children into result
|
||||
Object.assign(result, childMap);
|
||||
}
|
||||
|
||||
return Object.keys(result).length > 0 ? result : null;
|
||||
}
|
||||
|
||||
const rootElement = xmlDoc.documentElement;
|
||||
if (!rootElement) return null;
|
||||
|
||||
const parsed = xmlToObject(rootElement);
|
||||
return parsed ? { [rootElement.nodeName]: parsed } : null;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isTextNode(node) {
|
||||
return typeof node === 'string' || typeof node === 'number' || node === null;
|
||||
}
|
||||
|
||||
function getChildrenEntries(node) {
|
||||
// Given an XML-like JS object, return an array of [key, value] for all properties
|
||||
// This includes attributes (with _ prefix) and child elements
|
||||
if (typeof node !== 'object' || node === null) return [];
|
||||
return Object.entries(node);
|
||||
}
|
||||
|
||||
function getChildCount(node) {
|
||||
if (Array.isArray(node)) {
|
||||
return node.length;
|
||||
}
|
||||
const children = getChildrenEntries(node);
|
||||
return children.length;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import CodeEditor from 'components/CodeEditor/index';
|
||||
import { get } from 'lodash';
|
||||
import find from 'lodash/find';
|
||||
@@ -11,44 +11,22 @@ import 'react-pdf/dist/esm/Page/AnnotationLayer.css';
|
||||
import 'react-pdf/dist/esm/Page/TextLayer.css';
|
||||
import { GlobalWorkerOptions } from 'pdfjs-dist/build/pdf';
|
||||
GlobalWorkerOptions.workerSrc = 'pdfjs-dist/legacy/build/pdf.worker.min.mjs';
|
||||
import ReactPlayer from 'react-player';
|
||||
|
||||
const VideoPreview = React.memo(({ contentType, dataBuffer }) => {
|
||||
const [videoUrl, setVideoUrl] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const videoType = contentType.split(';')[0];
|
||||
const byteArray = Buffer.from(dataBuffer, 'base64');
|
||||
const blob = new Blob([byteArray], { type: videoType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
setVideoUrl(url);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
}, [contentType, dataBuffer]);
|
||||
|
||||
if (!videoUrl) return <div>Loading video...</div>;
|
||||
|
||||
return (
|
||||
<ReactPlayer
|
||||
url={videoUrl}
|
||||
controls
|
||||
muted={true}
|
||||
width="100%"
|
||||
height="100%"
|
||||
onError={(e) => console.error('Error loading video:', e)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
import XmlPreview from './XmlPreview/index';
|
||||
import TextPreview from './TextPreview';
|
||||
import HtmlPreview from './HtmlPreview';
|
||||
import VideoPreview from './VideoPreview';
|
||||
import JsonPreview from './JsonPreview';
|
||||
|
||||
const QueryResultPreview = ({
|
||||
previewTab,
|
||||
allowedPreviewModes,
|
||||
selectedTab,
|
||||
data,
|
||||
dataBuffer,
|
||||
formattedData,
|
||||
item,
|
||||
contentType,
|
||||
collection,
|
||||
mode,
|
||||
codeMirrorMode,
|
||||
previewMode,
|
||||
disableRunEventListener,
|
||||
displayedTheme
|
||||
}) => {
|
||||
@@ -63,10 +41,6 @@ const QueryResultPreview = ({
|
||||
function onDocumentLoadSuccess({ numPages }) {
|
||||
setNumPages(numPages);
|
||||
}
|
||||
// Fail safe, so we don't render anything with an invalid tab
|
||||
if (!allowedPreviewModes.find((previewMode) => previewMode?.uid == previewTab?.uid)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const onRun = () => {
|
||||
if (disableRunEventListener) {
|
||||
@@ -87,19 +61,31 @@ const QueryResultPreview = ({
|
||||
);
|
||||
};
|
||||
|
||||
switch (previewTab?.mode) {
|
||||
if (selectedTab === 'editor') {
|
||||
return (
|
||||
<CodeEditor
|
||||
collection={collection}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
theme={displayedTheme}
|
||||
onRun={onRun}
|
||||
onSave={onSave}
|
||||
onScroll={onScroll}
|
||||
value={formattedData}
|
||||
mode={codeMirrorMode}
|
||||
initialScroll={focusedTab.responsePaneScrollPosition || 0}
|
||||
readOnly
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
switch (previewMode) {
|
||||
case 'preview-web': {
|
||||
const webViewSrc = data.replace('<head>', `<head><base href="${item.requestSent?.url || ''}">`);
|
||||
return (
|
||||
<webview
|
||||
src={`data:text/html; charset=utf-8,${encodeURIComponent(webViewSrc)}`}
|
||||
webpreferences="disableDialogs=true, javascript=yes"
|
||||
className="h-full bg-white"
|
||||
/>
|
||||
);
|
||||
const baseUrl = item.requestSent?.url || '';
|
||||
return <HtmlPreview data={data} baseUrl={baseUrl} />;
|
||||
}
|
||||
case 'preview-image': {
|
||||
return <img src={`data:${contentType.replace(/\;(.*)/, '')};base64,${dataBuffer}`} className="mx-auto" />;
|
||||
return <img src={`data:${contentType.replace(/\;(.*)/, '')};base64,${dataBuffer}`} />;
|
||||
}
|
||||
case 'preview-pdf': {
|
||||
return (
|
||||
@@ -120,24 +106,29 @@ const QueryResultPreview = ({
|
||||
case 'preview-video': {
|
||||
return <VideoPreview contentType={contentType} dataBuffer={dataBuffer} />;
|
||||
}
|
||||
default:
|
||||
case 'raw': {
|
||||
return (
|
||||
<CodeEditor
|
||||
collection={collection}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
theme={displayedTheme}
|
||||
onRun={onRun}
|
||||
onSave={onSave}
|
||||
onScroll={onScroll}
|
||||
value={formattedData}
|
||||
mode={mode}
|
||||
initialScroll={focusedTab.responsePaneScrollPosition || 0}
|
||||
readOnly
|
||||
/>
|
||||
);
|
||||
case 'preview-json': {
|
||||
return <JsonPreview data={data} displayedTheme={displayedTheme} />;
|
||||
}
|
||||
|
||||
case 'preview-text': {
|
||||
return <TextPreview data={data} />;
|
||||
}
|
||||
|
||||
case 'preview-xml': {
|
||||
return <XmlPreview data={data} />;
|
||||
}
|
||||
|
||||
default:
|
||||
return (
|
||||
<div className="p-4 flex flex-col items-center justify-center h-full text-center">
|
||||
<div className="text-lg font-semibold text-gray-700 dark:text-gray-200 mb-2">
|
||||
No Preview Available
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Sorry, no preview is available for this content type.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.active {
|
||||
color: ${(props) => props.theme.colors.text.yellow};
|
||||
}
|
||||
|
||||
.preview-response-tab-label {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,46 @@
|
||||
import React from 'react';
|
||||
import { IconEye } from '@tabler/icons';
|
||||
import ButtonDropdown from 'ui/ButtonDropdown';
|
||||
import ToggleSwitch from 'components/ToggleSwitch';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const QueryResultTypeSelector = ({
|
||||
formatOptions,
|
||||
formatValue,
|
||||
onFormatChange,
|
||||
onPreviewTabSelect,
|
||||
selectedTab
|
||||
}) => {
|
||||
const header = (
|
||||
<div className="flex items-center justify-between gap-3 py-[0.35rem] px-[0.6rem]">
|
||||
<span className="text-[0.8125rem] preview-response-tab-label">Preview</span>
|
||||
<ToggleSwitch
|
||||
isOn={selectedTab === 'preview'}
|
||||
handleToggle={(e) => {
|
||||
e.preventDefault();
|
||||
// e.stopPropagation();
|
||||
onPreviewTabSelect();
|
||||
}}
|
||||
size="2xs"
|
||||
data-testid="preview-response-tab"
|
||||
title={selectedTab === 'preview' ? 'Turn off Preview Mode' : 'Turn on Preview Mode'}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<ButtonDropdown
|
||||
label={formatValue}
|
||||
options={formatOptions}
|
||||
value={formatValue}
|
||||
onChange={onFormatChange}
|
||||
header={header}
|
||||
className="h-[20px] text-[11px]"
|
||||
data-testid="format-response-tab"
|
||||
suffix={selectedTab === 'preview' ? <IconEye size={14} strokeWidth={2} className="active mr-[2px]" /> : null}
|
||||
/>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default QueryResultTypeSelector;
|
||||
@@ -1,9 +1,8 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: 100%;
|
||||
grid-template-rows: 1.25rem 1fr;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
/* This is a hack to force Codemirror to use all available space */
|
||||
> div {
|
||||
|
||||
@@ -1,15 +1,34 @@
|
||||
import { debounce } from 'lodash';
|
||||
import { useTheme } from 'providers/Theme/index';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { formatResponse, getContentType } from 'utils/common';
|
||||
import { getEncoding } from 'utils/common/index';
|
||||
import { getDefaultResponseFormat } from 'utils/response';
|
||||
import LargeResponseWarning from '../LargeResponseWarning';
|
||||
import QueryResultFilter from './QueryResultFilter';
|
||||
import React from 'react';
|
||||
import classnames from 'classnames';
|
||||
import { getContentType, formatResponse } from 'utils/common';
|
||||
import { getCodeMirrorModeBasedOnContentType } from 'utils/common/codemirror';
|
||||
import QueryResultPreview from './QueryResultPreview';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { useTheme } from 'providers/Theme/index';
|
||||
import { getEncoding, uuid } from 'utils/common/index';
|
||||
import LargeResponseWarning from '../LargeResponseWarning';
|
||||
import { detectContentTypeFromBuffer } from 'utils/response/index';
|
||||
|
||||
const PREVIEW_FORMAT_OPTIONS = [
|
||||
{
|
||||
// name: 'Structured',
|
||||
options: [
|
||||
{ label: 'JSON', value: 'json', codeMirrorMode: 'application/ld+json' },
|
||||
{ label: 'HTML', value: 'html', codeMirrorMode: 'xml' },
|
||||
{ label: 'XML', value: 'xml', codeMirrorMode: 'xml' },
|
||||
{ label: 'JavaScript', value: 'javascript', codeMirrorMode: 'javascript' }
|
||||
]
|
||||
},
|
||||
{
|
||||
// name: 'Raw',
|
||||
options: [
|
||||
{ label: 'Raw', value: 'raw', codeMirrorMode: 'text/plain' },
|
||||
{ label: 'Hex', value: 'hex', codeMirrorMode: 'text/plain' },
|
||||
{ label: 'Base64', value: 'base64', codeMirrorMode: 'text/plain' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const formatErrorMessage = (error) => {
|
||||
if (!error) return 'Something went wrong';
|
||||
@@ -24,9 +43,87 @@ const formatErrorMessage = (error) => {
|
||||
return error;
|
||||
};
|
||||
|
||||
const QueryResult = ({ item, collection, data, dataBuffer, disableRunEventListener, headers, error }) => {
|
||||
// Custom hook to determine the initial format and tab based on the data buffer and headers
|
||||
export const useInitialResponseFormat = (dataBuffer, headers) => {
|
||||
return useMemo(() => {
|
||||
let buffer = null;
|
||||
try {
|
||||
buffer = dataBuffer ? Buffer.from(dataBuffer, 'base64') : null;
|
||||
} catch (error) {
|
||||
console.error('Error converting dataBuffer to Buffer:', error);
|
||||
buffer = null;
|
||||
}
|
||||
|
||||
const detectedContentType = detectContentTypeFromBuffer(buffer);
|
||||
const contentType = getContentType(headers);
|
||||
|
||||
// Wait until both content types are available
|
||||
if (detectedContentType === null || contentType === undefined) {
|
||||
return { initialFormat: null, initialTab: null };
|
||||
}
|
||||
|
||||
const initial = getDefaultResponseFormat(contentType);
|
||||
return { initialFormat: initial.format, initialTab: initial.tab };
|
||||
}, [dataBuffer, headers]);
|
||||
};
|
||||
|
||||
// Custom hook to determine preview format options based on content type
|
||||
export const useResponsePreviewFormatOptions = (dataBuffer, headers) => {
|
||||
return useMemo(() => {
|
||||
let buffer = null;
|
||||
try {
|
||||
buffer = dataBuffer ? Buffer.from(dataBuffer, 'base64') : null;
|
||||
} catch (error) {
|
||||
console.error('Error converting dataBuffer to Buffer:', error);
|
||||
buffer = null;
|
||||
}
|
||||
|
||||
const detectedContentType = detectContentTypeFromBuffer(buffer);
|
||||
const contentType = getContentType(headers);
|
||||
|
||||
const byteFormatTypes = ['image', 'video', 'audio', 'pdf', 'zip'];
|
||||
|
||||
const isByteFormatType = (contentType) => {
|
||||
return byteFormatTypes.some((type) => contentType.includes(type));
|
||||
};
|
||||
|
||||
const getContentTypeToCheck = () => {
|
||||
if (detectedContentType) {
|
||||
return detectedContentType;
|
||||
}
|
||||
return contentType;
|
||||
};
|
||||
|
||||
const contentTypeToCheck = getContentTypeToCheck();
|
||||
|
||||
if (contentTypeToCheck && isByteFormatType(contentTypeToCheck)) {
|
||||
return PREVIEW_FORMAT_OPTIONS.slice(1, 2); // Remove structured format options
|
||||
}
|
||||
|
||||
return PREVIEW_FORMAT_OPTIONS;
|
||||
}, [dataBuffer, headers]);
|
||||
};
|
||||
|
||||
const QueryResult = ({
|
||||
item,
|
||||
collection,
|
||||
data,
|
||||
dataBuffer,
|
||||
disableRunEventListener,
|
||||
headers,
|
||||
error,
|
||||
selectedFormat, // one of the options in PREVIEW_FORMAT_OPTIONS
|
||||
selectedTab // 'editor' or 'preview'
|
||||
}) => {
|
||||
let buffer = null;
|
||||
try {
|
||||
buffer = Buffer.from(dataBuffer, 'base64'); // dataBuffer is already a base64 string, convert it to actual Buffer
|
||||
} catch (error) {
|
||||
console.error('Error converting dataBuffer to Buffer:', error);
|
||||
buffer = null;
|
||||
}
|
||||
const detectedContentType = detectContentTypeFromBuffer(buffer);
|
||||
const contentType = getContentType(headers);
|
||||
const mode = getCodeMirrorModeBasedOnContentType(contentType, data);
|
||||
const [filter, setFilter] = useState(null);
|
||||
const [showLargeResponse, setShowLargeResponse] = useState(false);
|
||||
const responseEncoding = getEncoding(headers);
|
||||
@@ -56,65 +153,44 @@ const QueryResult = ({ item, collection, data, dataBuffer, disableRunEventListen
|
||||
if (isLargeResponse && !showLargeResponse) {
|
||||
return '';
|
||||
}
|
||||
return formatResponse(data, dataBuffer, mode, filter);
|
||||
return formatResponse(data, dataBuffer, selectedFormat, filter);
|
||||
},
|
||||
[data, dataBuffer, responseEncoding, mode, filter, isLargeResponse, showLargeResponse]
|
||||
[data, dataBuffer, responseEncoding, selectedFormat, filter, isLargeResponse, showLargeResponse]
|
||||
);
|
||||
|
||||
const debouncedResultFilterOnChange = debounce((e) => {
|
||||
setFilter(e.target.value);
|
||||
}, 250);
|
||||
|
||||
const allowedPreviewModes = useMemo(() => {
|
||||
// Always show raw
|
||||
const allowedPreviewModes = [{ mode: 'raw', name: 'Raw', uid: uuid() }];
|
||||
const previewMode = useMemo(() => {
|
||||
// Derive preview mode based on selected format
|
||||
if (selectedFormat === 'html') return 'preview-web';
|
||||
if (selectedFormat === 'json') return 'preview-json';
|
||||
if (selectedFormat === 'xml') return 'preview-xml';
|
||||
if (selectedFormat === 'raw') return 'preview-text';
|
||||
if (selectedFormat === 'javascript') return 'preview-web';
|
||||
|
||||
if (!mode || !contentType) return allowedPreviewModes;
|
||||
|
||||
if (mode?.includes('html') && typeof data === 'string') {
|
||||
allowedPreviewModes.unshift({ mode: 'preview-web', name: 'Web', uid: uuid() });
|
||||
} else if (mode.includes('image')) {
|
||||
allowedPreviewModes.unshift({ mode: 'preview-image', name: 'Image', uid: uuid() });
|
||||
} else if (contentType.includes('pdf')) {
|
||||
allowedPreviewModes.unshift({ mode: 'preview-pdf', name: 'PDF', uid: uuid() });
|
||||
} else if (contentType.includes('audio')) {
|
||||
allowedPreviewModes.unshift({ mode: 'preview-audio', name: 'Audio', uid: uuid() });
|
||||
} else if (contentType.includes('video')) {
|
||||
allowedPreviewModes.unshift({ mode: 'preview-video', name: 'Video', uid: uuid() });
|
||||
// For base64/hex, check content type to determine binary preview type
|
||||
if (selectedFormat === 'base64' || selectedFormat === 'hex') {
|
||||
if (detectedContentType) {
|
||||
if (detectedContentType.includes('image')) return 'preview-image';
|
||||
if (detectedContentType.includes('pdf')) return 'preview-pdf';
|
||||
if (detectedContentType.includes('audio')) return 'preview-audio';
|
||||
if (detectedContentType.includes('video')) return 'preview-video';
|
||||
}
|
||||
// for all other content types, return preview-text
|
||||
return 'preview-text';
|
||||
}
|
||||
return 'preview-text';
|
||||
}, [selectedFormat, detectedContentType]);
|
||||
|
||||
return allowedPreviewModes;
|
||||
}, [mode, data, formattedData]);
|
||||
const codeMirrorMode = useMemo(() => {
|
||||
return PREVIEW_FORMAT_OPTIONS
|
||||
.flatMap((option) => option.options)
|
||||
.find((option) => option.value === selectedFormat)?.codeMirrorMode || 'text/plain';
|
||||
}, [selectedFormat]);
|
||||
|
||||
const [previewTab, setPreviewTab] = useState(allowedPreviewModes[0]);
|
||||
// Ensure the active Tab is always allowed
|
||||
useEffect(() => {
|
||||
if (!allowedPreviewModes.find((previewMode) => previewMode?.uid == previewTab?.uid)) {
|
||||
setPreviewTab(allowedPreviewModes[0]);
|
||||
}
|
||||
}, [previewTab, allowedPreviewModes]);
|
||||
|
||||
const tabs = useMemo(() => {
|
||||
if (allowedPreviewModes.length === 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return allowedPreviewModes.map((previewMode) => (
|
||||
<div
|
||||
className={classnames(
|
||||
'select-none capitalize',
|
||||
previewMode?.uid === previewTab?.uid ? 'active' : 'cursor-pointer'
|
||||
)}
|
||||
role="tab"
|
||||
onClick={() => setPreviewTab(previewMode)}
|
||||
key={previewMode?.uid}
|
||||
>
|
||||
{previewMode?.name}
|
||||
</div>
|
||||
));
|
||||
}, [allowedPreviewModes, previewTab]);
|
||||
|
||||
const queryFilterEnabled = useMemo(() => mode.includes('json'), [mode]);
|
||||
const queryFilterEnabled = useMemo(() => codeMirrorMode.includes('json') && selectedFormat === 'json' && selectedTab === 'editor', [codeMirrorMode, selectedFormat, selectedTab]);
|
||||
const hasScriptError = item.preRequestScriptErrorMessage || item.postResponseScriptErrorMessage;
|
||||
|
||||
return (
|
||||
@@ -122,9 +198,6 @@ const QueryResult = ({ item, collection, data, dataBuffer, disableRunEventListen
|
||||
className="w-full h-full relative flex"
|
||||
queryFilterEnabled={queryFilterEnabled}
|
||||
>
|
||||
<div className="flex justify-end gap-2 text-xs" role="tablist">
|
||||
{tabs}
|
||||
</div>
|
||||
{error ? (
|
||||
<div>
|
||||
{hasScriptError ? null : (
|
||||
@@ -147,21 +220,23 @@ const QueryResult = ({ item, collection, data, dataBuffer, disableRunEventListen
|
||||
) : (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex-1 relative">
|
||||
<QueryResultPreview
|
||||
previewTab={previewTab}
|
||||
data={data}
|
||||
dataBuffer={dataBuffer}
|
||||
formattedData={formattedData}
|
||||
item={item}
|
||||
contentType={contentType}
|
||||
mode={mode}
|
||||
collection={collection}
|
||||
allowedPreviewModes={allowedPreviewModes}
|
||||
disableRunEventListener={disableRunEventListener}
|
||||
displayedTheme={displayedTheme}
|
||||
/>
|
||||
<div className="absolute top-0 left-0 h-full w-full" data-testid="response-preview-container">
|
||||
<QueryResultPreview
|
||||
selectedTab={selectedTab}
|
||||
data={data}
|
||||
dataBuffer={dataBuffer}
|
||||
formattedData={formattedData}
|
||||
item={item}
|
||||
contentType={contentType}
|
||||
previewMode={previewMode}
|
||||
codeMirrorMode={codeMirrorMode}
|
||||
collection={collection}
|
||||
disableRunEventListener={disableRunEventListener}
|
||||
displayedTheme={displayedTheme}
|
||||
/>
|
||||
</div>
|
||||
{queryFilterEnabled && (
|
||||
<QueryResultFilter filter={filter} onChange={debouncedResultFilterOnChange} mode={mode} />
|
||||
<QueryResultFilter filter={filter} onChange={debouncedResultFilterOnChange} mode={codeMirrorMode} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@ 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';
|
||||
import ResponseDownload from 'src/components/ResponsePane/ResponseDownload';
|
||||
|
||||
const ResponseActions = ({ collection, item }) => {
|
||||
const menuDropdownTippyRef = useRef();
|
||||
@@ -26,7 +26,7 @@ const ResponseActions = ({ collection, item }) => {
|
||||
<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} />
|
||||
<ResponseDownload item={item} asDropdownItem onClose={handleClose} />
|
||||
</Dropdown>
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,13 @@ import styled from 'styled-components';
|
||||
const StyledWrapper = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: ${(props) => props.theme.codemirror.variable.info.iconColor};
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background-color: ${(props) => props.theme.workspace.button.bg};
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
|
||||
@@ -24,27 +24,51 @@ const getTitleText = ({ isResponseTooLarge, isStreamingResponse }) => {
|
||||
return 'Save current response as example';
|
||||
};
|
||||
|
||||
const ResponseBookmark = ({ item, collection, responseSize }) => {
|
||||
const ResponseBookmark = ({ item, collection, responseSize, children }) => {
|
||||
const dispatch = useDispatch();
|
||||
const [showSaveResponseExampleModal, setShowSaveResponseExampleModal] = useState(false);
|
||||
const response = item.response || {};
|
||||
|
||||
const isResponseTooLarge = responseSize >= 5 * 1024 * 1024; // 5 MB
|
||||
const isStreamingResponse = response.stream;
|
||||
const isDisabled = isResponseTooLarge || isStreamingResponse;
|
||||
|
||||
// Only show for HTTP requests
|
||||
if (item.type !== 'http-request') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleSaveClick = () => {
|
||||
const handleKeyDown = (e) => {
|
||||
if (isDisabled) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleSaveClick(e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveClick = (e) => {
|
||||
if (!response || response.error) {
|
||||
toast.error('No valid response to save as example');
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isResponseTooLarge) {
|
||||
toast.error('Response size exceeds 5MB limit. Cannot save as example.');
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDisabled) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -116,21 +140,28 @@ const ResponseBookmark = ({ item, collection, responseSize }) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledWrapper className="ml-2 flex items-center">
|
||||
<button
|
||||
onClick={handleSaveClick}
|
||||
disabled={isResponseTooLarge || isStreamingResponse}
|
||||
title={
|
||||
disabledMessage
|
||||
}
|
||||
className={classnames('p-1', {
|
||||
'opacity-50 cursor-not-allowed': isResponseTooLarge || isStreamingResponse
|
||||
})}
|
||||
data-testid="response-bookmark-btn"
|
||||
>
|
||||
<IconBookmark size={16} strokeWidth={1.5} />
|
||||
</button>
|
||||
</StyledWrapper>
|
||||
<div
|
||||
role={!!children ? 'button' : undefined}
|
||||
tabIndex={isDisabled ? -1 : 0}
|
||||
aria-disabled={isDisabled}
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={handleSaveClick}
|
||||
title={
|
||||
!children ? disabledMessage : (isDisabled ? disabledMessage : null)
|
||||
}
|
||||
className={classnames({
|
||||
'opacity-50 cursor-not-allowed': isDisabled
|
||||
})}
|
||||
data-testid="response-bookmark-btn"
|
||||
>
|
||||
{children ?? (
|
||||
<StyledWrapper className="flex items-center">
|
||||
<button className="p-1">
|
||||
<IconBookmark size={16} strokeWidth={2} />
|
||||
</button>
|
||||
</StyledWrapper>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CreateExampleModal
|
||||
isOpen={showSaveResponseExampleModal}
|
||||
|
||||
@@ -2,7 +2,13 @@ import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
color: ${(props) => props.theme.requestTabPanel.responseStatus};
|
||||
color: ${(props) => props.theme.codemirror.variable.info.iconColor};
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background-color: ${(props) => props.theme.workspace.button.bg};
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
|
||||
@@ -4,11 +4,11 @@ import { useDispatch } from 'react-redux';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { responseCleared } from 'providers/ReduxStore/slices/collections/index';
|
||||
|
||||
const ResponseClear = ({ collection, item, asDropdownItem, onClose }) => {
|
||||
// Hook to get clear response function
|
||||
export const useResponseClear = (item, collection) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const clearResponse = () => {
|
||||
if (onClose) onClose();
|
||||
dispatch(
|
||||
responseCleared({
|
||||
itemUid: item.uid,
|
||||
@@ -18,21 +18,29 @@ const ResponseClear = ({ collection, item, asDropdownItem, onClose }) => {
|
||||
);
|
||||
};
|
||||
|
||||
if (asDropdownItem) {
|
||||
return (
|
||||
<div className="dropdown-item" onClick={clearResponse}>
|
||||
<IconEraser size={16} strokeWidth={1.5} className="icon mr-2" />
|
||||
Clear
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return { clearResponse };
|
||||
};
|
||||
|
||||
const ResponseClear = ({ collection, item, children }) => {
|
||||
const { clearResponse } = useResponseClear(item, collection);
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
clearResponse();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper className="ml-2 flex items-center">
|
||||
<button onClick={clearResponse} title="Clear response">
|
||||
<IconEraser size={16} strokeWidth={1.5} />
|
||||
</button>
|
||||
</StyledWrapper>
|
||||
<div role={!!children ? 'button' : undefined} tabIndex={0} onClick={clearResponse} title={!children ? 'Clear response' : null} onKeyDown={handleKeyDown} data-testid="response-clear-button">
|
||||
{children ? children : (
|
||||
<StyledWrapper className="flex items-center">
|
||||
<button className="p-1">
|
||||
<IconEraser size={16} strokeWidth={2} />
|
||||
</button>
|
||||
</StyledWrapper>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default ResponseClear;
|
||||
|
||||
@@ -2,7 +2,13 @@ import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
font-size: 0.8125rem;
|
||||
color: ${(props) => props.theme.requestTabPanel.responseStatus};
|
||||
color: ${(props) => props.theme.codemirror.variable.info.iconColor};
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background-color: ${(props) => props.theme.workspace.button.bg};
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
|
||||
@@ -3,7 +3,8 @@ import StyledWrapper from './StyledWrapper';
|
||||
import toast from 'react-hot-toast';
|
||||
import { IconCopy, IconCheck } from '@tabler/icons';
|
||||
|
||||
const ResponseCopy = ({ item }) => {
|
||||
// Hook to get copy response function
|
||||
export const useResponseCopy = (item) => {
|
||||
const response = item.response || {};
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
@@ -30,16 +31,39 @@ const ResponseCopy = ({ item }) => {
|
||||
}
|
||||
};
|
||||
|
||||
return { copyResponse, copied, hasData: !!response.data };
|
||||
};
|
||||
|
||||
const ResponseCopy = ({ item, children }) => {
|
||||
const { copyResponse, copied, hasData } = useResponseCopy(item);
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if ((e.key === 'Enter' || e.key === ' ') && hasData) {
|
||||
e.preventDefault();
|
||||
copyResponse();
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
if (hasData) {
|
||||
copyResponse();
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
<div role={!!children ? 'button' : undefined} tabIndex={0} onClick={handleClick} title={!children ? 'Copy response to clipboard' : null} onKeyDown={handleKeyDown} data-testid="response-copy-btn">
|
||||
{children ? children : (
|
||||
<StyledWrapper className="flex items-center">
|
||||
<button className="p-1" disabled={!hasData}>
|
||||
{copied ? (
|
||||
<IconCheck size={16} strokeWidth={2} />
|
||||
) : (
|
||||
<IconCopy size={16} strokeWidth={2} />
|
||||
)}
|
||||
</button>
|
||||
</StyledWrapper>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
color: ${(props) => props.theme.codemirror.variable.info.iconColor};
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background-color: ${(props) => props.theme.workspace.button.bg};
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,63 @@
|
||||
import React from 'react';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import toast from 'react-hot-toast';
|
||||
import get from 'lodash/get';
|
||||
import { IconDownload } from '@tabler/icons';
|
||||
import classnames from 'classnames';
|
||||
|
||||
const ResponseDownload = ({ item, children }) => {
|
||||
const { ipcRenderer } = window;
|
||||
const response = item.response || {};
|
||||
const isDisabled = !response.dataBuffer;
|
||||
|
||||
const saveResponseToFile = () => {
|
||||
if (isDisabled) {
|
||||
return;
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
ipcRenderer
|
||||
.invoke('renderer:save-response-to-file', response, item?.requestSent?.url, item.pathname)
|
||||
.then(resolve)
|
||||
.catch((err) => {
|
||||
toast.error(get(err, 'error.message') || 'Something went wrong!');
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (isDisabled) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
saveResponseToFile();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role={!!children ? 'button' : undefined}
|
||||
tabIndex={isDisabled ? -1 : 0}
|
||||
aria-disabled={isDisabled}
|
||||
onClick={saveResponseToFile}
|
||||
onKeyDown={handleKeyDown}
|
||||
title={!children ? 'Save response to file' : null}
|
||||
className={classnames({
|
||||
'opacity-50 cursor-not-allowed': isDisabled
|
||||
})}
|
||||
>
|
||||
{children ? children : (
|
||||
<StyledWrapper className="flex items-center">
|
||||
<button className="p-1">
|
||||
<IconDownload size={16} strokeWidth={2} />
|
||||
</button>
|
||||
</StyledWrapper>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default ResponseDownload;
|
||||
@@ -8,7 +8,13 @@ const Wrapper = styled.div`
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
color: ${(props) => props.theme.codemirror.variable.info.iconColor};
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background-color: ${(props) => props.theme.workspace.button.bg};
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -3,14 +3,14 @@ import { useDispatch, useSelector } from 'react-redux';
|
||||
import { savePreferences } from 'providers/ReduxStore/slices/app';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const IconDockToBottom = () => {
|
||||
export const IconDockToBottom = () => {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="1.5"
|
||||
strokeWidth="2"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
>
|
||||
@@ -25,14 +25,14 @@ const IconDockToBottom = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const IconDockToRight = () => {
|
||||
export const IconDockToRight = () => {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="1.5"
|
||||
strokeWidth="2"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
>
|
||||
@@ -48,7 +48,8 @@ const IconDockToRight = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const ResponseLayoutToggle = () => {
|
||||
// Hook to get orientation and toggle function
|
||||
export const useResponseLayoutToggle = () => {
|
||||
const dispatch = useDispatch();
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const orientation = preferences?.layout?.responsePaneOrientation || 'horizontal';
|
||||
@@ -65,19 +66,42 @@ const ResponseLayoutToggle = () => {
|
||||
dispatch(savePreferences(updatedPreferences));
|
||||
};
|
||||
|
||||
return { orientation, toggleOrientation };
|
||||
};
|
||||
|
||||
const ResponseLayoutToggle = ({ children }) => {
|
||||
const { orientation, toggleOrientation } = useResponseLayoutToggle();
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
toggleOrientation();
|
||||
}
|
||||
};
|
||||
|
||||
const title = !children ? (orientation === 'horizontal' ? 'Switch to vertical layout' : 'Switch to horizontal layout') : null;
|
||||
|
||||
return (
|
||||
<StyledWrapper className="ml-2 flex items-center">
|
||||
<button
|
||||
onClick={toggleOrientation}
|
||||
title={orientation === 'horizontal' ? 'Switch to vertical layout' : 'Switch to horizontal layout'}
|
||||
>
|
||||
{orientation === 'horizontal' ? (
|
||||
<IconDockToBottom />
|
||||
) : (
|
||||
<IconDockToRight />
|
||||
)}
|
||||
</button>
|
||||
</StyledWrapper>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={toggleOrientation}
|
||||
title={title}
|
||||
onKeyDown={handleKeyDown}
|
||||
data-testid="response-layout-toggle-button"
|
||||
>
|
||||
{children ? children : (
|
||||
<StyledWrapper className="flex items-center w-full">
|
||||
<button className="p-1">
|
||||
{orientation === 'horizontal' ? (
|
||||
<IconDockToBottom />
|
||||
) : (
|
||||
<IconDockToRight />
|
||||
)}
|
||||
</button>
|
||||
</StyledWrapper>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ describe('ResponseLayoutToggle', () => {
|
||||
describe('Initial Render', () => {
|
||||
it('should render with horizontal orientation by default', () => {
|
||||
renderWithProviders(<ResponseLayoutToggle />);
|
||||
const button = screen.getByRole('button');
|
||||
const button = screen.getByTestId('response-layout-toggle-button');
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(button).toHaveAttribute('title', 'Switch to vertical layout');
|
||||
});
|
||||
@@ -100,7 +100,7 @@ describe('ResponseLayoutToggle', () => {
|
||||
}
|
||||
};
|
||||
renderWithProviders(<ResponseLayoutToggle />, customState);
|
||||
const button = screen.getByRole('button');
|
||||
const button = screen.getByTestId('response-layout-toggle-button');
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(button).toHaveAttribute('title', 'Switch to horizontal layout');
|
||||
});
|
||||
@@ -109,7 +109,7 @@ describe('ResponseLayoutToggle', () => {
|
||||
describe('Interaction', () => {
|
||||
it('should switch to vertical layout when clicked in horizontal mode', () => {
|
||||
const { store } = renderWithProviders(<ResponseLayoutToggle />);
|
||||
const button = screen.getByRole('button');
|
||||
const button = screen.getByTestId('response-layout-toggle-button');
|
||||
|
||||
// Initial state check
|
||||
expect(button).toHaveAttribute('title', 'Switch to vertical layout');
|
||||
@@ -145,7 +145,7 @@ describe('ResponseLayoutToggle', () => {
|
||||
}
|
||||
};
|
||||
const { store } = renderWithProviders(<ResponseLayoutToggle />, customState);
|
||||
const button = screen.getByRole('button');
|
||||
const button = screen.getByTestId('response-layout-toggle-button');
|
||||
|
||||
// Initial state check
|
||||
expect(button).toHaveAttribute('title', 'Switch to horizontal layout');
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,188 @@
|
||||
import React, { useState, useEffect, useRef, forwardRef, useCallback, useMemo } from 'react';
|
||||
import { debounce } from 'lodash';
|
||||
import styled from 'styled-components';
|
||||
import { IconDots, IconDownload, IconEraser, IconBookmark, IconCopy } from '@tabler/icons';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import ResponseDownload from '../ResponseDownload';
|
||||
import ResponseBookmark from '../ResponseBookmark';
|
||||
import ResponseClear from '../ResponseClear';
|
||||
import ResponseLayoutToggle, { useResponseLayoutToggle, IconDockToBottom, IconDockToRight } from '../ResponseLayoutToggle';
|
||||
import ResponseCopy from '../ResponseCopy/index';
|
||||
import StyledWrapper from '../StyledWrapper';
|
||||
|
||||
const PADDING = 48;
|
||||
|
||||
const StyledMenuIcon = styled.button`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 1.25rem;
|
||||
width: 1.5rem;
|
||||
border: 1px solid ${(props) => props.theme.workspace.border};
|
||||
color: ${(props) => props.theme.codemirror.variable.info.iconColor};
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background-color: ${(props) => props.theme.workspace.button.bg};
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
`;
|
||||
|
||||
const MenuIcon = forwardRef((props, ref) => (
|
||||
<StyledMenuIcon
|
||||
ref={ref}
|
||||
title="More actions"
|
||||
{...props}
|
||||
>
|
||||
<IconDots size={16} strokeWidth={1.5} />
|
||||
</StyledMenuIcon>
|
||||
));
|
||||
|
||||
MenuIcon.displayName = 'MenuIcon';
|
||||
|
||||
const ResponsePaneActions = ({ item, collection, responseSize }) => {
|
||||
const { orientation } = useResponseLayoutToggle();
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
const actionsRef = useRef(null);
|
||||
const dropdownTippyRef = useRef();
|
||||
const individualButtonsWidthRef = useRef(null);
|
||||
const showMenuRef = useRef(showMenu);
|
||||
|
||||
const checkSpace = useCallback(() => {
|
||||
const actionsContainer = actionsRef.current?.parentElement;
|
||||
const rightSideContainer = actionsContainer?.closest('.right-side-container');
|
||||
|
||||
if (!actionsContainer || !rightSideContainer) return;
|
||||
|
||||
const currentActionsWidth = actionsContainer.offsetWidth || 0;
|
||||
|
||||
// Store individual buttons width when they're visible
|
||||
if (!showMenuRef.current && currentActionsWidth > 0) {
|
||||
individualButtonsWidthRef.current = currentActionsWidth;
|
||||
}
|
||||
|
||||
// Calculate siblings total width
|
||||
let siblingsTotalWidth = 0;
|
||||
let sibling = actionsContainer.previousElementSibling;
|
||||
while (sibling) {
|
||||
siblingsTotalWidth += sibling.offsetWidth || 0;
|
||||
sibling = sibling.previousElementSibling;
|
||||
}
|
||||
|
||||
const actionsWidth = individualButtonsWidthRef.current || currentActionsWidth;
|
||||
const requiredWidth = actionsWidth + siblingsTotalWidth + PADDING;
|
||||
const shouldShowMenu = rightSideContainer.offsetWidth < requiredWidth;
|
||||
|
||||
if (showMenuRef.current !== shouldShowMenu) {
|
||||
showMenuRef.current = shouldShowMenu;
|
||||
setShowMenu(shouldShowMenu);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const debouncedCheckSpace = useMemo(
|
||||
() => debounce(checkSpace, 50),
|
||||
[checkSpace]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
showMenuRef.current = showMenu;
|
||||
}, [showMenu]);
|
||||
|
||||
useEffect(() => {
|
||||
checkSpace();
|
||||
|
||||
const rightSideContainer = actionsRef.current?.closest('.right-side-container');
|
||||
if (!rightSideContainer) return;
|
||||
|
||||
const resizeObserver = new ResizeObserver(debouncedCheckSpace);
|
||||
resizeObserver.observe(rightSideContainer);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
debouncedCheckSpace.cancel();
|
||||
};
|
||||
}, [item, debouncedCheckSpace]);
|
||||
|
||||
const onDropdownCreate = (ref) => {
|
||||
dropdownTippyRef.current = ref;
|
||||
};
|
||||
|
||||
const closeDropdown = () => {
|
||||
if (dropdownTippyRef.current) {
|
||||
dropdownTippyRef.current.hide();
|
||||
}
|
||||
};
|
||||
|
||||
if (item.type !== 'http-request') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledWrapper ref={actionsRef} className="flex items-center gap-2">
|
||||
{showMenu ? (
|
||||
<Dropdown onCreate={onDropdownCreate} icon={<MenuIcon data-testid="response-actions-menu" />} placement="bottom-end">
|
||||
|
||||
{/* Response Copy */}
|
||||
<ResponseCopy item={item}>
|
||||
<div className="dropdown-item" onClick={closeDropdown}>
|
||||
<span className="dropdown-icon">
|
||||
<IconCopy size={16} strokeWidth={1.5} />
|
||||
</span>
|
||||
<span>Copy response</span>
|
||||
</div>
|
||||
</ResponseCopy>
|
||||
|
||||
{/* Response Save as Example */}
|
||||
<ResponseBookmark item={item} collection={collection} responseSize={responseSize}>
|
||||
<div className="dropdown-item" onClick={closeDropdown}>
|
||||
<span className="dropdown-icon">
|
||||
<IconBookmark size={16} strokeWidth={1.5} />
|
||||
</span>
|
||||
<span>Save response</span>
|
||||
</div>
|
||||
</ResponseBookmark>
|
||||
|
||||
{/* Response Download */}
|
||||
<ResponseDownload item={item}>
|
||||
<div className="dropdown-item" onClick={closeDropdown}>
|
||||
<span className="dropdown-icon">
|
||||
<IconDownload size={16} strokeWidth={1.5} />
|
||||
</span>
|
||||
Download response
|
||||
</div>
|
||||
</ResponseDownload>
|
||||
|
||||
{/* Response Clear */}
|
||||
<ResponseClear item={item} collection={collection}>
|
||||
<div className="dropdown-item" onClick={closeDropdown}>
|
||||
<span className="dropdown-icon">
|
||||
<IconEraser size={16} strokeWidth={1.5} />
|
||||
</span>
|
||||
Clear response
|
||||
</div>
|
||||
</ResponseClear>
|
||||
|
||||
{/* Response Layout Toggle */}
|
||||
<ResponseLayoutToggle>
|
||||
<div className="dropdown-item" onClick={closeDropdown}>
|
||||
<span className="dropdown-icon">
|
||||
{orientation === 'horizontal' ? <IconDockToBottom /> : <IconDockToRight />}
|
||||
</span>
|
||||
<span>Change layout</span>
|
||||
</div>
|
||||
</ResponseLayoutToggle>
|
||||
</Dropdown>
|
||||
) : (
|
||||
<div className="flex items-center gap-[2px]">
|
||||
<ResponseCopy item={item} />
|
||||
<ResponseBookmark item={item} collection={collection} responseSize={responseSize} />
|
||||
<ResponseDownload item={item} />
|
||||
<ResponseClear item={item} collection={collection} />
|
||||
<ResponseLayoutToggle />
|
||||
</div>
|
||||
)}
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResponsePaneActions;
|
||||
@@ -1,8 +0,0 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
color: ${(props) => props.theme.requestTabPanel.responseStatus};
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -1,47 +0,0 @@
|
||||
import React from 'react';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import toast from 'react-hot-toast';
|
||||
import get from 'lodash/get';
|
||||
import { IconDownload } from '@tabler/icons';
|
||||
|
||||
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)
|
||||
.then(resolve)
|
||||
.catch((err) => {
|
||||
toast.error(get(err, 'error.message') || 'Something went wrong!');
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
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">
|
||||
<IconDownload size={16} strokeWidth={1.5} />
|
||||
</button>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
export default ResponseSave;
|
||||
@@ -19,7 +19,7 @@ const ResponseSize = ({ size }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledWrapper title={(size?.toLocaleString() || '0') + 'B'} className="ml-4">
|
||||
<StyledWrapper title={(size?.toLocaleString() || '0') + 'B'} className="ml-2">
|
||||
{sizeToDisplay}
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@ import styled from 'styled-components';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
font-weight: 500;
|
||||
font-weight: 600;
|
||||
color: ${(props) => props.theme.requestTabPanel.responseStatus};
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
@@ -21,7 +21,7 @@ const ResponseStopWatch = ({ startMillis }) => {
|
||||
let seconds = milliseconds / 1000;
|
||||
let secondsFormatted = `${seconds.toFixed(1)}s`;
|
||||
let width = secondsFormatted.length * 0.4; // Calculate width manually to stop parent layout from "flickering" by changing width too fast
|
||||
return <StyledWrapper className="ml-4" style={{ width: `${width}rem` }}>{secondsFormatted}</StyledWrapper>;
|
||||
return <StyledWrapper className="ml-2" style={{ width: `${width}rem` }}>{secondsFormatted}</StyledWrapper>;
|
||||
};
|
||||
|
||||
export default React.memo(ResponseStopWatch);
|
||||
|
||||
@@ -2,7 +2,7 @@ import styled from 'styled-components';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
font-weight: 500;
|
||||
font-weight: 600;
|
||||
color: ${(props) => props.theme.requestTabPanel.responseStatus};
|
||||
`;
|
||||
|
||||
|
||||
@@ -17,6 +17,6 @@ const ResponseTime = ({ duration }) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <StyledWrapper className="ml-4">{durationToDisplay}</StyledWrapper>;
|
||||
return <StyledWrapper className="ml-2">{durationToDisplay}</StyledWrapper>;
|
||||
};
|
||||
export default ResponseTime;
|
||||
|
||||
@@ -2,7 +2,8 @@ import styled from 'styled-components';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
font-weight: 500;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
|
||||
&.text-ok {
|
||||
color: ${(props) => props.theme.requestTabPanel.responseOk};
|
||||
|
||||
@@ -6,7 +6,7 @@ import StyledWrapper from './StyledWrapper';
|
||||
// Todo: text-error class is not getting pulled in for 500 errors
|
||||
const StatusCode = ({ status, statusText, isStreaming }) => {
|
||||
const getTabClassname = (status) => {
|
||||
return classnames('ml-2', {
|
||||
return classnames({
|
||||
'text-ok': status >= 100 && status < 200,
|
||||
'text-ok': status >= 200 && status < 300,
|
||||
'text-error': status >= 300 && status < 400,
|
||||
|
||||
@@ -33,6 +33,12 @@ const StyledWrapper = styled.div`
|
||||
.all-tests-passed {
|
||||
color: ${(props) => props.theme.colors.text.green} !important;
|
||||
}
|
||||
|
||||
.separator {
|
||||
height: 16px;
|
||||
border-left: 1px solid ${(props) => props.theme.preferences.sidebar.border};
|
||||
margin: 0 8px;
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import QueryResult from 'components/ResponsePane/QueryResult/index';
|
||||
import QueryResponse from 'components/ResponsePane/QueryResponse/index';
|
||||
import { useState } from 'react';
|
||||
|
||||
const BodyBlock = ({ collection, data, dataBuffer, headers, error, item }) => {
|
||||
@@ -14,7 +14,7 @@ const BodyBlock = ({ collection, data, dataBuffer, headers, error, item }) => {
|
||||
<div className="mt-2">
|
||||
{data || dataBuffer ? (
|
||||
<div className="h-96 overflow-auto">
|
||||
<QueryResult
|
||||
<QueryResponse
|
||||
item={item}
|
||||
collection={collection}
|
||||
data={data}
|
||||
|
||||
@@ -16,12 +16,11 @@ import TestResultsLabel from './TestResultsLabel';
|
||||
import ScriptError from './ScriptError';
|
||||
import ScriptErrorIcon from './ScriptErrorIcon';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import ResponseActions from 'src/components/ResponsePane/ResponseActions';
|
||||
import ResponseBookmark from 'src/components/ResponsePane/ResponseBookmark';
|
||||
import ResponseCopy from 'src/components/ResponsePane/ResponseCopy';
|
||||
import ResponsePaneActions from './ResponsePaneActions';
|
||||
import QueryResultTypeSelector from './QueryResult/QueryResultTypeSelector/index';
|
||||
import { useInitialResponseFormat, useResponsePreviewFormatOptions } from './QueryResult/index';
|
||||
import SkippedRequest from './SkippedRequest';
|
||||
import ClearTimeline from './ClearTimeline/index';
|
||||
import ResponseLayoutToggle from './ResponseLayoutToggle';
|
||||
import HeightBoundContainer from 'ui/HeightBoundContainer';
|
||||
import ResponseStopWatch from 'components/ResponsePane/ResponseStopWatch';
|
||||
import WSMessagesList from './WsResponsePane/WSMessagesList';
|
||||
@@ -32,6 +31,19 @@ const ResponsePane = ({ item, collection }) => {
|
||||
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
|
||||
const isLoading = ['queued', 'sending'].includes(item.requestState);
|
||||
const [showScriptErrorCard, setShowScriptErrorCard] = useState(false);
|
||||
const [selectedFormat, setSelectedFormat] = useState('raw');
|
||||
const [selectedTab, setSelectedTab] = useState('editor');
|
||||
|
||||
// Initialize format and tab only once when data loads
|
||||
const { initialFormat, initialTab } = useInitialResponseFormat(item.response?.dataBuffer, item.response?.headers);
|
||||
const previewFormatOptions = useResponsePreviewFormatOptions(item.response?.dataBuffer, item.response?.headers);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialFormat !== null && initialTab !== null) {
|
||||
setSelectedFormat(initialFormat);
|
||||
setSelectedTab(initialTab);
|
||||
}
|
||||
}, [initialFormat, initialTab]);
|
||||
|
||||
const requestTimeline = ([...(collection.timeline || [])]).filter((obj) => {
|
||||
if (obj.itemUid === item.uid) return true;
|
||||
@@ -86,6 +98,8 @@ const ResponsePane = ({ item, collection }) => {
|
||||
headers={response.headers}
|
||||
error={response.error}
|
||||
key={item.filename}
|
||||
selectedFormat={selectedFormat}
|
||||
selectedTab={selectedTab}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -157,7 +171,7 @@ const ResponsePane = ({ item, collection }) => {
|
||||
|
||||
return (
|
||||
<StyledWrapper className="flex flex-col h-full relative">
|
||||
<div className="flex flex-wrap items-center px-4 tabs" role="tablist">
|
||||
<div className="flex items-center px-4 tabs" role="tablist">
|
||||
<div className={getTabClassname('response')} role="tab" onClick={() => selectTab('response')}>
|
||||
Response
|
||||
</div>
|
||||
@@ -177,33 +191,50 @@ const ResponsePane = ({ item, collection }) => {
|
||||
/>
|
||||
</div>
|
||||
{!isLoading ? (
|
||||
<div className="flex flex-grow justify-end items-center">
|
||||
<div className="flex flex-grow justify-end items-center right-side-container">
|
||||
{hasScriptError && !showScriptErrorCard && (
|
||||
<ScriptErrorIcon
|
||||
itemUid={item.uid}
|
||||
onClick={() => setShowScriptErrorCard(true)}
|
||||
/>
|
||||
)}
|
||||
<ResponseLayoutToggle />
|
||||
{focusedTab?.responsePaneTab === 'timeline' ? (
|
||||
<ClearTimeline item={item} collection={collection} />
|
||||
) : (item?.response && !item?.response?.error) ? (
|
||||
{focusedTab?.responsePaneTab === 'response' ? (
|
||||
<>
|
||||
<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} />
|
||||
: <ResponseTime duration={response.duration} />}
|
||||
<ResponseSize size={responseSize} />
|
||||
<QueryResultTypeSelector
|
||||
formatOptions={previewFormatOptions}
|
||||
formatValue={selectedFormat}
|
||||
onFormatChange={(newFormat) => {
|
||||
setSelectedFormat(newFormat);
|
||||
}}
|
||||
onPreviewTabSelect={() => {
|
||||
setSelectedTab((prev) => prev === 'editor' ? 'preview' : 'editor');
|
||||
}}
|
||||
selectedTab={selectedTab}
|
||||
/>
|
||||
<div className="separator" />
|
||||
</>
|
||||
) : null}
|
||||
<div className="flex items-center response-pane-status">
|
||||
<StatusCode status={response.status} isStreaming={item.response?.stream?.running} />
|
||||
{item.response?.stream?.running
|
||||
? <ResponseStopWatch startMillis={response.duration} />
|
||||
: <ResponseTime duration={response.duration} />}
|
||||
<ResponseSize size={responseSize} />
|
||||
</div>
|
||||
|
||||
<div className="separator" />
|
||||
<div className="flex items-center response-pane-actions">
|
||||
{focusedTab?.responsePaneTab === 'timeline' ? (
|
||||
<ClearTimeline item={item} collection={collection} />
|
||||
) : (item?.response && !item?.response?.error) ? (
|
||||
<ResponsePaneActions item={item} collection={collection} responseSize={responseSize} />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<section
|
||||
className="flex flex-col min-h-0 relative px-4 auto overflow-auto"
|
||||
className="flex flex-col min-h-0 relative px-4 pt-3 auto overflow-auto"
|
||||
style={{
|
||||
flex: '1 1 0',
|
||||
height: hasScriptError && showScriptErrorCard ? 'auto' : '100%'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import classnames from 'classnames';
|
||||
import QueryResult from 'components/ResponsePane/QueryResult';
|
||||
import QueryResponse from 'components/ResponsePane/QueryResponse/index';
|
||||
import ResponseHeaders from 'components/ResponsePane/ResponseHeaders';
|
||||
import StatusCode from 'components/ResponsePane/StatusCode';
|
||||
import ResponseTime from 'components/ResponsePane/ResponseTime';
|
||||
@@ -39,7 +39,7 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
|
||||
switch (tab) {
|
||||
case 'response': {
|
||||
return (
|
||||
<QueryResult
|
||||
<QueryResponse
|
||||
item={item}
|
||||
collection={collection}
|
||||
width={rightPaneWidth}
|
||||
|
||||
@@ -504,6 +504,7 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
data-testid="sidebar-collection-item-row"
|
||||
>
|
||||
<div className="flex items-center h-full w-full">
|
||||
{indents && indents.length
|
||||
|
||||
@@ -293,6 +293,7 @@ const Collection = ({ collection, searchText }) => {
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
data-testid="sidebar-collection-row"
|
||||
>
|
||||
<div
|
||||
className="flex flex-grow items-center overflow-hidden"
|
||||
|
||||
24
packages/bruno-app/src/ui/ButtonDropdown/StyledWrapper.js
Normal file
24
packages/bruno-app/src/ui/ButtonDropdown/StyledWrapper.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.caret {
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.button-dropdown-button {
|
||||
color: ${(props) => props.theme.dropdown.primaryText};
|
||||
border-color: ${(props) => props.theme.workspace.border};
|
||||
|
||||
&:hover {
|
||||
background-color: ${(props) => props.theme.dropdown.hoverBg};
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-divider {
|
||||
background-color: ${(props) => props.theme.dropdown.separator};
|
||||
height: 1px;
|
||||
margin: 4px 0;
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
146
packages/bruno-app/src/ui/ButtonDropdown/index.jsx
Normal file
146
packages/bruno-app/src/ui/ButtonDropdown/index.jsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import React, { useRef, forwardRef } from 'react';
|
||||
import { IconCaretDown } from '@tabler/icons';
|
||||
import classnames from 'classnames';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
|
||||
const ButtonIcon = forwardRef(({ disabled, className, style, prefix, selectedLabel, suffix, ...props }, ref) => {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className={classnames('button-dropdown-button flex items-center gap-1.5 text-xs',
|
||||
'cursor-pointer select-none',
|
||||
'h-7 rounded-[6px] border px-2 transition-colors',
|
||||
{ 'opacity-50 cursor-not-allowed': disabled },
|
||||
className)}
|
||||
disabled={disabled}
|
||||
data-testid={props['data-testid']}
|
||||
style={style}
|
||||
role="button"
|
||||
{...props}
|
||||
>
|
||||
{prefix && <span>{prefix}</span>}
|
||||
<span className="active">{selectedLabel}</span>
|
||||
{suffix && <span>{suffix}</span>}
|
||||
<IconCaretDown className="caret ml-1" size={14} strokeWidth={2} />
|
||||
</button>
|
||||
);
|
||||
});
|
||||
ButtonIcon.displayName = 'ButtonIcon';
|
||||
|
||||
const ButtonDropdown = ({
|
||||
label,
|
||||
options,
|
||||
onChange,
|
||||
value,
|
||||
disabled,
|
||||
className,
|
||||
style,
|
||||
header,
|
||||
prefix,
|
||||
suffix,
|
||||
...props
|
||||
}) => {
|
||||
const dropdownTippyRef = useRef(null);
|
||||
// Check if options is a group array
|
||||
const isGrouped = Array.isArray(options) && options.length > 0 && 'options' in options[0];
|
||||
|
||||
// Find the selected option's label
|
||||
const findSelectedLabel = () => {
|
||||
if (isGrouped) {
|
||||
const groups = options;
|
||||
for (const group of groups) {
|
||||
const option = group.options.find((opt) => opt.value === value);
|
||||
if (option) return option.label;
|
||||
}
|
||||
} else {
|
||||
const flatOptions = options;
|
||||
const option = flatOptions.find((opt) => opt.value === value);
|
||||
if (option) return option.label;
|
||||
}
|
||||
return label;
|
||||
};
|
||||
|
||||
const selectedLabel = findSelectedLabel();
|
||||
|
||||
const onDropdownCreate = (ref) => {
|
||||
dropdownTippyRef.current = ref;
|
||||
};
|
||||
|
||||
const handleOptionSelect = (optionValue) => {
|
||||
onChange(optionValue);
|
||||
dropdownTippyRef.current?.hide();
|
||||
};
|
||||
|
||||
// Flatten options for rendering
|
||||
const renderOptions = () => {
|
||||
if (isGrouped) {
|
||||
const groups = options;
|
||||
return groups.map((group, groupIndex) => (
|
||||
<React.Fragment key={groupIndex}>
|
||||
{group.options.map((option, optionIndex) => {
|
||||
const isFirstInGroup = optionIndex === 0;
|
||||
const isFirstGroup = groupIndex === 0;
|
||||
const showSeparator = !isFirstGroup && isFirstInGroup;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={option.value}
|
||||
className={classnames('dropdown-item flex items-center gap-2',
|
||||
{
|
||||
'active': option.value === value,
|
||||
'border-top': showSeparator
|
||||
})}
|
||||
onClick={() => handleOptionSelect(option.value)}
|
||||
>
|
||||
<span>{option.label}</span>
|
||||
{option.value === value && (
|
||||
<span className="ml-auto">✓</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
));
|
||||
} else {
|
||||
const flatOptions = options;
|
||||
return flatOptions.map((option) => (
|
||||
<div
|
||||
key={option.value}
|
||||
className={classnames('dropdown-item flex items-center gap-2', {
|
||||
active: option.value === value
|
||||
})}
|
||||
onClick={() => handleOptionSelect(option.value)}
|
||||
>
|
||||
<span>{option.label}</span>
|
||||
{option.value === value && (
|
||||
<span className="ml-auto">✓</span>
|
||||
)}
|
||||
</div>
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<Dropdown
|
||||
onCreate={onDropdownCreate}
|
||||
icon={<ButtonIcon selectedLabel={selectedLabel} prefix={prefix} suffix={suffix} disabled={disabled} className={className} style={style} {...props} />}
|
||||
placement="bottom-end"
|
||||
disabled={disabled}
|
||||
>
|
||||
<div {...(props['data-testid'] && { 'data-testid': props['data-testid'] + '-dropdown' })}>
|
||||
{header && (
|
||||
<div className="dropdown-header-container" onClick={() => dropdownTippyRef.current?.hide()}>
|
||||
{header}
|
||||
<div className="dropdown-divider"></div>
|
||||
</div>
|
||||
)}
|
||||
{renderOptions()}
|
||||
</div>
|
||||
</Dropdown>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default ButtonDropdown;
|
||||
44
packages/bruno-app/src/ui/ErrorAlert/StyledWrapper.js
Normal file
44
packages/bruno-app/src/ui/ErrorAlert/StyledWrapper.js
Normal file
@@ -0,0 +1,44 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
border-left: 4px solid ${(props) => props.theme.colors.text.danger};
|
||||
border-top: 1px solid transparent;
|
||||
border-right: 1px solid transparent;
|
||||
border-bottom: 1px solid transparent;
|
||||
border-radius: ${(props) => props.theme.border.radius.base};
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
max-height: 200px;
|
||||
min-height: 70px;
|
||||
overflow-y: auto;
|
||||
background-color: ${(props) => (props.theme.bg === '#1e1e1e' ? 'rgba(40, 40, 40, 0.5)' : 'rgba(250, 250, 250, 0.9)')};
|
||||
|
||||
.close-button {
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
svg {
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.375rem;
|
||||
color: ${(props) => props.theme.colors.text.danger};
|
||||
}
|
||||
|
||||
.error-message {
|
||||
font-family: monospace;
|
||||
font-size: ${(props) => props.theme.font.size.xs};
|
||||
line-height: 1.25rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
25
packages/bruno-app/src/ui/ErrorAlert/index.js
Normal file
25
packages/bruno-app/src/ui/ErrorAlert/index.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
import { IconX } from '@tabler/icons';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const ErrorAlert = ({ title, message, onClose }) => {
|
||||
if (!message) return null;
|
||||
|
||||
return (
|
||||
<StyledWrapper className="mt-4 mb-2">
|
||||
<div className="flex items-start gap-3 px-4 py-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
{title && <div className="error-title">{title}</div>}
|
||||
<div className="error-message">{typeof message === 'string' ? message : JSON.stringify(message, null, 2)}</div>
|
||||
</div>
|
||||
{onClose && (
|
||||
<div className="close-button flex-shrink-0 cursor-pointer" onClick={onClose}>
|
||||
<IconX size={16} strokeWidth={1.5} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default ErrorAlert;
|
||||
@@ -103,6 +103,8 @@ export const getCodeMirrorModeBasedOnContentType = (contentType, body) => {
|
||||
|
||||
if (contentType.includes('json')) {
|
||||
return 'application/ld+json';
|
||||
} else if (contentType.includes('javascript') || contentType.includes('ecmascript')) {
|
||||
return 'application/javascript';
|
||||
} else if (contentType.includes('image')) {
|
||||
return 'application/image';
|
||||
} else if (contentType.includes('xml')) {
|
||||
|
||||
@@ -4,6 +4,8 @@ import { JSONPath } from 'jsonpath-plus';
|
||||
import fastJsonFormat from 'fast-json-format';
|
||||
import { format, applyEdits } from 'jsonc-parser';
|
||||
import { patternHasher } from '@usebruno/common/utils';
|
||||
import prettierFormat from 'prettier/standalone';
|
||||
import parserBabel from 'prettier/parser-babel';
|
||||
|
||||
export const isPlaywright = () => {
|
||||
return typeof window !== 'undefined' && window.isPlaywright === true;
|
||||
@@ -104,6 +106,8 @@ export const getContentType = (headers) => {
|
||||
const SVG_PATTERN = /^image\/svg/i;
|
||||
// This pattern matches content types like application/xml, text/xml, application/atom+xml, etc.
|
||||
const XML_PATTERN = /^[\w\-]+\/([\w\-]+\+)?xml/;
|
||||
// This pattern matches JavaScript content types: application/javascript, text/javascript, application/ecmascript, text/ecmascript
|
||||
const JAVASCRIPT_PATTERN = /^(application|text)\/(javascript|ecmascript)/i;
|
||||
|
||||
if (JSON_PATTERN.test(contentType)) {
|
||||
return 'application/ld+json';
|
||||
@@ -111,6 +115,8 @@ export const getContentType = (headers) => {
|
||||
return 'image/svg+xml';
|
||||
} else if (XML_PATTERN.test(contentType)) {
|
||||
return 'application/xml';
|
||||
} else if (JAVASCRIPT_PATTERN.test(contentType)) {
|
||||
return 'application/javascript';
|
||||
}
|
||||
|
||||
return contentType;
|
||||
@@ -320,6 +326,105 @@ export const formatResponse = (data, dataBufferString, mode, filter, bufferThres
|
||||
return safeStringifyJSON(parsed, true);
|
||||
}
|
||||
|
||||
if (mode.includes('html')) {
|
||||
if (isVeryLargeResponse) {
|
||||
if (typeof data === 'string') {
|
||||
return data;
|
||||
}
|
||||
if (data === null || data === undefined) {
|
||||
return String(data);
|
||||
}
|
||||
if (typeof data === 'object') {
|
||||
return safeStringifyJSON(data, false);
|
||||
}
|
||||
return String(data);
|
||||
}
|
||||
|
||||
// Get HTML string from rawData
|
||||
let htmlString = rawData;
|
||||
// Prettify HTML
|
||||
try {
|
||||
return prettifyHtmlString(htmlString);
|
||||
} catch (error) {
|
||||
return htmlString;
|
||||
}
|
||||
}
|
||||
|
||||
if (mode.includes('javascript')) {
|
||||
if (isVeryLargeResponse) {
|
||||
if (typeof data === 'string') {
|
||||
return data;
|
||||
}
|
||||
if (data === null || data === undefined) {
|
||||
return String(data);
|
||||
}
|
||||
if (typeof data === 'object') {
|
||||
return safeStringifyJSON(data, false);
|
||||
}
|
||||
return String(data);
|
||||
}
|
||||
|
||||
// Get JavaScript string from rawData
|
||||
let jsString = rawData;
|
||||
|
||||
// Prettify JavaScript
|
||||
try {
|
||||
return prettifyJavaScriptString(jsString);
|
||||
} catch (error) {
|
||||
return jsString;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle hex format - return hex representation
|
||||
if (mode.includes('hex')) {
|
||||
// Check if data is already in hex format
|
||||
if (typeof data === 'string' && isHexFormat(data)) {
|
||||
// Data is already in hex format, return it as-is
|
||||
return data;
|
||||
}
|
||||
|
||||
// Data is not in hex format, encode it to hex
|
||||
try {
|
||||
const dataBuffer = Buffer.from(dataBufferString, 'base64');
|
||||
const hexView = formatHexView(dataBuffer);
|
||||
return hexView;
|
||||
} catch (error) {
|
||||
// If buffer conversion fails, try to encode the string data directly
|
||||
if (typeof data === 'string') {
|
||||
try {
|
||||
const stringBuffer = Buffer.from(data, 'utf8');
|
||||
return formatHexView(stringBuffer);
|
||||
} catch (stringError) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// Handle base64 format - return base64 string as-is
|
||||
if (mode.includes('base64')) {
|
||||
return dataBufferString;
|
||||
}
|
||||
|
||||
// Handle raw format - return data as-is without any formatting
|
||||
if (mode.includes('text') || mode.includes('raw')) {
|
||||
if (isVeryLargeResponse) {
|
||||
if (typeof data === 'string') {
|
||||
return data;
|
||||
}
|
||||
if (data === null || data === undefined) {
|
||||
return String(data);
|
||||
}
|
||||
if (typeof data === 'object') {
|
||||
return safeStringifyJSON(data, false);
|
||||
}
|
||||
return String(data);
|
||||
}
|
||||
// Return the raw decoded buffer data
|
||||
return rawData;
|
||||
}
|
||||
|
||||
if (typeof data === 'string') {
|
||||
return data;
|
||||
}
|
||||
@@ -362,3 +467,96 @@ export const toTitleCase = (str) => {
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
||||
.join(' ');
|
||||
};
|
||||
// Simple HTML formatter that indents HTML properly
|
||||
export function prettifyHtmlString(htmlString) {
|
||||
if (typeof htmlString !== 'string') return htmlString;
|
||||
|
||||
try {
|
||||
// Use xml-formatter which works well for HTML
|
||||
return xmlFormat(htmlString, {
|
||||
collapseContent: true,
|
||||
lineSeparator: '\n',
|
||||
whiteSpaceAtEndOfSelfClosingTag: true
|
||||
});
|
||||
} catch (error) {
|
||||
console.log('error formatting html data!');
|
||||
console.error(error);
|
||||
// Fallback: return original string if formatting fails
|
||||
return htmlString;
|
||||
}
|
||||
};
|
||||
|
||||
// Simple JavaScript formatter that uses prettier
|
||||
export function prettifyJavaScriptString(jsString) {
|
||||
if (typeof jsString !== 'string') return jsString;
|
||||
|
||||
try {
|
||||
return prettierFormat.format(jsString, {
|
||||
parser: 'babel',
|
||||
plugins: [parserBabel],
|
||||
semi: true,
|
||||
singleQuote: true,
|
||||
tabWidth: 2,
|
||||
trailingComma: 'none',
|
||||
printWidth: 120
|
||||
});
|
||||
} catch (error) {
|
||||
// If prettier fails, return the original string
|
||||
return jsString;
|
||||
}
|
||||
};
|
||||
|
||||
// Check if string contains valid HTML structure
|
||||
export const isValidHtml = (str) => {
|
||||
if (typeof str !== 'string' || !str.trim()) return false;
|
||||
return /<\s*html[\s>]/i.test(str);
|
||||
};
|
||||
|
||||
export function formatHexView(buffer) {
|
||||
const width = 16;
|
||||
let output = '';
|
||||
|
||||
for (let i = 0; i < buffer.length; i += width) {
|
||||
const slice = buffer.slice(i, i + width);
|
||||
const hex = Array.from(slice)
|
||||
.map((b) => b.toString(16).padStart(2, '0').toUpperCase())
|
||||
.join(' ');
|
||||
const ascii = Array.from(slice)
|
||||
.map((b) => (b >= 32 && b <= 126 ? String.fromCharCode(b) : '.'))
|
||||
.join('');
|
||||
|
||||
output += `${i.toString(16).padStart(8, '0')}: ${hex.padEnd(48)} ${ascii}\n`;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
// Function to detect if a string is already in hex format
|
||||
// Checks if the string looks like hex dump format (with addresses and ASCII) or plain hex
|
||||
export function isHexFormat(str) {
|
||||
if (typeof str !== 'string' || !str.trim()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const trimmed = str.trim();
|
||||
|
||||
// Check for hex dump format (e.g., "00000000: 48 65 6C 6C 6F 20 57 6F 72 6C 64 21 00 00 00 00 Hello World!....")
|
||||
const hexDumpPattern = /^[0-9a-fA-F]{8}:\s+([0-9a-fA-F]{2}\s+){1,16}/m;
|
||||
if (hexDumpPattern.test(trimmed)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for plain hex string (only hex characters, possibly with spaces)
|
||||
// Remove spaces and check if all characters are hex
|
||||
const hexOnly = trimmed.replace(/\s+/g, '');
|
||||
if (hexOnly.length > 0 && /^[0-9a-fA-F]+$/i.test(hexOnly)) {
|
||||
// Make sure it's not too short (could be a regular number) and has even length
|
||||
// Require minimum length of 6 to reduce false positives (e.g., "dead", "beef")
|
||||
// Also require at least one digit 0-9 to avoid matching all-letter words
|
||||
if (hexOnly.length >= 6 && hexOnly.length % 2 === 0 && /[0-9]/.test(hexOnly)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
260
packages/bruno-app/src/utils/response/index.js
Normal file
260
packages/bruno-app/src/utils/response/index.js
Normal file
@@ -0,0 +1,260 @@
|
||||
// Normalize & extract MIME type from full header
|
||||
const extractMimeType = (contentType = '') => {
|
||||
const cleaned = String(contentType).trim().toLowerCase();
|
||||
const match = cleaned.match(/^[^;]+/); // strip "; charset=utf-8"
|
||||
return match ? match[0] : cleaned;
|
||||
};
|
||||
|
||||
export const getDefaultResponseFormat = (contentType) => {
|
||||
const mime = extractMimeType(contentType);
|
||||
|
||||
const rules = [
|
||||
// ====== HTML ======
|
||||
{ test: /^text\/html$/, result: { format: 'html', tab: 'preview' } },
|
||||
|
||||
// ====== JSON (including custom +json types) ======
|
||||
{
|
||||
test: /^application\/(json|.+\+json)$/,
|
||||
result: { format: 'json', tab: 'editor' }
|
||||
},
|
||||
{
|
||||
test: /^text\/(json|.+\+json)$/,
|
||||
result: { format: 'json', tab: 'editor' }
|
||||
},
|
||||
|
||||
// ====== XML (including custom +xml types) ======
|
||||
{
|
||||
test: /^application\/(xml|.+\+xml)$/,
|
||||
result: { format: 'xml', tab: 'editor' }
|
||||
},
|
||||
{
|
||||
test: /^text\/(xml|.+\+xml)$/,
|
||||
result: { format: 'xml', tab: 'editor' }
|
||||
},
|
||||
|
||||
// ====== JavaScript ======
|
||||
{
|
||||
test: /^(application|text)\/javascript$/,
|
||||
result: { format: 'javascript', tab: 'editor' }
|
||||
},
|
||||
|
||||
// ====== Images, audio, video, PDFs → preview (base64) ======
|
||||
{ test: /^image\//, result: { format: 'base64', tab: 'preview' } },
|
||||
{ test: /^audio\//, result: { format: 'base64', tab: 'preview' } },
|
||||
{ test: /^video\//, result: { format: 'base64', tab: 'preview' } },
|
||||
{ test: /^application\/pdf$/, result: { format: 'base64', tab: 'preview' } },
|
||||
|
||||
// ====== Any other text types ======
|
||||
{ test: /^text\//, result: { format: 'raw', tab: 'editor' } }
|
||||
];
|
||||
|
||||
for (const rule of rules) {
|
||||
if (rule.test.test(mime)) {
|
||||
return rule.result;
|
||||
}
|
||||
}
|
||||
|
||||
// ====== Fallback ======
|
||||
return { format: 'raw', tab: 'editor' };
|
||||
};
|
||||
|
||||
// Safe HTML escaping for webview content
|
||||
export const escapeHtml = (text) => {
|
||||
if (typeof text !== 'string') return text;
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to detect if buffer contains text data
|
||||
*/
|
||||
const isLikelyText = (buffer) => {
|
||||
if (!buffer || buffer.length === 0) return false;
|
||||
let textChars = 0;
|
||||
const sampleSize = Math.min(buffer.length, 512);
|
||||
|
||||
for (let i = 0; i < sampleSize; i++) {
|
||||
const byte = buffer[i];
|
||||
// Check for common text characters (printable ASCII + common control chars)
|
||||
if ((byte >= 0x20 && byte <= 0x7E) // Printable ASCII
|
||||
|| byte === 0x09 // Tab
|
||||
|| byte === 0x0A // Line feed
|
||||
|| byte === 0x0D) { // Carriage return
|
||||
textChars++;
|
||||
}
|
||||
}
|
||||
|
||||
// If more than 85% are text characters, likely text
|
||||
return (textChars / sampleSize) > 0.85;
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to detect if snippet is valid HTML
|
||||
*/
|
||||
export const isValidHtmlSnippet = (snippet) => {
|
||||
if (!snippet || typeof snippet !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const trimmed = snippet.trim();
|
||||
|
||||
// Check for XML declaration
|
||||
if (trimmed.startsWith('<?xml')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for XML namespaces
|
||||
if (/xmlns(:\w+)?=/.test(trimmed)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Extract all tag names from the snippet
|
||||
const tagMatches = trimmed.matchAll(/<\s*\/?([a-zA-Z][a-zA-Z0-9]*)/g);
|
||||
const tags = [...tagMatches].map((match) => match[1].toLowerCase());
|
||||
|
||||
if (tags.length === 0) {
|
||||
return false; // No tags found
|
||||
}
|
||||
|
||||
// Define recognized HTML tags
|
||||
const validHtmlTags = new Set([
|
||||
'a', 'abbr', 'address', 'area', 'article', 'aside', 'audio',
|
||||
'b', 'base', 'bdi', 'bdo', 'blockquote', 'body', 'br', 'button',
|
||||
'canvas', 'caption', 'cite', 'code', 'col', 'colgroup',
|
||||
'data', 'datalist', 'dd', 'del', 'details', 'dfn', 'dialog', 'div', 'dl', 'dt',
|
||||
'em', 'embed',
|
||||
'fieldset', 'figcaption', 'figure', 'footer', 'form',
|
||||
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'head', 'header', 'hgroup', 'hr', 'html',
|
||||
'i', 'iframe', 'img', 'input', 'ins',
|
||||
'kbd',
|
||||
'label', 'legend', 'li', 'link',
|
||||
'main', 'map', 'mark', 'meta', 'meter',
|
||||
'nav', 'noscript',
|
||||
'object', 'ol', 'optgroup', 'option', 'output',
|
||||
'p', 'param', 'picture', 'pre', 'progress',
|
||||
'q',
|
||||
'rp', 'rt', 'ruby',
|
||||
's', 'samp', 'script', 'section', 'select', 'slot', 'small', 'source', 'span', 'strong', 'style', 'sub', 'summary', 'sup', 'svg',
|
||||
'table', 'tbody', 'td', 'template', 'textarea', 'tfoot', 'th', 'thead', 'time', 'title', 'tr', 'track',
|
||||
'u', 'ul',
|
||||
'var', 'video',
|
||||
'wbr'
|
||||
]);
|
||||
|
||||
// Check if all tags are valid HTML tags
|
||||
const allTagsValid = tags.every((tag) => validHtmlTags.has(tag));
|
||||
|
||||
if (!allTagsValid) {
|
||||
return false; // Contains non-HTML tags
|
||||
}
|
||||
|
||||
try {
|
||||
// Parse with DOMParser
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(trimmed, 'text/html');
|
||||
|
||||
// Check for parsing errors
|
||||
const parseError = doc.querySelector('parsererror');
|
||||
if (parseError) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// HTML parser is lenient; if we reach here with valid tags, consider it valid
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Detects content type from buffer by checking magic numbers (file signatures)
|
||||
* @param {Buffer} buffer - The data buffer to analyze
|
||||
* @returns {string|null} - Detected MIME type or null
|
||||
*/
|
||||
export const detectContentTypeFromBuffer = (buffer) => {
|
||||
if (!buffer || buffer.length < 4) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get first few bytes for magic number checking
|
||||
const bytes = buffer.subarray(0, 12);
|
||||
|
||||
// Image formats
|
||||
if (bytes[0] === 0xFF && bytes[1] === 0xD8 && bytes[2] === 0xFF) {
|
||||
return 'image/jpeg';
|
||||
}
|
||||
if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4E && bytes[3] === 0x47) {
|
||||
return 'image/png';
|
||||
}
|
||||
if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46) {
|
||||
return 'image/gif';
|
||||
}
|
||||
if (bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50) {
|
||||
return 'image/webp';
|
||||
}
|
||||
if (bytes[0] === 0x42 && bytes[1] === 0x4D) {
|
||||
return 'image/bmp';
|
||||
}
|
||||
if ((bytes[0] === 0x49 && bytes[1] === 0x49 && bytes[2] === 0x2A && bytes[3] === 0x00)
|
||||
|| (bytes[0] === 0x4D && bytes[1] === 0x4D && bytes[2] === 0x00 && bytes[3] === 0x2A)) {
|
||||
return 'image/tiff';
|
||||
}
|
||||
if (bytes[0] === 0x00 && bytes[1] === 0x00 && bytes[2] === 0x01 && bytes[3] === 0x00) {
|
||||
return 'image/x-icon';
|
||||
}
|
||||
|
||||
// PDF
|
||||
if (bytes[0] === 0x25 && bytes[1] === 0x50 && bytes[2] === 0x44 && bytes[3] === 0x46) {
|
||||
return 'application/pdf';
|
||||
}
|
||||
|
||||
// Video formats
|
||||
if (bytes[0] === 0x00 && bytes[1] === 0x00 && bytes[2] === 0x00
|
||||
&& (bytes[3] === 0x18 || bytes[3] === 0x20)
|
||||
&& bytes[4] === 0x66 && bytes[5] === 0x74 && bytes[6] === 0x79 && bytes[7] === 0x70) {
|
||||
return 'video/mp4';
|
||||
}
|
||||
if ((bytes[0] === 0x1A && bytes[1] === 0x45 && bytes[2] === 0xDF && bytes[3] === 0xA3)) {
|
||||
return 'video/webm';
|
||||
}
|
||||
if (bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46
|
||||
&& bytes[8] === 0x41 && bytes[9] === 0x56 && bytes[10] === 0x49 && bytes[11] === 0x20) {
|
||||
return 'video/x-msvideo'; // AVI
|
||||
}
|
||||
|
||||
// Audio formats
|
||||
if (bytes[0] === 0xFF && (bytes[1] & 0xE0) === 0xE0) {
|
||||
return 'audio/mpeg'; // MP3
|
||||
}
|
||||
if (bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46
|
||||
&& bytes[8] === 0x57 && bytes[9] === 0x41 && bytes[10] === 0x56 && bytes[11] === 0x45) {
|
||||
return 'audio/wav';
|
||||
}
|
||||
if (bytes[0] === 0x4F && bytes[1] === 0x67 && bytes[2] === 0x67 && bytes[3] === 0x53) {
|
||||
return 'audio/ogg';
|
||||
}
|
||||
if (bytes[4] === 0x66 && bytes[5] === 0x74 && bytes[6] === 0x79 && bytes[7] === 0x70
|
||||
&& bytes[8] === 0x4D && bytes[9] === 0x34 && bytes[10] === 0x41) {
|
||||
return 'audio/m4a';
|
||||
}
|
||||
|
||||
// Archive formats
|
||||
if (bytes[0] === 0x50 && bytes[1] === 0x4B
|
||||
&& (bytes[2] === 0x03 || bytes[2] === 0x05 || bytes[2] === 0x07)) {
|
||||
return 'application/zip';
|
||||
}
|
||||
if (bytes[0] === 0x1F && bytes[1] === 0x8B) {
|
||||
return 'application/gzip';
|
||||
}
|
||||
|
||||
// Check if it's likely text (UTF-8)
|
||||
if (isLikelyText(buffer.slice(0, Math.min(512, buffer.length)))) {
|
||||
return 'text/plain';
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import { execSync } from 'child_process';
|
||||
import { test, expect } from '../../playwright';
|
||||
import path from 'path';
|
||||
import { clickResponseAction } from '../utils/page/actions';
|
||||
|
||||
test.describe.serial('Create and Delete Response Examples', () => {
|
||||
test.afterAll(async () => {
|
||||
@@ -17,7 +18,7 @@ test.describe.serial('Create and Delete Response Examples', () => {
|
||||
await test.step('Send request and validate example creation', async () => {
|
||||
await page.getByTestId('send-arrow-icon').click();
|
||||
// Wait for 30 seconds for the response bookmark button to be visible, on slower internet connections it may take longer to get the response.
|
||||
await page.getByTestId('response-bookmark-btn').click({ timeout: 30000 });
|
||||
await clickResponseAction(page, 'response-bookmark-btn');
|
||||
|
||||
await expect(page.getByText('Save Response as Example')).toBeVisible();
|
||||
await expect(page.getByTestId('create-example-name-input')).toBeVisible();
|
||||
@@ -39,7 +40,7 @@ test.describe.serial('Create and Delete Response Examples', () => {
|
||||
|
||||
await test.step('Validate error when name is empty', async () => {
|
||||
await page.getByTestId('send-arrow-icon').click();
|
||||
await page.getByTestId('response-bookmark-btn').click({ timeout: 30000 });
|
||||
await clickResponseAction(page, 'response-bookmark-btn');
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Create Example' })).toBeEnabled();
|
||||
|
||||
@@ -63,7 +64,7 @@ test.describe.serial('Create and Delete Response Examples', () => {
|
||||
await test.step('Test modal cancellation', async () => {
|
||||
await page.locator('.collection-item-name').getByText('create-example').click();
|
||||
await page.getByTestId('send-arrow-icon').click();
|
||||
await page.getByTestId('response-bookmark-btn').click({ timeout: 30000 });
|
||||
await clickResponseAction(page, 'response-bookmark-btn');
|
||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||
await expect(page.getByText('Save Response as Example')).not.toBeVisible();
|
||||
});
|
||||
@@ -77,13 +78,13 @@ test.describe.serial('Create and Delete Response Examples', () => {
|
||||
|
||||
await test.step('Test form reset', async () => {
|
||||
await page.locator('#send-request').getByRole('img').nth(2).click();
|
||||
await page.getByTestId('response-bookmark-btn').click({ timeout: 30000 });
|
||||
await clickResponseAction(page, 'response-bookmark-btn');
|
||||
|
||||
await page.getByTestId('create-example-name-input').fill('Test Name');
|
||||
await page.getByTestId('create-example-description-input').fill('Test Description');
|
||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||
|
||||
await page.getByTestId('response-bookmark-btn').click({ timeout: 30000 });
|
||||
await clickResponseAction(page, 'response-bookmark-btn');
|
||||
// The name field should have the pre-filled default value
|
||||
await expect(page.getByTestId('create-example-name-input')).toHaveValue('example');
|
||||
// Description should still be empty
|
||||
@@ -100,7 +101,7 @@ test.describe.serial('Create and Delete Response Examples', () => {
|
||||
|
||||
await test.step('Create example and verify sidebar visibility', async () => {
|
||||
await page.getByTestId('send-arrow-icon').click();
|
||||
await page.getByTestId('response-bookmark-btn').click({ timeout: 30000 });
|
||||
await clickResponseAction(page, 'response-bookmark-btn');
|
||||
|
||||
await page.getByTestId('create-example-name-input').clear();
|
||||
await page.getByTestId('create-example-name-input').fill('Sidebar Test Example');
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { test, expect } from '../../playwright';
|
||||
import { execSync } from 'child_process';
|
||||
import path from 'path';
|
||||
import { clickResponseAction } from '../utils/page/actions';
|
||||
|
||||
test.describe.serial('Edit Response Examples', () => {
|
||||
test.afterAll(async () => {
|
||||
@@ -16,7 +17,7 @@ test.describe.serial('Edit Response Examples', () => {
|
||||
|
||||
await test.step('Make a successful request and create an example', async () => {
|
||||
await page.getByTestId('send-arrow-icon').click();
|
||||
await page.getByTestId('response-bookmark-btn').click();
|
||||
await clickResponseAction(page, 'response-bookmark-btn');
|
||||
await page.getByTestId('create-example-name-input').clear();
|
||||
await page.getByTestId('create-example-name-input').fill('Test Example');
|
||||
await page.getByTestId('create-example-description-input').fill('This is a test example');
|
||||
@@ -51,7 +52,7 @@ test.describe.serial('Edit Response Examples', () => {
|
||||
|
||||
await test.step('Create example to update', async () => {
|
||||
await page.getByTestId('send-arrow-icon').click();
|
||||
await page.getByTestId('response-bookmark-btn').click({ timeout: 30000 });
|
||||
await clickResponseAction(page, 'response-bookmark-btn');
|
||||
await page.getByTestId('create-example-name-input').clear();
|
||||
await page.getByTestId('create-example-name-input').fill('Original Example Name');
|
||||
await page.getByTestId('create-example-description-input').fill('Original description');
|
||||
@@ -85,7 +86,7 @@ test.describe.serial('Edit Response Examples', () => {
|
||||
|
||||
await test.step('Create example to update description', async () => {
|
||||
await page.getByTestId('send-arrow-icon').click();
|
||||
await page.getByTestId('response-bookmark-btn').click({ timeout: 30000 });
|
||||
await clickResponseAction(page, 'response-bookmark-btn');
|
||||
await page.getByTestId('create-example-name-input').clear();
|
||||
await page.getByTestId('create-example-name-input').fill('Description Test Example');
|
||||
await page.getByTestId('create-example-description-input').fill('Original description');
|
||||
@@ -119,7 +120,7 @@ test.describe.serial('Edit Response Examples', () => {
|
||||
|
||||
await test.step('Create example to test cancel functionality', async () => {
|
||||
await page.getByTestId('send-arrow-icon').click();
|
||||
await page.getByTestId('response-bookmark-btn').click({ timeout: 30000 });
|
||||
await clickResponseAction(page, 'response-bookmark-btn');
|
||||
await page.getByTestId('create-example-name-input').clear();
|
||||
await page.getByTestId('create-example-name-input').fill('Cancel Test Example');
|
||||
await page.getByTestId('create-example-description-input').fill('Original description for cancel test');
|
||||
@@ -154,7 +155,7 @@ test.describe.serial('Edit Response Examples', () => {
|
||||
|
||||
await test.step('Create example to test keyboard shortcut', async () => {
|
||||
await page.getByTestId('send-arrow-icon').click();
|
||||
await page.getByTestId('response-bookmark-btn').click({ timeout: 30000 });
|
||||
await clickResponseAction(page, 'response-bookmark-btn');
|
||||
await page.getByTestId('create-example-name-input').clear();
|
||||
await page.getByTestId('create-example-name-input').fill('Keyboard Shortcut Test Example');
|
||||
await page.getByTestId('create-example-description-input').fill('Original description for keyboard test');
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { test, expect } from '../../playwright';
|
||||
import { execSync } from 'child_process';
|
||||
import path from 'path';
|
||||
import { clickResponseAction } from '../utils/page/actions';
|
||||
|
||||
test.describe.serial('Response Example Menu Operations', () => {
|
||||
test.setTimeout(1 * 60 * 1000); // 1 minute for all tests in this describe block, default is 30 seconds.
|
||||
@@ -17,7 +18,7 @@ test.describe.serial('Response Example Menu Operations', () => {
|
||||
|
||||
await test.step('Create example', async () => {
|
||||
await page.getByTestId('send-arrow-icon').click();
|
||||
await page.getByTestId('response-bookmark-btn').click();
|
||||
await clickResponseAction(page, 'response-bookmark-btn');
|
||||
await page.getByTestId('create-example-name-input').clear();
|
||||
await page.getByTestId('create-example-name-input').fill('Example to Clone');
|
||||
await page.getByRole('button', { name: 'Create Example' }).click();
|
||||
@@ -48,7 +49,7 @@ test.describe.serial('Response Example Menu Operations', () => {
|
||||
|
||||
await test.step('Create example to delete', async () => {
|
||||
await page.getByTestId('send-arrow-icon').click();
|
||||
await page.getByTestId('response-bookmark-btn').click({ timeout: 30000 });
|
||||
await clickResponseAction(page, 'response-bookmark-btn');
|
||||
await page.getByTestId('create-example-name-input').clear();
|
||||
await page.getByTestId('create-example-name-input').fill('Example to Delete');
|
||||
await page.getByTestId('create-example-description-input').fill('This example will be deleted');
|
||||
@@ -81,7 +82,7 @@ test.describe.serial('Response Example Menu Operations', () => {
|
||||
|
||||
await test.step('Create example to rename', async () => {
|
||||
await page.getByTestId('send-arrow-icon').click();
|
||||
await page.getByTestId('response-bookmark-btn').click({ timeout: 30000 });
|
||||
await clickResponseAction(page, 'response-bookmark-btn');
|
||||
await page.getByTestId('create-example-name-input').clear();
|
||||
await page.getByTestId('create-example-name-input').fill('Example to Rename');
|
||||
await page.getByTestId('create-example-description-input').fill('This example will be renamed');
|
||||
|
||||
@@ -35,6 +35,6 @@ test.describe('Large Response Crash/High Memory Usage Prevention', () => {
|
||||
await expect(page.getByText('could degrade performance')).toBeVisible();
|
||||
|
||||
// Verify action button
|
||||
await expect(page.getByRole('button', { name: 'View' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'View', exact: true })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { test, expect } from '../../playwright';
|
||||
import {
|
||||
clickResponseAction,
|
||||
closeAllCollections,
|
||||
createCollection,
|
||||
createRequest,
|
||||
@@ -26,8 +27,7 @@ test.describe('Response Pane Actions', () => {
|
||||
});
|
||||
|
||||
await test.step('Copy response to clipboard', async () => {
|
||||
await expect(locators.response.copyButton()).toBeVisible();
|
||||
await locators.response.copyButton().click();
|
||||
await clickResponseAction(page, 'response-copy-btn');
|
||||
await expect(page.getByText('Response copied to clipboard')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"version": "1",
|
||||
"name": "collection",
|
||||
"type": "collection",
|
||||
"ignore": [
|
||||
"node_modules",
|
||||
".git"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
meta {
|
||||
name: request-html
|
||||
type: http
|
||||
seq: 5
|
||||
}
|
||||
|
||||
post {
|
||||
url: https://www.httpfaker.org/api/echo/custom
|
||||
body: json
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
body:json {
|
||||
{
|
||||
"headers": { "content-type": "text/html" },
|
||||
"content": "<h1>hello</h1>"
|
||||
}
|
||||
}
|
||||
|
||||
settings {
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
meta {
|
||||
name: request-json
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
post {
|
||||
url: https://echo.usebruno.com
|
||||
body: json
|
||||
auth: none
|
||||
}
|
||||
|
||||
body:json {
|
||||
{
|
||||
"hello": "bruno"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"collections": [
|
||||
{
|
||||
"path": "{{projectRoot}}/tests/response/response-format-select-and-preview/fixtures/collection",
|
||||
"securityConfig": {
|
||||
"jsSandboxMode": "safe"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"maximized": false,
|
||||
"lastOpenedCollections": [
|
||||
"{{projectRoot}}/tests/response/response-format-select-and-preview/fixtures/collection"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { test, expect } from '../../../playwright';
|
||||
import { closeAllCollections } from '../../utils/page';
|
||||
import { buildCommonLocators } from '../../utils/page/locators';
|
||||
import {
|
||||
openRequest,
|
||||
sendRequestAndWaitForResponse,
|
||||
switchResponseFormat,
|
||||
switchToPreviewTab,
|
||||
switchToEditorTab
|
||||
} from '../../utils/page/actions';
|
||||
|
||||
test.describe.serial('Response Format Select and Preview', () => {
|
||||
test.afterAll(async ({ pageWithUserData: page }) => {
|
||||
// cleanup: close all collections
|
||||
await closeAllCollections(page);
|
||||
});
|
||||
|
||||
test('Verify Response Format Select and Preview features are rendering properly for JSON response', async ({ pageWithUserData: page }) => {
|
||||
await openRequest(page, 'collection', 'request-json');
|
||||
await sendRequestAndWaitForResponse(page);
|
||||
|
||||
const locators = buildCommonLocators(page);
|
||||
const responseBody = locators.response.body();
|
||||
const editorContainer = locators.response.editorContainer();
|
||||
const responseFormatTab = locators.response.formatTab();
|
||||
const codeLine = locators.response.codeLine();
|
||||
const previewContainer = locators.response.previewContainer();
|
||||
|
||||
await test.step('Verify response pane and default JSON editor formatting', async () => {
|
||||
await expect(responseBody).toBeVisible();
|
||||
await expect(responseFormatTab).toHaveText('JSON');
|
||||
await expect(codeLine.nth(1)).toContainText('"hello": "bruno"');
|
||||
});
|
||||
|
||||
await test.step('Switch to Preview tab and check formatted object', async () => {
|
||||
await switchToPreviewTab(page);
|
||||
const jsonTreeLines = locators.response.jsonTreeLine();
|
||||
await expect(jsonTreeLines.nth(1)).toContainText('"hello":"bruno"');
|
||||
});
|
||||
|
||||
await test.step('Switch to Editor, select HTML, verify editor and preview', async () => {
|
||||
await switchToEditorTab(page);
|
||||
await switchResponseFormat(page, 'HTML');
|
||||
await expect(codeLine.nth(1)).toContainText('"hello": "bruno"');
|
||||
await switchToPreviewTab(page);
|
||||
await expect(previewContainer).toContainText('{"hello":"bruno"}');
|
||||
});
|
||||
|
||||
await test.step('Switch to Editor, select XML, verify editor and preview error', async () => {
|
||||
await switchToEditorTab(page);
|
||||
await switchResponseFormat(page, 'XML');
|
||||
await expect(codeLine.nth(1)).toContainText('"hello": "bruno"');
|
||||
await switchToPreviewTab(page);
|
||||
await expect(previewContainer).toContainText('Cannot preview as XML');
|
||||
});
|
||||
|
||||
await test.step('Switch to Editor, select JavaScript, verify editor and preview fallback', async () => {
|
||||
await switchToEditorTab(page);
|
||||
await switchResponseFormat(page, 'JavaScript');
|
||||
await expect(codeLine.nth(1)).toContainText('"hello": "bruno"');
|
||||
await switchToPreviewTab(page);
|
||||
await expect(previewContainer).toContainText('{"hello":"bruno"}');
|
||||
});
|
||||
|
||||
await test.step('Switch to Editor, select Raw, verify editor and preview', async () => {
|
||||
await switchToEditorTab(page);
|
||||
await switchResponseFormat(page, 'Raw');
|
||||
await expect(codeLine.nth(1)).toContainText('"hello": "bruno"');
|
||||
await switchToPreviewTab(page);
|
||||
await expect(previewContainer).toContainText('{"hello":"bruno"}');
|
||||
});
|
||||
|
||||
await test.step('Switch to Editor, select Hex, verify editor and preview', async () => {
|
||||
await switchToEditorTab(page);
|
||||
await switchResponseFormat(page, 'Hex');
|
||||
await expect(editorContainer).toContainText('00000000: 7B 0A 20 20 22 68 65 6C 6C 6F 22 3A 20 22');
|
||||
await switchToPreviewTab(page);
|
||||
await expect(previewContainer).toContainText('{"hello":"bruno"}');
|
||||
});
|
||||
|
||||
await test.step('Switch to Editor, select Base64, verify editor and preview', async () => {
|
||||
await switchToEditorTab(page);
|
||||
await switchResponseFormat(page, 'Base64');
|
||||
await expect(editorContainer).toContainText('ewogICJoZWxsbyI6ICJicnVubyIKfQ==');
|
||||
await switchToPreviewTab(page);
|
||||
await expect(previewContainer).toContainText('{"hello":"bruno"}');
|
||||
});
|
||||
});
|
||||
|
||||
test('Verify Response Format Select and Preview features are rendering properly for HTML response', async ({ pageWithUserData: page }) => {
|
||||
await openRequest(page, 'collection', 'request-html');
|
||||
await sendRequestAndWaitForResponse(page);
|
||||
|
||||
const locators = buildCommonLocators(page);
|
||||
const responseBody = locators.response.body();
|
||||
const editorContainer = locators.response.editorContainer();
|
||||
const responseFormatTab = locators.response.formatTab();
|
||||
const codeLine = locators.response.codeLine();
|
||||
const previewContainer = locators.response.previewContainer();
|
||||
|
||||
await test.step('Verify response pane and default HTML preview', async () => {
|
||||
await expect(responseBody).toBeVisible();
|
||||
await expect(previewContainer.locator('webview')).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Switch to Editor tab and check formatted HTML', async () => {
|
||||
await expect(responseFormatTab).toHaveText('HTML');
|
||||
await switchToEditorTab(page);
|
||||
await expect(codeLine.first()).toContainText('<h1>hello</h1>');
|
||||
});
|
||||
|
||||
await test.step('Select JSON, verify editor and preview', async () => {
|
||||
await switchResponseFormat(page, 'JSON');
|
||||
await expect(codeLine.first()).toContainText('<h1>hello</h1>');
|
||||
await switchToPreviewTab(page);
|
||||
await expect(previewContainer).toContainText('Cannot preview as JSON');
|
||||
});
|
||||
|
||||
await test.step('Switch to Editor, select XML, verify editor and preview', async () => {
|
||||
await switchToEditorTab(page);
|
||||
await switchResponseFormat(page, 'XML');
|
||||
await expect(codeLine.first()).toContainText('<h1>hello</h1>');
|
||||
await switchToPreviewTab(page);
|
||||
await expect(previewContainer).toContainText('h1');
|
||||
await expect(previewContainer).toContainText(':');
|
||||
await expect(previewContainer).toContainText('hello');
|
||||
});
|
||||
|
||||
await test.step('Switch to Editor, select JavaScript, verify editor and preview fallback', async () => {
|
||||
await switchToEditorTab(page);
|
||||
await switchResponseFormat(page, 'JavaScript');
|
||||
await expect(codeLine.first()).toContainText('<h1>hello</h1>');
|
||||
await switchToPreviewTab(page);
|
||||
await expect(previewContainer.locator('webview')).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Switch to Editor, select Raw, verify editor and preview', async () => {
|
||||
await switchToEditorTab(page);
|
||||
await switchResponseFormat(page, 'Raw');
|
||||
await expect(codeLine.first()).toContainText('<h1>hello</h1>');
|
||||
await switchToPreviewTab(page);
|
||||
await expect(previewContainer).toContainText('<h1>hello</h1>');
|
||||
});
|
||||
|
||||
await test.step('Switch to Editor, select Hex, verify editor and preview', async () => {
|
||||
await switchToEditorTab(page);
|
||||
await switchResponseFormat(page, 'Hex');
|
||||
await expect(editorContainer).toContainText('00000000: 3C 68 31 3E 68 65 6C 6C 6F 3C 2F 68 31 3E');
|
||||
await switchToPreviewTab(page);
|
||||
await expect(previewContainer).toContainText('<h1>hello</h1>');
|
||||
});
|
||||
|
||||
await test.step('Switch to Editor, select Base64, verify editor and preview', async () => {
|
||||
await switchToEditorTab(page);
|
||||
await switchResponseFormat(page, 'Base64');
|
||||
await expect(editorContainer).toContainText('PGgxPmhlbGxvPC9oMT4=');
|
||||
await switchToPreviewTab(page);
|
||||
await expect(previewContainer).toContainText('<h1>hello</h1>');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -531,14 +531,29 @@ const sendRequest = async (
|
||||
* @param requestName - The name of the request to open
|
||||
* @returns void
|
||||
*/
|
||||
const openRequest = async (page: Page, requestName: string) => {
|
||||
await test.step(`Open request "${requestName}"`, async () => {
|
||||
const locators = buildCommonLocators(page);
|
||||
await locators.sidebar.request(requestName).click();
|
||||
await expect(locators.tabs.activeRequestTab()).toContainText(requestName);
|
||||
// const openRequest = async (page: Page, requestName: string) => {
|
||||
// await test.step(`Open request "${requestName}"`, async () => {
|
||||
// const locators = buildCommonLocators(page);
|
||||
// await locators.sidebar.request(requestName).click();
|
||||
// await expect(locators.tabs.activeRequestTab()).toContainText(requestName);
|
||||
// });
|
||||
// };
|
||||
|
||||
/**
|
||||
* Navigate to a collection and open a request
|
||||
* @param page - The page object
|
||||
* @param collectionName - The name of the collection
|
||||
* @param requestName - The name of the request
|
||||
*/
|
||||
const openRequest = async (page: Page, collectionName: string, requestName: string) => {
|
||||
await test.step(`Navigate to collection "${collectionName}" and open request "${requestName}"`, async () => {
|
||||
const collectionContainer = page.getByTestId('sidebar-collection-row').filter({ hasText: collectionName });
|
||||
await collectionContainer.click();
|
||||
const collectionWrapper = collectionContainer.locator('..');
|
||||
const request = collectionWrapper.getByTestId('sidebar-collection-item-row').filter({ hasText: requestName });
|
||||
await request.click();
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Open a request within a folder
|
||||
* @param page - The page object
|
||||
@@ -554,6 +569,64 @@ const openFolderRequest = async (page: Page, folderName: string, requestName: st
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Send a request and wait for the response
|
||||
* @param page - The page object
|
||||
* @param expectedStatusCode - The expected status code (default: '200')
|
||||
* @param options - The options for sending the request (default: { timeout: 15000 })
|
||||
*/
|
||||
const sendRequestAndWaitForResponse = async (page: Page,
|
||||
expectedStatusCode: string = '200',
|
||||
options: {
|
||||
ignoreCase?: boolean;
|
||||
timeout?: number;
|
||||
useInnerText?: boolean;
|
||||
} = { timeout: 15000 }) => {
|
||||
await test.step(`Send request and wait for status code ${expectedStatusCode}`, async () => {
|
||||
await page.getByTestId('send-arrow-icon').click();
|
||||
await expect(page.getByTestId('response-status-code')).toContainText(expectedStatusCode, options);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Switch the response format
|
||||
* @param page - The page object
|
||||
* @param format - The format to switch to (e.g., 'JSON', 'HTML', 'XML', 'JavaScript', 'Raw', 'Hex', 'Base64')
|
||||
*/
|
||||
const switchResponseFormat = async (page: Page, format: string) => {
|
||||
await test.step(`Switch response format to ${format}`, async () => {
|
||||
const responseFormatTab = page.getByTestId('format-response-tab');
|
||||
await responseFormatTab.click();
|
||||
await page.getByTestId('format-response-tab-dropdown').getByText(format).click();
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Switch to the preview tab
|
||||
* @param page - The page object
|
||||
*/
|
||||
const switchToPreviewTab = async (page: Page) => {
|
||||
await test.step('Switch to preview tab', async () => {
|
||||
const responseFormatTab = page.getByTestId('format-response-tab');
|
||||
await responseFormatTab.click();
|
||||
const previewTab = page.getByTestId('preview-response-tab');
|
||||
await previewTab.click();
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Switch to the editor tab
|
||||
* @param page - The page object
|
||||
*/
|
||||
const switchToEditorTab = async (page: Page) => {
|
||||
await test.step('Switch to editor tab', async () => {
|
||||
const responseFormatTab = page.getByTestId('format-response-tab');
|
||||
await responseFormatTab.click();
|
||||
const previewTab = page.getByTestId('preview-response-tab');
|
||||
await previewTab.click();
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the response body text
|
||||
* @param page - The page object
|
||||
@@ -605,6 +678,18 @@ const expectResponseContains = async (page: Page, texts: string[]) => {
|
||||
});
|
||||
};
|
||||
|
||||
// Create a action to click a response action
|
||||
const clickResponseAction = async (page: Page, actionTestId: string) => {
|
||||
const actionButton = await page.getByTestId(actionTestId);
|
||||
if (await actionButton.isVisible()) {
|
||||
await actionButton.click();
|
||||
} else {
|
||||
const menu = await page.getByTestId('response-actions-menu');
|
||||
await menu.click();
|
||||
await actionButton.click();
|
||||
}
|
||||
};
|
||||
|
||||
export {
|
||||
closeAllCollections,
|
||||
openCollectionAndAcceptSandbox,
|
||||
@@ -627,7 +712,12 @@ export {
|
||||
openFolderRequest,
|
||||
getResponseBody,
|
||||
expectResponseContains,
|
||||
selectRequestPaneTab
|
||||
selectRequestPaneTab,
|
||||
sendRequestAndWaitForResponse,
|
||||
switchResponseFormat,
|
||||
switchToPreviewTab,
|
||||
switchToEditorTab,
|
||||
clickResponseAction
|
||||
};
|
||||
|
||||
export type { SandboxMode, EnvironmentType, EnvironmentVariable, CreateCollectionOptions, ImportCollectionOptions, CreateRequestOptions, CreateUntitledRequestOptions };
|
||||
|
||||
@@ -78,7 +78,14 @@ export const buildCommonLocators = (page: Page) => ({
|
||||
response: {
|
||||
statusCode: () => page.getByTestId('response-status-code'),
|
||||
pane: () => page.locator('.response-pane'),
|
||||
copyButton: () => page.locator('button[title="Copy response to clipboard"]')
|
||||
copyButton: () => page.locator('button[title="Copy response to clipboard"]'),
|
||||
body: () => page.locator('.response-pane'),
|
||||
editorContainer: () => page.locator('.response-pane .editor-container'),
|
||||
formatTab: () => page.getByTestId('format-response-tab'),
|
||||
formatTabDropdown: () => page.getByTestId('format-response-tab-dropdown'),
|
||||
previewContainer: () => page.getByTestId('response-preview-container'),
|
||||
codeLine: () => page.locator('.response-pane .editor-container .CodeMirror-line'),
|
||||
jsonTreeLine: () => page.locator('.response-pane .object-content')
|
||||
},
|
||||
plusMenu: {
|
||||
button: () => page.getByTestId('collections-header-add-menu'),
|
||||
@@ -89,7 +96,8 @@ export const buildCommonLocators = (page: Page) => ({
|
||||
modal: () => page.locator('[data-testid="import-collection-modal"]'),
|
||||
locationModal: () => page.locator('[data-testid="import-collection-location-modal"]'),
|
||||
locationInput: () => page.locator('#collection-location'),
|
||||
fileInput: () => page.locator('input[type="file"]')
|
||||
fileInput: () => page.locator('input[type="file"]'),
|
||||
envOption: (name: string) => page.locator('.dropdown-item').getByText(name, { exact: true })
|
||||
}
|
||||
});
|
||||
|
||||
@@ -111,7 +119,7 @@ export const buildWebsocketCommonLocators = (page: Page) => ({
|
||||
toolbar: {
|
||||
latestFirst: () => page.getByRole('button', { name: 'Latest First' }),
|
||||
latestLast: () => page.getByRole('button', { name: 'Latest Last' }),
|
||||
clearResponse: () => page.getByRole('button', { name: 'Clear Response' })
|
||||
clearResponse: () => page.getByTestId('response-clear-button')
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user