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:
Abhishek S Lal
2025-12-09 23:45:01 +05:30
committed by GitHub
parent 4d1c3f9e52
commit a798b32f25
66 changed files with 2590 additions and 341 deletions

View File

@@ -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;

View File

@@ -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}

View File

@@ -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>

View File

@@ -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';

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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>
);
}
};

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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>
);

View File

@@ -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;

View File

@@ -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}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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>
);
};

View File

@@ -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;

View File

@@ -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;

View File

@@ -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};
}
}
`;

View File

@@ -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>
);
};

View File

@@ -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');

View File

@@ -0,0 +1,7 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
`;
export default StyledWrapper;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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>
);

View File

@@ -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;
`;

View File

@@ -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);

View File

@@ -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};
`;

View File

@@ -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;

View File

@@ -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};

View File

@@ -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,

View File

@@ -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;

View File

@@ -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}

View File

@@ -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%'

View File

@@ -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}

View File

@@ -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

View File

@@ -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"

View 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;

View 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;

View 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;

View 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;

View File

@@ -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')) {

View File

@@ -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;
}

View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
};
/**
* 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;
};

View File

@@ -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');

View File

@@ -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');

View File

@@ -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');

View File

@@ -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();
});
});

View File

@@ -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();
});
});

View File

@@ -0,0 +1,9 @@
{
"version": "1",
"name": "collection",
"type": "collection",
"ignore": [
"node_modules",
".git"
]
}

View File

@@ -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
}

View File

@@ -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"
}
}

View File

@@ -0,0 +1,10 @@
{
"collections": [
{
"path": "{{projectRoot}}/tests/response/response-format-select-and-preview/fixtures/collection",
"securityConfig": {
"jsSandboxMode": "safe"
}
}
]
}

View File

@@ -0,0 +1,6 @@
{
"maximized": false,
"lastOpenedCollections": [
"{{projectRoot}}/tests/response/response-format-select-and-preview/fixtures/collection"
]
}

View File

@@ -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>');
});
});
});

View File

@@ -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 };

View File

@@ -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')
}
});