Compare commits

...

5 Commits

Author SHA1 Message Date
Bijin A B
19840f8546 fix: lighten dark pastel theme modal background color 2026-01-04 21:44:43 +05:30
Anoop M D
d03d8f01a1 feat: update @opencollection/types to version 0.7.0 and add demo image to GenerateDocumentation component (#6651) 2026-01-04 21:28:19 +05:30
lohit
97c700beba fix: update logic for checking formdata instances (#6643)
* fix: update logic for checking formdata instance

* fix: isFormData logic update

* fix: review comment fix, add isFormData to @usebruno/common package

* fix: review comment fix
2026-01-04 21:27:07 +05:30
Sid
b6a27bc66c fix: reverse sorting order for websocket messages (#6652) 2026-01-04 16:54:27 +05:30
Bijin A B
76a2889206 fix(ux): fix sidebar invisible for environments tab, grpc and ws (#6648) 2026-01-04 12:40:22 +05:30
23 changed files with 149 additions and 32 deletions

9
package-lock.json generated
View File

@@ -29,7 +29,7 @@
"@eslint/compat": "^1.3.2",
"@faker-js/faker": "^7.6.0",
"@jest/globals": "^29.2.0",
"@opencollection/types": "~0.6.0",
"@opencollection/types": "~0.7.0",
"@playwright/test": "^1.51.1",
"@rollup/plugin-json": "^6.1.0",
"@storybook/addon-webpack5-compiler-babel": "^4.0.0",
@@ -6100,9 +6100,9 @@
}
},
"node_modules/@opencollection/types": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@opencollection/types/-/types-0.6.0.tgz",
"integrity": "sha512-nasB4/1hIZ61xp2dnnZWhdH83f0t800VrSl3G2q+BtHabBqN/IG+j9BMOJg0hYZjAVx+Yhl1njkzUqkiX5+Q0g==",
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/@opencollection/types/-/types-0.7.0.tgz",
"integrity": "sha512-CSwdaHNPa2bNNBAOy++t6W9gBTExUJZW3aPkWyhAjasusThbvjymD/0uCLR50gCXSs0ezv61jsd19m9x+2DMtQ==",
"dev": true,
"license": "MIT"
},
@@ -33081,6 +33081,7 @@
"@rollup/plugin-typescript": "^12.1.2",
"@types/jest": "^29.5.14",
"babel-jest": "^29.7.0",
"form-data": "^4.0.0",
"is-ip": "^5.0.1",
"moment": "^2.29.4",
"rollup": "3.29.5",

View File

@@ -23,7 +23,7 @@
"@eslint/compat": "^1.3.2",
"@faker-js/faker": "^7.6.0",
"@jest/globals": "^29.2.0",
"@opencollection/types": "~0.6.0",
"@opencollection/types": "~0.7.0",
"@playwright/test": "^1.51.1",
"@rollup/plugin-json": "^6.1.0",
"@storybook/addon-webpack5-compiler-babel": "^4.0.0",

View File

@@ -5,7 +5,6 @@ const StyledWrapper = styled.div`
display: flex;
height: 100%;
overflow: hidden;
background-color: ${(props) => props.theme.bg};
position: relative;
.environments-container {

View File

@@ -3,7 +3,6 @@ import styled from 'styled-components';
const StyledWrapper = styled.div`
height: 100%;
overflow: hidden;
background: ${(props) => props.theme.bg};
border-radius: 4px;
div.tabs {

View File

@@ -3,7 +3,6 @@ import styled from 'styled-components';
const StyledWrapper = styled.div`
height: 100%;
overflow: hidden;
background: ${(props) => props.theme.bg};
border-radius: 4px;
div.tabs {

View File

@@ -185,7 +185,7 @@ const WSMessagesList = ({ order = -1, messages = [] }) => {
// sort based on order, seq was newly added and might be missing in some cases and when missing,
// the timestamp will be used instead
const ordered = messages.toSorted((x, y) => ((x.seq ?? x.timestamp) - (y.seq ?? y.timestamp)) * order);
const ordered = messages.toSorted((x, y) => ((x.seq ?? x.timestamp) - (y.seq ?? y.timestamp)) * (-order));
return (
<StyledWrapper className="ws-messages-list flex flex-col">

View File

@@ -13,6 +13,30 @@ const StyledWrapper = styled.div`
line-height: 1.6;
}
.preview-container {
border-radius: ${(props) => props.theme.border.radius.md};
overflow: hidden;
border: 1px solid ${(props) => props.theme.border.border1};
.preview-label {
top: 0.5rem;
right: 0.5rem;
padding: 0.125rem 0.5rem;
font-size: ${(props) => props.theme.font.size.xs};
font-weight: 500;
color: #3b82f6;
background-color: rgba(59, 130, 246, 0.1);
border: 1px dashed rgba(59, 130, 246, 0.4);
border-radius: ${(props) => props.theme.border.radius.sm};
}
.preview-image {
width: 100%;
height: auto;
display: block;
}
}
.features {
li {
font-size: ${(props) => props.theme.font.size.sm};

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

View File

@@ -9,6 +9,7 @@ import { IconBook, IconCheck, IconAlertTriangle, IconLoader2 } from '@tabler/ico
import Modal from 'components/Modal';
import StyledWrapper from './StyledWrapper';
import demoImage from './demo.png';
import { useApp } from 'providers/App';
import { transformCollectionToSaveToExportAsFile, findCollectionByUid, areItemsLoading } from 'utils/collections/index';
import { brunoToOpenCollection } from '@usebruno/converters';
@@ -141,12 +142,16 @@ const GenerateDocumentation = ({ onClose, collectionUid }) => {
<IconBook size={18} />
<span>Interactive API Documentation</span>
</h3>
<p className="description mb-5">
Generate a standalone HTML file containing interactive documentation for your API collection.
This file can be hosted anywhere or shared with your team.
<p className="description mb-4">
Generate a standalone HTML file that can be hosted anywhere or shared with your team.
</p>
<ul className="features flex flex-col list-none gap-2.5 p-0 mb-5">
<div className="preview-container relative mb-4">
<span className="preview-label absolute">Sample Output</span>
<img src={demoImage} alt="Documentation preview" className="preview-image" />
</div>
<ul className="features flex flex-col list-none gap-2 p-0 mb-4">
{FEATURES.map((feature) => (
<li key={feature} className="flex items-center gap-2.5">
<IconCheck size={16} className="check-icon flex-shrink-0" />
@@ -156,7 +161,7 @@ const GenerateDocumentation = ({ onClose, collectionUid }) => {
</ul>
<p className="note m-0">
The generated file does not embed all assets. It loads OpenCollections JavaScript and CSS files from a CDN when viewing docs, which requires an internet connection.
The generated file loads OpenCollection's JavaScript and CSS files from a CDN, which requires an internet connection.
</p>
</div>
)}

View File

@@ -14,6 +14,18 @@ const StyledWrapper = styled.div`
border-radius: ${(props) => props.theme.border.radius.sm};
}
.discussion-link {
margin-left: 0.5rem;
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.textLink};
cursor: pointer;
font-weight: 400;
&:hover {
text-decoration: underline;
}
}
.report-issue-link {
display: inline-flex;
align-items: center;

View File

@@ -224,7 +224,19 @@ const CreateCollection = ({ onClose, defaultLocation: propDefaultLocation }) =>
</p>
</Help>
{formik.values.format === 'yml' && (
<span className="beta-badge">Beta</span>
<>
<span className="beta-badge">Beta</span>
<a
href="#"
className="discussion-link"
onClick={(e) => {
e.preventDefault();
window.open('https://github.com/usebruno/bruno/discussions/6634', '_blank', 'noopener,noreferrer');
}}
>
Join the discussion
</a>
</>
)}
</label>
<select

View File

@@ -5,7 +5,6 @@ const StyledWrapper = styled.div`
display: flex;
height: 100%;
overflow: hidden;
background-color: ${(props) => props.theme.bg};
position: relative;
.environments-container {

View File

@@ -295,7 +295,7 @@ const darkPastelTheme = {
},
body: {
color: colors.TEXT,
bg: colors.GRAY_1
bg: colors.GRAY_2
},
input: {
bg: 'transparent',

View File

@@ -1,6 +1,6 @@
const { interpolate } = require('@usebruno/common');
const { each, forOwn, cloneDeep, find } = require('lodash');
const FormData = require('form-data');
const { isFormData } = require('@usebruno/common').utils;
const getContentType = (headers = {}) => {
let contentType = '';
@@ -87,7 +87,7 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc
}));
}
} else if (contentType === 'multipart/form-data') {
if (Array.isArray(request?.data) && !(request.data instanceof FormData)) {
if (Array.isArray(request?.data) && !isFormData(request.data)) {
try {
request.data = request?.data?.map((d) => ({
...d,

View File

@@ -3,7 +3,6 @@ const chalk = require('chalk');
const decomment = require('decomment');
const fs = require('fs');
const { forOwn, isUndefined, isNull, each, extend, get, compact } = require('lodash');
const FormData = require('form-data');
const prepareRequest = require('./prepare-request');
const interpolateVars = require('./interpolate-vars');
const { interpolateString } = require('./interpolate-string');
@@ -25,7 +24,7 @@ const { NtlmClient } = require('axios-ntlm');
const { addDigestInterceptor } = require('@usebruno/requests');
const { getCACertificates } = require('@usebruno/requests');
const { getOAuth2Token } = require('../utils/oauth2');
const { encodeUrl, buildFormUrlEncodedPayload, extractPromptVariables } = require('@usebruno/common').utils;
const { encodeUrl, buildFormUrlEncodedPayload, extractPromptVariables, isFormData } = require('@usebruno/common').utils;
const onConsoleLog = (type, args) => {
console[type](...args);
@@ -429,7 +428,7 @@ const runSingleRequest = async function (
}
if (contentTypeHeader && request.headers[contentTypeHeader] === 'multipart/form-data') {
if (!(request?.data instanceof FormData)) {
if (!isFormData(request?.data)) {
request._originalMultipartData = request.data;
request.collectionPath = collectionPath;
let form = createFormData(request.data, collectionPath);

View File

@@ -46,6 +46,7 @@
"@rollup/plugin-typescript": "^12.1.2",
"@types/jest": "^29.5.14",
"babel-jest": "^29.7.0",
"form-data": "^4.0.0",
"is-ip": "^5.0.1",
"moment": "^2.29.4",
"rollup": "3.29.5",

View File

@@ -1,5 +1,6 @@
import { describe, it, expect } from '@jest/globals';
import { buildFormUrlEncodedPayload } from './form-data';
import { buildFormUrlEncodedPayload, isFormData } from './form-data';
import FormData from 'form-data';
describe('buildFormUrlEncodedPayload', () => {
it('should handle single key-value pair', () => {
@@ -110,3 +111,53 @@ describe('buildFormUrlEncodedPayload', () => {
expect(result).toEqual(expected);
});
});
describe('isFormData', () => {
it('should return true for objects with FormData constructor name', () => {
const mockFormData = {
constructor: { name: 'FormData' }
};
expect(isFormData(mockFormData)).toBe(true);
});
it('should return false for null', () => {
expect(isFormData(null)).toBe(false);
});
it('should return false for undefined', () => {
expect(isFormData(undefined)).toBe(false);
});
it('should return false for plain objects', () => {
expect(isFormData({})).toBe(false);
expect(isFormData({ key: 'value' })).toBe(false);
});
it('should return false for arrays', () => {
expect(isFormData([])).toBe(false);
expect(isFormData([1, 2, 3])).toBe(false);
});
it('should return false for primitives', () => {
expect(isFormData('string')).toBe(false);
expect(isFormData(123)).toBe(false);
expect(isFormData(true)).toBe(false);
});
it('should return false for objects with different constructor names', () => {
class CustomClass {}
const customObj = new CustomClass();
expect(isFormData(customObj)).toBe(false);
});
it('should return false for objects without constructor', () => {
const obj = Object.create(null);
expect(isFormData(obj)).toBe(false);
});
it('should return true for actual FormData instance from form-data library', () => {
const formData = new FormData();
formData.append('key', 'value');
expect(isFormData(formData)).toBe(true);
});
});

View File

@@ -31,3 +31,15 @@ export const buildFormUrlEncodedPayload = (params: Array<{ name: string; value:
return resultParams.toString();
};
/**
* Determines if the given object is a FormData instance.
* Supports native FormData (Node 18+, browser) and the 'form-data' npm package.
* @param obj - Object to check.
* @returns True if obj is a FormData instance, false otherwise.
*/
export const isFormData = (obj: unknown): boolean => {
// Check constructor name (works for both native FormData and form-data npm package)
// todo: checking constructor.name can produce false positives for objects that have a constructor.name property set to 'FormData', but this is rare.
return obj?.constructor?.name === 'FormData';
};

View File

@@ -5,7 +5,8 @@ export {
} from './url';
export {
buildFormUrlEncodedPayload
buildFormUrlEncodedPayload,
isFormData
} from './form-data';
export {

View File

@@ -5,7 +5,6 @@ const qs = require('qs');
const decomment = require('decomment');
const contentDispositionParser = require('content-disposition');
const mime = require('mime-types');
const FormData = require('form-data');
const { ipcMain } = require('electron');
const { each, get, extend, cloneDeep, merge } = require('lodash');
const { NtlmClient } = require('axios-ntlm');
@@ -36,7 +35,7 @@ const { cookiesStore } = require('../../store/cookies');
const registerGrpcEventHandlers = require('./grpc-event-handlers');
const { registerWsEventHandlers } = require('./ws-event-handlers');
const { getCertsAndProxyConfig } = require('./cert-utils');
const { buildFormUrlEncodedPayload } = require('@usebruno/common').utils;
const { buildFormUrlEncodedPayload, isFormData } = require('@usebruno/common').utils;
const ERROR_OCCURRED_WHILE_EXECUTING_REQUEST = 'Error occurred while executing the request!';
@@ -474,7 +473,7 @@ const registerNetworkIpc = (mainWindow) => {
}
if (contentTypeHeader && request.headers[contentTypeHeader] === 'multipart/form-data') {
if (!(request.data instanceof FormData)) {
if (!isFormData(request.data)) {
request._originalMultipartData = request.data;
request.collectionPath = collectionPath;
let form = createFormData(request.data, collectionPath);

View File

@@ -1,6 +1,6 @@
const { interpolate } = require('@usebruno/common');
const { each, forOwn, cloneDeep } = require('lodash');
const FormData = require('form-data');
const { isFormData } = require('@usebruno/common').utils;
const getContentType = (headers = {}) => {
let contentType = '';
@@ -132,7 +132,7 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc
}));
}
} else if (contentType === 'multipart/form-data') {
if (Array.isArray(request?.data) && !(request.data instanceof FormData)) {
if (Array.isArray(request?.data) && !isFormData(request.data)) {
try {
request.data = request?.data?.map((d) => ({
...d,

View File

@@ -1,8 +1,8 @@
const { customAlphabet } = require('nanoid');
const iconv = require('iconv-lite');
const { cloneDeep } = require('lodash');
const FormData = require('form-data');
const { formatMultipartData } = require('./form-data');
const { isFormData } = require('@usebruno/common').utils;
// a customized version of nanoid without using _ and -
const uuid = () => {
@@ -135,7 +135,7 @@ const parseDataFromRequest = (request) => {
// File uploads are redacted, multipart FormData is formatted from original data for readability, and other types are stringified as-is.
if (request.mode === 'file') {
requestDataString = '<request body redacted>';
} else if (request?.data instanceof FormData && Array.isArray(request._originalMultipartData)) {
} else if (isFormData(request?.data) && Array.isArray(request._originalMultipartData)) {
const boundary = request.data._boundary || 'boundary';
requestDataString = formatMultipartData(request._originalMultipartData, boundary);
} else {

View File

@@ -81,6 +81,10 @@ export const toOpenCollectionBody = (body: BrunoHttpRequestBody | null | undefin
value: entry.value || (entry.type === 'file' ? [] : '')
};
if (entry?.contentType?.trim().length) {
multipartEntry.contentType = entry.contentType;
}
if (entry?.description?.trim().length) {
multipartEntry.description = entry.description;
}
@@ -200,7 +204,7 @@ export const toBrunoBody = (body: HttpRequestBody | null | undefined): BrunoHttp
type: entry.type,
name: entry.name || '',
value: entry.value || (entry.type === 'file' ? [] : ''),
contentType: null,
contentType: entry.contentType || null,
enabled: entry.disabled !== true
};