Merge branch 'main' into feat/folder-recursive-rename-win32

This commit is contained in:
Anoop M D 2024-11-29 12:16:27 +05:30 committed by GitHub
commit 4283bb4bb0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
128 changed files with 11261 additions and 6696 deletions

View File

@ -2,11 +2,6 @@ name: Bru CLI Tests (npm)
on:
workflow_dispatch:
inputs:
build:
description: 'Test Bru CLI (npm)'
required: true
default: 'true'
# Assign permissions for unit tests to be reported.
# See https://github.com/dorny/test-reporter/issues/168

View File

@ -5,6 +5,9 @@ on:
pull_request:
branches: [main]
permissions:
contents: read
jobs:
unit-test:
name: Unit Tests

2
.nvmrc
View File

@ -1 +1 @@
v20.9.0
v22.11.0

View File

@ -48,7 +48,7 @@ Bruno is being developed as a desktop app. You need to load the app by running t
### Local Development
```bash
# use nodejs 18 version
# use nodejs 20 version
nvm use
# install deps

14271
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -51,10 +51,6 @@
"prepare": "husky install"
},
"overrides": {
"rollup":"3.29.4"
},
"dependencies": {
"json-bigint": "^1.0.0",
"lossless-json": "^4.0.1"
"rollup":"3.29.5"
}
}

View File

@ -32,3 +32,5 @@ yarn-error.log*
# next.js
.next/
out/
.env

View File

@ -0,0 +1,16 @@
module.exports = {
rootDir: '.',
moduleNameMapper: {
'^assets/(.*)$': '<rootDir>/src/assets/$1',
'^components/(.*)$': '<rootDir>/src/components/$1',
'^hooks/(.*)$': '<rootDir>/src/hooks/$1',
'^themes/(.*)$': '<rootDir>/src/themes/$1',
'^api/(.*)$': '<rootDir>/src/api/$1',
'^pageComponents/(.*)$': '<rootDir>/src/pageComponents/$1',
'^providers/(.*)$': '<rootDir>/src/providers/$1',
'^utils/(.*)$': '<rootDir>/src/utils/$1'
},
clearMocks: true,
moduleDirectories: ['node_modules', 'src'],
testEnvironment: 'node'
};

View File

@ -1,4 +1,5 @@
module.exports = {
output: 'export',
reactStrictMode: false,
publicRuntimeConfig: {
CI: process.env.CI,
@ -10,6 +11,12 @@ module.exports = {
if (!isServer) {
config.resolve.fallback.fs = false;
}
Object.defineProperty(config, 'devtool', {
get() {
return 'source-map';
},
set() {},
});
return config;
},
};

View File

@ -4,7 +4,7 @@
"private": true,
"scripts": {
"dev": "cross-env ENV=dev next dev -p 3000",
"build": "next build && next export",
"build": "next build",
"start": "next start",
"lint": "next lint",
"test": "jest",
@ -13,27 +13,25 @@
},
"dependencies": {
"@fontsource/inter": "^5.0.15",
"@fortawesome/fontawesome-svg-core": "^1.2.36",
"@fortawesome/free-solid-svg-icons": "^5.15.4",
"@fortawesome/react-fontawesome": "^0.1.16",
"@prantlf/jsonlint": "^16.0.0",
"@reduxjs/toolkit": "^1.8.0",
"@tabler/icons": "^1.46.0",
"@tippyjs/react": "^4.2.6",
"@usebruno/common": "0.1.0",
"@usebruno/graphql-docs": "0.1.0",
"@usebruno/schema": "0.7.0",
"axios": "^1.5.1",
"axios": "1.7.5",
"classnames": "^2.3.1",
"codemirror": "5.65.2",
"codemirror-graphql": "1.2.5",
"cookie": "^0.6.0",
"codemirror-graphql": "2.1.1",
"cookie": "0.7.1",
"escape-html": "^1.0.3",
"file": "^0.2.2",
"file-dialog": "^0.0.8",
"file-saver": "^2.0.5",
"formik": "^2.2.9",
"github-markdown-css": "^5.2.0",
"graphiql": "^1.5.9",
"graphiql": "3.7.1",
"graphql": "^16.6.0",
"graphql-request": "^3.7.0",
"httpsnippet": "^3.0.6",
@ -44,19 +42,18 @@
"jshint": "^2.13.6",
"json5": "^2.2.3",
"jsonc-parser": "^3.2.1",
"jsonlint": "^1.6.3",
"jsonpath-plus": "^7.2.0",
"jsonpath-plus": "10.1.0",
"know-your-http-well": "^0.5.0",
"lodash": "^4.17.21",
"markdown-it": "^13.0.2",
"markdown-it-replace-link": "^1.2.0",
"mousetrap": "^1.6.5",
"nanoid": "3.3.4",
"next": "12.3.3",
"next": "14.2.16",
"path": "^0.12.7",
"pdfjs-dist": "^3.11.174",
"pdfjs-dist": "4.4.168",
"platform": "^1.3.6",
"posthog-node": "^2.1.0",
"posthog-node": "4.2.1",
"prettier": "^2.7.1",
"qs": "^6.11.0",
"query-string": "^7.0.1",
@ -65,11 +62,11 @@
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "18.2.0",
"react-github-btn": "^1.4.0",
"react-hot-toast": "^2.4.0",
"react-i18next": "^15.0.1",
"react-inspector": "^6.0.2",
"react-pdf": "^7.5.1",
"react-pdf": "9.1.1",
"react-player": "^2.16.0",
"react-redux": "^7.2.6",
"react-tooltip": "^5.5.2",
"sass": "^1.46.0",
@ -87,15 +84,15 @@
"@babel/preset-env": "^7.16.4",
"@babel/preset-react": "^7.16.0",
"@babel/runtime": "^7.16.3",
"autoprefixer": "^10.4.17",
"autoprefixer": "10.4.20",
"babel-loader": "^8.2.3",
"cross-env": "^7.0.3",
"css-loader": "^6.5.1",
"css-loader": "7.1.2",
"file-loader": "^6.2.0",
"html-loader": "^3.0.1",
"html-webpack-plugin": "^5.5.0",
"mini-css-extract-plugin": "^2.4.5",
"postcss": "^8.4.35",
"postcss": "8.4.47",
"style-loader": "^3.3.1",
"tailwindcss": "^3.4.1",
"webpack": "^5.64.4",

View File

@ -14,6 +14,9 @@ const StyledWrapper = styled.div`
.CodeMirror-foldmarker {
text-shadow: none;
color: ${(props) => props.theme.textLink};
background: none;
padding: 0;
margin: 0;
}
.CodeMirror-overlayscroll-horizontal div,
@ -23,6 +26,12 @@ const StyledWrapper = styled.div`
.CodeMirror-dialog {
overflow: visible;
input {
background: transparent;
border: 1px solid #d3d6db;
outline: none;
border-radius: 0px;
}
}
#search-results-count {
@ -75,6 +84,18 @@ const StyledWrapper = styled.div`
.cm-variable-invalid {
color: red;
}
.CodeMirror-search-hint {
display: inline;
}
.cm-s-default span.cm-property {
color: #1f61a0 !important;
}
.cm-s-default span.cm-variable {
color: #397d13 !important;
}
`;
export default StyledWrapper;

View File

@ -10,12 +10,12 @@ import { isEqual, escapeRegExp } from 'lodash';
import { getEnvironmentVariables } from 'utils/collections';
import { defineCodeMirrorBrunoVariablesMode } from 'utils/common/codemirror';
import StyledWrapper from './StyledWrapper';
import jsonlint from 'jsonlint';
import * as jsonlint from '@prantlf/jsonlint';
import { JSHINT } from 'jshint';
import stripJsonComments from 'strip-json-comments';
let CodeMirror;
const SERVER_RENDERED = typeof navigator === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
const TAB_SIZE = 2;
if (!SERVER_RENDERED) {
@ -58,21 +58,25 @@ if (!SERVER_RENDERED) {
'req.getExecutionMode()',
'bru',
'bru.cwd()',
'bru.getEnvName(key)',
'bru.getEnvName()',
'bru.getProcessEnv(key)',
'bru.hasEnvVar(key)',
'bru.getEnvVar(key)',
'bru.getFolderVar(key)',
'bru.getCollectionVar(key)',
'bru.setEnvVar(key,value)',
'bru.deleteEnvVar(key)',
'bru.hasVar(key)',
'bru.getVar(key)',
'bru.setVar(key,value)',
'bru.deleteVar(key)',
'bru.deleteAllVars()',
'bru.setNextRequest(requestName)',
'req.disableParsingResponseJson()',
'bru.getRequestVar(key)',
'bru.sleep(ms)'
'bru.sleep(ms)',
'bru.getGlobalEnvVar(key)',
'bru.setGlobalEnvVar(key, value)'
];
CodeMirror.registerHelper('hint', 'brunoJS', (editor, options) => {
const cursor = editor.getCursor();
@ -186,32 +190,8 @@ export default class CodeEditor extends React.Component {
'Cmd-Y': 'foldAll',
'Ctrl-I': 'unfoldAll',
'Cmd-I': 'unfoldAll',
'Cmd-/': (cm) => {
// comment/uncomment every selected line(s)
const selections = cm.listSelections();
selections.forEach((range) => {
for (let i = range.from().line; i <= range.to().line; i++) {
const selectedLine = cm.getLine(i);
// if commented line, remove comment
if (selectedLine.trim().startsWith('//')) {
cm.replaceRange(
selectedLine.replace(/^(\s*)\/\/\s?/, '$1'),
{ line: i, ch: 0 },
{ line: i, ch: selectedLine.length }
);
continue;
}
// otherwise add comment
cm.replaceRange(
selectedLine.search(/\S|$/) >= TAB_SIZE
? ' '.repeat(TAB_SIZE) + '// ' + selectedLine.trim()
: '// ' + selectedLine,
{ line: i, ch: 0 },
{ line: i, ch: selectedLine.length }
);
}
});
}
'Ctrl-/': 'toggleComment',
'Cmd-/': 'toggleComment'
},
foldOptions: {
widget: (from, to) => {
@ -248,17 +228,20 @@ export default class CodeEditor extends React.Component {
return found;
}
let jsonlint = window.jsonlint.parser || window.jsonlint;
jsonlint.parseError = function (str, hash) {
let loc = hash.loc;
found.push({
from: CodeMirror.Pos(loc.first_line - 1, loc.first_column),
to: CodeMirror.Pos(loc.last_line - 1, loc.last_column),
message: str
});
};
try {
jsonlint.parse(stripJsonComments(text.replace(/(?<!"[^":{]*){{[^}]*}}(?![^"},]*")/g, '1')));
} catch (e) {}
} catch (error) {
const { message, location } = error;
const line = location?.start?.line;
const column = location?.start?.column;
if (line && column) {
found.push({
from: CodeMirror.Pos(line - 1, column),
to: CodeMirror.Pos(line - 1, column),
message
});
}
}
return found;
});
if (editor) {
@ -275,9 +258,9 @@ export default class CodeEditor extends React.Component {
while (end < currentLine.length && /[^{}();\s\[\]\,]/.test(currentLine.charAt(end))) ++end;
while (start && /[^{}();\s\[\]\,]/.test(currentLine.charAt(start - 1))) --start;
let curWord = start != end && currentLine.slice(start, end);
//Qualify if autocomplete will be shown
// Qualify if autocomplete will be shown
if (
/^(?!Shift|Tab|Enter|Escape|ArrowUp|ArrowDown|ArrowLeft|ArrowRight|\s)\w*/.test(event.key) &&
/^(?!Shift|Tab|Enter|Escape|ArrowUp|ArrowDown|ArrowLeft|ArrowRight|Meta|Alt|Home|End\s)\w*/.test(event.key) &&
curWord.length > 0 &&
!/\/\/|\/\*|.*{{|`[^$]*{|`[^{]*$/.test(currentLine.slice(0, end)) &&
/(?<!\d)[a-zA-Z\._]$/.test(curWord)
@ -333,7 +316,7 @@ export default class CodeEditor extends React.Component {
}
return (
<StyledWrapper
className="h-full w-full flex flex-col relative"
className="h-full w-full flex flex-col relative graphiql-container"
aria-label="Code Editor"
font={this.props.font}
fontSize={this.props.fontSize}

View File

@ -17,6 +17,15 @@ import Presets from './Presets';
import Info from './Info';
import StyledWrapper from './StyledWrapper';
import Vars from './Vars/index';
import DotIcon from 'components/Icons/Dot';
const ContentIndicator = () => {
return (
<sup className="ml-[.125rem] opacity-80 font-medium">
<DotIcon width="10"></DotIcon>
</sup>
);
};
const CollectionSettings = ({ collection }) => {
const dispatch = useDispatch();
@ -30,10 +39,23 @@ const CollectionSettings = ({ collection }) => {
);
};
const proxyConfig = get(collection, 'brunoConfig.proxy', {});
const root = collection?.root;
const hasScripts = root?.request?.script?.res || root?.request?.script?.req;
const hasTests = root?.request?.tests;
const hasDocs = root?.docs;
const headers = get(collection, 'root.request.headers', []);
const activeHeadersCount = headers.filter((header) => header.enabled).length;
const requestVars = get(collection, 'root.request.vars.req', []);
const responseVars = get(collection, 'root.request.vars.res', []);
const activeVarsCount = requestVars.filter((v) => v.enabled).length + responseVars.filter((v) => v.enabled).length;
const auth = get(collection, 'root.request.auth', {}).mode;
const proxyConfig = get(collection, 'brunoConfig.proxy', {});
const clientCertConfig = get(collection, 'brunoConfig.clientCertificates.certs', []);
const onProxySettingsUpdate = (config) => {
const brunoConfig = cloneDeep(collection.brunoConfig);
brunoConfig.proxy = config;
@ -126,30 +148,38 @@ const CollectionSettings = ({ collection }) => {
<div className="flex flex-wrap items-center tabs" role="tablist">
<div className={getTabClassname('headers')} role="tab" onClick={() => setTab('headers')}>
Headers
{activeHeadersCount > 0 && <sup className="ml-1 font-medium">{activeHeadersCount}</sup>}
</div>
<div className={getTabClassname('vars')} role="tab" onClick={() => setTab('vars')}>
Vars
{activeVarsCount > 0 && <sup className="ml-1 font-medium">{activeVarsCount}</sup>}
</div>
<div className={getTabClassname('auth')} role="tab" onClick={() => setTab('auth')}>
Auth
{auth !== 'none' && <ContentIndicator />}
</div>
<div className={getTabClassname('script')} role="tab" onClick={() => setTab('script')}>
Script
{hasScripts && <ContentIndicator />}
</div>
<div className={getTabClassname('tests')} role="tab" onClick={() => setTab('tests')}>
Tests
{hasTests && <ContentIndicator />}
</div>
<div className={getTabClassname('presets')} role="tab" onClick={() => setTab('presets')}>
Presets
</div>
<div className={getTabClassname('proxy')} role="tab" onClick={() => setTab('proxy')}>
Proxy
{Object.keys(proxyConfig).length > 0 && <ContentIndicator />}
</div>
<div className={getTabClassname('clientCert')} role="tab" onClick={() => setTab('clientCert')}>
Client Certificates
{clientCertConfig.length > 0 && <ContentIndicator />}
</div>
<div className={getTabClassname('docs')} role="tab" onClick={() => setTab('docs')}>
Docs
{hasDocs && <ContentIndicator />}
</div>
<div className={getTabClassname('info')} role="tab" onClick={() => setTab('info')}>
Info

View File

@ -36,6 +36,13 @@ const Wrapper = styled.div`
padding: 0.35rem 0.6rem;
cursor: pointer;
&.active {
color: ${(props) => props.theme.colors.text.yellow} !important;
.icon {
color: ${(props) => props.theme.colors.text.yellow} !important;
}
}
.icon {
color: ${(props) => props.theme.dropdown.iconColor};
}

View File

@ -2,9 +2,9 @@ import React from 'react';
import Tippy from '@tippyjs/react';
import StyledWrapper from './StyledWrapper';
const Dropdown = ({ icon, children, onCreate, placement }) => {
const Dropdown = ({ icon, children, onCreate, placement, transparent }) => {
return (
<StyledWrapper className="dropdown">
<StyledWrapper className="dropdown" transparent={transparent}>
<Tippy
content={children}
placement={placement || 'bottom-end'}

View File

@ -53,10 +53,11 @@ const EnvironmentSelector = ({ collection }) => {
<StyledWrapper>
<div className="flex items-center cursor-pointer environment-selector">
<Dropdown onCreate={onDropdownCreate} icon={<Icon />} placement="bottom-end">
<div className="label-item font-medium">Collection Environments</div>
{environments && environments.length
? environments.map((e) => (
<div
className="dropdown-item"
className={`dropdown-item ${e?.uid === activeEnvironmentUid ? 'active' : ''}`}
key={e.uid}
onClick={() => {
onSelect(e);

View File

@ -39,6 +39,11 @@ const Wrapper = styled.div`
font-size: 0.8125rem;
}
.tooltip-mod {
font-size: 11px !important;
width: 150px !important;
}
input[type='text'] {
width: 100%;
border: solid 1px transparent;

View File

@ -1,6 +1,6 @@
import React, { useRef, useEffect } from 'react';
import cloneDeep from 'lodash/cloneDeep';
import { IconTrash } from '@tabler/icons';
import { IconTrash, IconAlertCircle } from '@tabler/icons';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
import SingleLineEditor from 'components/SingleLineEditor';
@ -11,6 +11,7 @@ import * as Yup from 'yup';
import { variableNameRegex } from 'utils/common/regex';
import { saveEnvironment } from 'providers/ReduxStore/slices/collections/actions';
import toast from 'react-hot-toast';
import { Tooltip } from 'react-tooltip';
const EnvironmentVariables = ({ environment, collection, setIsModified, originalEnvironmentVariables }) => {
const dispatch = useDispatch();
@ -59,14 +60,15 @@ const EnvironmentVariables = ({ environment, collection, setIsModified, original
const ErrorMessage = ({ name }) => {
const meta = formik.getFieldMeta(name);
if (!meta.error) {
const id = uuid();
if (!meta.error || !meta.touched) {
return null;
}
return (
<label htmlFor={name} className="text-red-500">
{meta.error}
</label>
<span>
<IconAlertCircle id={id} className="text-red-600 cursor-pointer " size={20} />
<Tooltip className="tooltip-mod" anchorId={id} html={meta.error || ''} />
</span>
);
};
@ -124,19 +126,21 @@ const EnvironmentVariables = ({ environment, collection, setIsModified, original
/>
</td>
<td>
<input
type="text"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
className="mousetrap"
id={`${index}.name`}
name={`${index}.name`}
value={variable.name}
onChange={formik.handleChange}
/>
<ErrorMessage name={`${index}.name`} />
<div className="flex items-center">
<input
type="text"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
className="mousetrap"
id={`${index}.name`}
name={`${index}.name`}
value={variable.name}
onChange={formik.handleChange}
/>
<ErrorMessage name={`${index}.name`} />
</div>
</td>
<td className="flex flex-row flex-nowrap">
<div className="overflow-hidden grow w-full relative">

View File

@ -7,6 +7,15 @@ import Script from './Script';
import Tests from './Tests';
import StyledWrapper from './StyledWrapper';
import Vars from './Vars';
import DotIcon from 'components/Icons/Dot';
const ContentIndicator = () => {
return (
<sup className="ml-[.125rem] opacity-80 font-medium">
<DotIcon width="10"></DotIcon>
</sup>
);
};
const FolderSettings = ({ collection, folder }) => {
const dispatch = useDispatch();
@ -16,6 +25,17 @@ const FolderSettings = ({ collection, folder }) => {
tab = folderLevelSettingsSelectedTab[folder?.uid];
}
const folderRoot = collection?.items.find((item) => item.uid === folder?.uid)?.root;
const hasScripts = folderRoot?.request?.script?.res || folderRoot?.request?.script?.req;
const hasTests = folderRoot?.request?.tests;
const headers = folderRoot?.request?.headers || [];
const activeHeadersCount = headers.filter((header) => header.enabled).length;
const requestVars = folderRoot?.request?.vars?.req || [];
const responseVars = folderRoot?.request?.vars?.res || [];
const activeVarsCount = requestVars.filter((v) => v.enabled).length + responseVars.filter((v) => v.enabled).length;
const setTab = (tab) => {
dispatch(
updatedFolderSettingsSelectedTab({
@ -55,15 +75,19 @@ const FolderSettings = ({ collection, folder }) => {
<div className="flex flex-wrap items-center tabs" role="tablist">
<div className={getTabClassname('headers')} role="tab" onClick={() => setTab('headers')}>
Headers
{activeHeadersCount > 0 && <sup className="ml-1 font-medium">{activeHeadersCount}</sup>}
</div>
<div className={getTabClassname('script')} role="tab" onClick={() => setTab('script')}>
Script
{hasScripts && <ContentIndicator />}
</div>
<div className={getTabClassname('test')} role="tab" onClick={() => setTab('test')}>
Test
{hasTests && <ContentIndicator />}
</div>
<div className={getTabClassname('vars')} role="tab" onClick={() => setTab('vars')}>
Vars
{activeVarsCount > 0 && <sup className="ml-1 font-medium">{activeVarsCount}</sup>}
</div>
</div>
<section className={`flex mt-4 h-full`}>{getTabPanel(tab)}</section>

View File

@ -0,0 +1,18 @@
import styled from 'styled-components';
const Wrapper = styled.div`
.current-environment {
}
.environment-active {
padding: 0.3rem 0.4rem;
color: ${(props) => props.theme.colors.text.yellow};
border: solid 1px ${(props) => props.theme.colors.text.yellow} !important;
}
.environment-selector {
.active: {
color: ${(props) => props.theme.colors.text.yellow};
}
}
`;
export default Wrapper;

View File

@ -0,0 +1,97 @@
import React, { useRef, forwardRef, useState } from 'react';
import find from 'lodash/find';
import Dropdown from 'components/Dropdown';
import { IconSettings, IconWorld, IconDatabase, IconDatabaseOff, IconCheck } from '@tabler/icons';
import EnvironmentSettings from '../EnvironmentSettings';
import toast from 'react-hot-toast';
import { useDispatch, useSelector } from 'react-redux';
import StyledWrapper from './StyledWrapper';
import { selectGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
import ToolHint from 'components/ToolHint/index';
const EnvironmentSelector = () => {
const dispatch = useDispatch();
const dropdownTippyRef = useRef();
const globalEnvironments = useSelector((state) => state.globalEnvironments.globalEnvironments);
const activeGlobalEnvironmentUid = useSelector((state) => state.globalEnvironments.activeGlobalEnvironmentUid);
const [openSettingsModal, setOpenSettingsModal] = useState(false);
const activeEnvironment = activeGlobalEnvironmentUid ? find(globalEnvironments, (e) => e.uid === activeGlobalEnvironmentUid) : null;
const Icon = forwardRef((props, ref) => {
return (
<div ref={ref} className={`current-environment flex flex-row gap-1 rounded-xl text-xs cursor-pointer items-center justify-center select-none ${activeGlobalEnvironmentUid? 'environment-active': ''}`}>
<ToolHint text="Global Environments" toolhintId="GlobalEnvironmentsToolhintId" className='flex flex-row'>
<IconWorld className="globe" size={16} strokeWidth={1.5} />
{
activeEnvironment ? <div>{activeEnvironment?.name}</div> : null
}
</ToolHint>
</div>
);
});
const handleSettingsIconClick = () => {
setOpenSettingsModal(true);
};
const handleModalClose = () => {
setOpenSettingsModal(false);
};
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const onSelect = (environment) => {
dispatch(selectGlobalEnvironment({ environmentUid: environment ? environment.uid : null }))
.then(() => {
if (environment) {
toast.success(`Environment changed to ${environment.name}`);
} else {
toast.success(`No Environments are active now`);
}
})
.catch((err) => console.log(err) && toast.error('An error occurred while selecting the environment'));
};
return (
<StyledWrapper>
<div className="flex items-center cursor-pointer environment-selector mr-3">
<Dropdown onCreate={onDropdownCreate} icon={<Icon />} placement="bottom-end" transparent={true}>
<div className="label-item font-medium">Global Environments</div>
{globalEnvironments && globalEnvironments.length
? globalEnvironments.map((e) => (
<div
className={`dropdown-item ${e?.uid === activeGlobalEnvironmentUid ? 'active' : ''}`}
key={e.uid}
onClick={() => {
onSelect(e);
dropdownTippyRef.current.hide();
}}
>
<IconDatabase size={18} strokeWidth={1.5} /> <span className="ml-2 break-all">{e.name}</span>
</div>
))
: null}
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onSelect(null);
}}
>
<IconDatabaseOff size={18} strokeWidth={1.5} />
<span className="ml-2">No Environment</span>
</div>
<div className="dropdown-item border-top" onClick={handleSettingsIconClick}>
<div className="pr-2 text-gray-600">
<IconSettings size={18} strokeWidth={1.5} />
</div>
<span>Configure</span>
</div>
</Dropdown>
</div>
{openSettingsModal && <EnvironmentSettings globalEnvironments={globalEnvironments} activeGlobalEnvironmentUid={activeGlobalEnvironmentUid} onClose={handleModalClose} />}
</StyledWrapper>
);
};
export default EnvironmentSelector;

View File

@ -0,0 +1,78 @@
import Modal from 'components/Modal/index';
import Portal from 'components/Portal/index';
import { useFormik } from 'formik';
import { copyGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
import { useEffect, useRef } from 'react';
import toast from 'react-hot-toast';
import { useDispatch } from 'react-redux';
import * as Yup from 'yup';
const CopyEnvironment = ({ environment, onClose }) => {
const dispatch = useDispatch();
const inputRef = useRef();
const formik = useFormik({
enableReinitialize: true,
initialValues: {
name: environment.name + ' - Copy'
},
validationSchema: Yup.object({
name: Yup.string()
.min(1, 'must be at least 1 character')
.max(50, 'must be 50 characters or less')
.required('name is required')
}),
onSubmit: (values) => {
dispatch(copyGlobalEnvironment({ name: values.name, environmentUid: environment.uid }))
.then(() => {
toast.success('Global environment created!');
onClose();
})
.catch((error) => {
toast.error('An error occurred while created the environment');
console.error(error);
});
}
});
useEffect(() => {
if (inputRef && inputRef.current) {
inputRef.current.focus();
}
}, [inputRef]);
const onSubmit = () => {
formik.handleSubmit();
};
return (
<Portal>
<Modal size="sm" title={'Copy Global Environment'} confirmText="Copy" handleConfirm={onSubmit} handleCancel={onClose}>
<form className="bruno-form" onSubmit={e => e.preventDefault()}>
<div>
<label htmlFor="name" className="block font-semibold">
New Environment Name
</label>
<input
id="environment-name"
type="text"
name="name"
ref={inputRef}
className="block textbox mt-2 w-full"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
onChange={formik.handleChange}
value={formik.values.name || ''}
/>
{formik.touched.name && formik.errors.name ? (
<div className="text-red-500">{formik.errors.name}</div>
) : null}
</div>
</form>
</Modal>
</Portal>
);
};
export default CopyEnvironment;

View File

@ -0,0 +1,83 @@
import React, { useEffect, useRef } from 'react';
import toast from 'react-hot-toast';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import { useDispatch } from 'react-redux';
import Portal from 'components/Portal';
import Modal from 'components/Modal';
import { addGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
const CreateEnvironment = ({ onClose }) => {
const dispatch = useDispatch();
const inputRef = useRef();
const formik = useFormik({
enableReinitialize: true,
initialValues: {
name: ''
},
validationSchema: Yup.object({
name: Yup.string()
.min(1, 'must be at least 1 character')
.max(50, 'must be 50 characters or less')
.required('name is required')
}),
onSubmit: (values) => {
dispatch(addGlobalEnvironment({ name: values.name }))
.then(() => {
toast.success('Global environment created!');
onClose();
})
.catch(() => toast.error('An error occurred while creating the environment'));
}
});
useEffect(() => {
if (inputRef && inputRef.current) {
inputRef.current.focus();
}
}, [inputRef]);
const onSubmit = () => {
formik.handleSubmit();
};
return (
<Portal>
<Modal
size="sm"
title={'Create Global Environment'}
confirmText="Create"
handleConfirm={onSubmit}
handleCancel={onClose}
>
<form className="bruno-form" onSubmit={e => e.preventDefault()}>
<div>
<label htmlFor="name" className="block font-semibold">
Environment Name
</label>
<div className="flex items-center mt-2">
<input
id="environment-name"
type="text"
name="name"
ref={inputRef}
className="block textbox w-full"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
onChange={formik.handleChange}
value={formik.values.name || ''}
/>
</div>
{formik.touched.name && formik.errors.name ? (
<div className="text-red-500">{formik.errors.name}</div>
) : null}
</div>
</form>
</Modal>
</Portal>
);
};
export default CreateEnvironment;

View File

@ -0,0 +1,15 @@
import styled from 'styled-components';
const Wrapper = styled.div`
button.submit {
color: white;
background-color: var(--color-background-danger) !important;
border: inherit !important;
&:hover {
border: inherit !important;
}
}
`;
export default Wrapper;

View File

@ -0,0 +1,37 @@
import React from 'react';
import Portal from 'components/Portal/index';
import toast from 'react-hot-toast';
import Modal from 'components/Modal/index';
import { useDispatch } from 'react-redux';
import StyledWrapper from './StyledWrapper';
import { deleteGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
const DeleteEnvironment = ({ onClose, environment }) => {
const dispatch = useDispatch();
const onConfirm = () => {
dispatch(deleteGlobalEnvironment({ environmentUid: environment.uid }))
.then(() => {
toast.success('Global Environment deleted successfully');
onClose();
})
.catch(() => toast.error('An error occurred while deleting the environment'));
};
return (
<Portal>
<StyledWrapper>
<Modal
size="sm"
title={'Delete Global Environment'}
confirmText="Delete"
handleConfirm={onConfirm}
handleCancel={onClose}
>
Are you sure you want to delete <span className="font-semibold">{environment.name}</span> ?
</Modal>
</StyledWrapper>
</Portal>
);
};
export default DeleteEnvironment;

View File

@ -0,0 +1,42 @@
import React from 'react';
import { IconAlertTriangle } from '@tabler/icons';
import Modal from 'components/Modal';
import { createPortal } from 'react-dom';
const ConfirmSwitchEnv = ({ onCancel }) => {
return createPortal(
<Modal
size="md"
title="Unsaved changes"
confirmText="Save and Close"
cancelText="Close without saving"
disableEscapeKey={true}
disableCloseOnOutsideClick={true}
closeModalFadeTimeout={150}
handleCancel={onCancel}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
}}
hideFooter={true}
>
<div className="flex items-center font-normal">
<IconAlertTriangle size={32} strokeWidth={1.5} className="text-yellow-600" />
<h1 className="ml-2 text-lg font-semibold">Hold on..</h1>
</div>
<div className="font-normal mt-4">You have unsaved changes in this environment.</div>
<div className="flex justify-between mt-6">
<div>
<button className="btn btn-sm btn-danger" onClick={onCancel}>
Close
</button>
</div>
<div></div>
</div>
</Modal>,
document.body
);
};
export default ConfirmSwitchEnv;

View File

@ -0,0 +1,66 @@
import styled from 'styled-components';
const Wrapper = styled.div`
table {
width: 100%;
border-collapse: collapse;
font-weight: 600;
table-layout: fixed;
thead,
td {
border: 1px solid ${(props) => props.theme.collection.environment.settings.gridBorder};
padding: 4px 10px;
&:nth-child(1),
&:nth-child(4) {
width: 70px;
}
&:nth-child(5) {
width: 40px;
}
&:nth-child(2) {
width: 25%;
}
}
thead {
color: ${(props) => props.theme.table.thead.color};
font-size: 0.8125rem;
user-select: none;
}
thead td {
padding: 6px 10px;
}
}
.btn-add-param {
font-size: 0.8125rem;
}
.tooltip-mod {
font-size: 11px !important;
width: 150px !important;
}
input[type='text'] {
width: 100%;
border: solid 1px transparent;
outline: none !important;
background-color: transparent;
&:focus {
outline: none !important;
border: solid 1px transparent;
}
}
input[type='checkbox'] {
cursor: pointer;
position: relative;
top: 1px;
}
`;
export default Wrapper;

View File

@ -0,0 +1,199 @@
import React, { useRef, useEffect } from 'react';
import cloneDeep from 'lodash/cloneDeep';
import { IconTrash, IconAlertCircle } from '@tabler/icons';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
import SingleLineEditor from 'components/SingleLineEditor';
import StyledWrapper from './StyledWrapper';
import { uuid } from 'utils/common';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import { variableNameRegex } from 'utils/common/regex';
import toast from 'react-hot-toast';
import { saveGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
import { Tooltip } from 'react-tooltip';
const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentVariables }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const addButtonRef = useRef(null);
const formik = useFormik({
enableReinitialize: true,
initialValues: environment.variables || [],
validationSchema: Yup.array().of(
Yup.object({
enabled: Yup.boolean(),
name: Yup.string()
.required('Name cannot be empty')
.matches(
variableNameRegex,
'Name contains invalid characters. Must only contain alphanumeric characters, "-", "_", "." and cannot start with a digit.'
)
.trim(),
secret: Yup.boolean(),
type: Yup.string(),
uid: Yup.string(),
value: Yup.string().trim().nullable()
})
),
onSubmit: (values) => {
if (!formik.dirty) {
toast.error('Nothing to save');
return;
}
dispatch(saveGlobalEnvironment({ environmentUid: environment.uid, variables: cloneDeep(values) }))
.then(() => {
toast.success('Changes saved successfully');
formik.resetForm({ values });
setIsModified(false);
})
.catch((error) => {
console.error(error);
toast.error('An error occurred while saving the changes')
});
}
});
// Effect to track modifications.
React.useEffect(() => {
setIsModified(formik.dirty);
}, [formik.dirty]);
const ErrorMessage = ({ name }) => {
const meta = formik.getFieldMeta(name);
const id = uuid();
if (!meta.error || !meta.touched) {
return null;
}
return (
<span>
<IconAlertCircle id={id} className="text-red-600 cursor-pointer " size={20} />
<Tooltip className="tooltip-mod" anchorId={id} html={meta.error || ''} />
</span>
);
};
const addVariable = () => {
const newVariable = {
uid: uuid(),
name: '',
value: '',
type: 'text',
secret: false,
enabled: true
};
formik.setFieldValue(formik.values.length, newVariable, false);
};
const handleRemoveVar = (id) => {
formik.setValues(formik.values.filter((variable) => variable.uid !== id));
};
useEffect(() => {
if (formik.dirty) {
// Smooth scrolling to the changed parameter is temporarily disabled
// due to UX issues when editing the first row in a long list of environment variables.
// addButtonRef.current?.scrollIntoView({ behavior: 'smooth' });
}
}, [formik.values, formik.dirty]);
const handleReset = () => {
formik.resetForm({ originalEnvironmentVariables });
};
return (
<StyledWrapper className="w-full mt-6 mb-6">
<div className="h-[50vh] overflow-y-auto w-full">
<table>
<thead>
<tr>
<td className="text-center">Enabled</td>
<td>Name</td>
<td>Value</td>
<td className="text-center">Secret</td>
<td></td>
</tr>
</thead>
<tbody>
{formik.values.map((variable, index) => (
<tr key={variable.uid}>
<td className="text-center">
<input
type="checkbox"
className="mousetrap"
name={`${index}.enabled`}
checked={variable.enabled}
onChange={formik.handleChange}
/>
</td>
<td>
<div className="flex items-center">
<input
type="text"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
className="mousetrap"
id={`${index}.name`}
name={`${index}.name`}
value={variable.name}
onChange={formik.handleChange}
/>
<ErrorMessage name={`${index}.name`} />
</div>
</td>
<td className="flex flex-row flex-nowrap">
<div className="overflow-hidden grow w-full relative">
<SingleLineEditor
theme={storedTheme}
name={`${index}.value`}
value={variable.value}
isSecret={variable.secret}
onChange={(newValue) => formik.setFieldValue(`${index}.value`, newValue, true)}
/>
</div>
</td>
<td className="text-center">
<input
type="checkbox"
className="mousetrap"
name={`${index}.secret`}
checked={variable.secret}
onChange={formik.handleChange}
/>
</td>
<td>
<button onClick={() => handleRemoveVar(variable.uid)}>
<IconTrash strokeWidth={1.5} size={20} />
</button>
</td>
</tr>
))}
</tbody>
</table>
<div>
<button
ref={addButtonRef}
className="btn-add-param text-link pr-2 py-3 mt-2 select-none"
onClick={addVariable}
>
+ Add Variable
</button>
</div>
</div>
<div>
<button type="submit" className="submit btn btn-md btn-secondary mt-2" onClick={formik.handleSubmit}>
Save
</button>
<button type="submit" className="ml-2 px-1 submit btn btn-md btn-secondary mt-2" onClick={handleReset}>
Reset
</button>
</div>
</StyledWrapper>
);
};
export default EnvironmentVariables;

View File

@ -0,0 +1,46 @@
import { IconCopy, IconDatabase, IconEdit, IconTrash } from '@tabler/icons';
import { useState } from 'react';
import CopyEnvironment from '../../CopyEnvironment';
import DeleteEnvironment from '../../DeleteEnvironment';
import RenameEnvironment from '../../RenameEnvironment';
import EnvironmentVariables from './EnvironmentVariables';
const EnvironmentDetails = ({ environment, setIsModified }) => {
const [openEditModal, setOpenEditModal] = useState(false);
const [openDeleteModal, setOpenDeleteModal] = useState(false);
const [openCopyModal, setOpenCopyModal] = useState(false);
return (
<div className="px-6 flex-grow flex flex-col pt-6" style={{ maxWidth: '700px' }}>
{openEditModal && (
<RenameEnvironment onClose={() => setOpenEditModal(false)} environment={environment} />
)}
{openDeleteModal && (
<DeleteEnvironment
onClose={() => setOpenDeleteModal(false)}
environment={environment}
/>
)}
{openCopyModal && (
<CopyEnvironment onClose={() => setOpenCopyModal(false)} environment={environment} />
)}
<div className="flex">
<div className="flex flex-grow items-center">
<IconDatabase className="cursor-pointer" size={20} strokeWidth={1.5} />
<span className="ml-1 font-semibold break-all">{environment.name}</span>
</div>
<div className="flex gap-x-4 pl-4">
<IconEdit className="cursor-pointer" size={20} strokeWidth={1.5} onClick={() => setOpenEditModal(true)} />
<IconCopy className="cursor-pointer" size={20} strokeWidth={1.5} onClick={() => setOpenCopyModal(true)} />
<IconTrash className="cursor-pointer" size={20} strokeWidth={1.5} onClick={() => setOpenDeleteModal(true)} />
</div>
</div>
<div>
<EnvironmentVariables environment={environment} setIsModified={setIsModified} />
</div>
</div>
);
};
export default EnvironmentDetails;

View File

@ -0,0 +1,58 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
margin-inline: -1rem;
margin-block: -1.5rem;
background-color: ${(props) => props.theme.collection.environment.settings.bg};
.environments-sidebar {
background-color: ${(props) => props.theme.collection.environment.settings.sidebar.bg};
border-right: solid 1px ${(props) => props.theme.collection.environment.settings.sidebar.borderRight};
min-height: 400px;
height: 100%;
max-height: 85vh;
overflow-y: auto;
}
.environment-item {
min-width: 150px;
display: block;
position: relative;
cursor: pointer;
padding: 8px 10px;
border-left: solid 2px transparent;
text-decoration: none;
&:hover {
text-decoration: none;
background-color: ${(props) => props.theme.collection.environment.settings.item.hoverBg};
}
}
.active {
background-color: ${(props) => props.theme.collection.environment.settings.item.active.bg} !important;
border-left: solid 2px ${(props) => props.theme.collection.environment.settings.item.border};
&:hover {
background-color: ${(props) => props.theme.collection.environment.settings.item.active.hoverBg} !important;
}
}
.btn-create-environment,
.btn-import-environment {
padding: 8px 10px;
cursor: pointer;
border-bottom: none;
color: ${(props) => props.theme.textLink};
span:hover {
text-decoration: underline;
}
}
.btn-import-environment {
color: ${(props) => props.theme.colors.text.muted};
}
`;
export default StyledWrapper;

View File

@ -0,0 +1,149 @@
import React, { useEffect, useState } from 'react';
import usePrevious from 'hooks/usePrevious';
import EnvironmentDetails from './EnvironmentDetails';
import CreateEnvironment from '../CreateEnvironment';
import { IconDownload, IconShieldLock } from '@tabler/icons';
import StyledWrapper from './StyledWrapper';
import ConfirmSwitchEnv from './ConfirmSwitchEnv';
import ManageSecrets from 'components/Environments/EnvironmentSettings/ManageSecrets/index';
import ImportEnvironment from '../ImportEnvironment';
import { isEqual } from 'lodash';
const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironment, setSelectedEnvironment, isModified, setIsModified }) => {
const [openCreateModal, setOpenCreateModal] = useState(false);
const [openImportModal, setOpenImportModal] = useState(false);
const [openManageSecretsModal, setOpenManageSecretsModal] = useState(false);
const [switchEnvConfirmClose, setSwitchEnvConfirmClose] = useState(false);
const [originalEnvironmentVariables, setOriginalEnvironmentVariables] = useState([]);
const envUids = environments ? environments.map((env) => env.uid) : [];
const prevEnvUids = usePrevious(envUids);
useEffect(() => {
if (!environments?.length) {
setSelectedEnvironment(null);
setOriginalEnvironmentVariables([]);
return;
}
if (selectedEnvironment) {
const _selectedEnvironment = environments?.find(env => env?.uid === selectedEnvironment?.uid);
const hasSelectedEnvironmentChanged = !isEqual(selectedEnvironment, _selectedEnvironment);
if (hasSelectedEnvironmentChanged) {
setSelectedEnvironment(_selectedEnvironment);
}
setOriginalEnvironmentVariables(selectedEnvironment.variables);
return;
}
const environment = environments?.find(env => env.uid === activeEnvironmentUid) || environments?.[0];
setSelectedEnvironment(environment);
setOriginalEnvironmentVariables(environment?.variables || []);
}, [environments, activeEnvironmentUid]);
useEffect(() => {
if (prevEnvUids && prevEnvUids.length && envUids.length > prevEnvUids.length) {
const newEnv = environments.find((env) => !prevEnvUids.includes(env.uid));
if (newEnv) {
setSelectedEnvironment(newEnv);
}
}
if (prevEnvUids && prevEnvUids.length && envUids.length < prevEnvUids.length) {
setSelectedEnvironment(environments && environments.length ? environments[0] : null);
}
}, [envUids, environments, prevEnvUids]);
const handleEnvironmentClick = (env) => {
if (!isModified) {
setSelectedEnvironment(env);
} else {
setSwitchEnvConfirmClose(true);
}
};
if (!selectedEnvironment) {
return null;
}
const handleCreateEnvClick = () => {
if (!isModified) {
setOpenCreateModal(true);
} else {
setSwitchEnvConfirmClose(true);
}
};
const handleImportClick = () => {
if (!isModified) {
setOpenImportModal(true);
} else {
setSwitchEnvConfirmClose(true);
}
};
const handleSecretsClick = () => {
setOpenManageSecretsModal(true);
};
const handleConfirmSwitch = (saveChanges) => {
if (!saveChanges) {
setSwitchEnvConfirmClose(false);
}
};
return (
<StyledWrapper>
{openCreateModal && <CreateEnvironment onClose={() => setOpenCreateModal(false)} />}
{openImportModal && <ImportEnvironment onClose={() => setOpenImportModal(false)} />}
{openManageSecretsModal && <ManageSecrets onClose={() => setOpenManageSecretsModal(false)} />}
<div className="flex">
<div>
{switchEnvConfirmClose && (
<div className="flex items-center justify-between tab-container px-1">
<ConfirmSwitchEnv onCancel={() => handleConfirmSwitch(false)} />
</div>
)}
<div className="environments-sidebar flex flex-col">
{environments &&
environments.length &&
environments.map((env) => (
<div
key={env.uid}
className={selectedEnvironment.uid === env.uid ? 'environment-item active' : 'environment-item'}
onClick={() => handleEnvironmentClick(env)} // Use handleEnvironmentClick to handle clicks
>
<span className="break-all">{env.name}</span>
</div>
))}
<div className="btn-create-environment" onClick={() => handleCreateEnvClick()}>
+ <span>Create</span>
</div>
<div className="mt-auto btn-import-environment">
<div className="flex items-center" onClick={() => handleImportClick()}>
<IconDownload size={12} strokeWidth={2} />
<span className="label ml-1 text-xs">Import</span>
</div>
<div className="flex items-center mt-2" onClick={() => handleSecretsClick()}>
<IconShieldLock size={12} strokeWidth={2} />
<span className="label ml-1 text-xs">Managing Secrets</span>
</div>
</div>
</div>
</div>
<EnvironmentDetails
environment={selectedEnvironment}
setIsModified={setIsModified}
originalEnvironmentVariables={originalEnvironmentVariables}
/>
</div>
</StyledWrapper>
);
};
export default EnvironmentList;

View File

@ -0,0 +1,62 @@
import React from 'react';
import Portal from 'components/Portal';
import Modal from 'components/Modal';
import toast from 'react-hot-toast';
import { useDispatch } from 'react-redux';
import importPostmanEnvironment from 'utils/importers/postman-environment';
import { toastError } from 'utils/common/error';
import { IconDatabaseImport } from '@tabler/icons';
import { addGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
import { uuid } from 'utils/common/index';
const ImportEnvironment = ({ onClose }) => {
const dispatch = useDispatch();
const handleImportPostmanEnvironment = () => {
importPostmanEnvironment()
.then((environments) => {
environments
.filter((env) =>
env.name && env.name !== 'undefined'
? true
: () => {
toast.error('Failed to import environment: env has no name');
return false;
}
)
.map((environment) => {
let variables = environment?.variables?.map(v => ({
...v,
uid: uuid(),
type: 'text'
}));
dispatch(addGlobalEnvironment({ name: environment.name, variables }))
.then(() => {
toast.success('Global Environment imported successfully');
})
.catch(() => toast.error('An error occurred while importing the environment'));
});
})
.then(() => {
onClose();
})
.catch((err) => toastError(err, 'Postman Import environment failed'));
};
return (
<Portal>
<Modal size="sm" title="Import Global Environment" hideFooter={true} handleConfirm={onClose} handleCancel={onClose}>
<button
type="button"
onClick={handleImportPostmanEnvironment}
className="flex justify-center flex-col items-center w-full dark:bg-zinc-700 rounded-lg border-2 border-dashed border-zinc-300 dark:border-zinc-400 p-12 text-center hover:border-zinc-400 focus:outline-none focus:ring-2 focus:ring-amber-500 focus:ring-offset-2"
>
<IconDatabaseImport size={64} />
<span className="mt-2 block text-sm font-semibold">Import your Postman environments</span>
</button>
</Modal>
</Portal>
);
};
export default ImportEnvironment;

View File

@ -0,0 +1,88 @@
import React, { useEffect, useRef } from 'react';
import Portal from 'components/Portal/index';
import Modal from 'components/Modal/index';
import toast from 'react-hot-toast';
import { useFormik } from 'formik';
import { renameEnvironment } from 'providers/ReduxStore/slices/collections/actions';
import * as Yup from 'yup';
import { useDispatch } from 'react-redux';
import { renameGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
const RenameEnvironment = ({ onClose, environment }) => {
const dispatch = useDispatch();
const inputRef = useRef();
const formik = useFormik({
enableReinitialize: true,
initialValues: {
name: environment.name
},
validationSchema: Yup.object({
name: Yup.string()
.min(1, 'must be at least 1 character')
.max(50, 'must be 50 characters or less')
.required('name is required')
}),
onSubmit: (values) => {
if (values.name === environment.name) {
return;
}
dispatch(renameGlobalEnvironment({ name: values.name, environmentUid: environment.uid }))
.then(() => {
toast.success('Environment renamed successfully');
onClose();
})
.catch((error) => {
toast.error('An error occurred while renaming the environment');
console.error(error);
});
}
});
useEffect(() => {
if (inputRef && inputRef.current) {
inputRef.current.focus();
}
}, [inputRef]);
const onSubmit = () => {
formik.handleSubmit();
};
return (
<Portal>
<Modal
size="sm"
title={'Rename Environment'}
confirmText="Rename"
handleConfirm={onSubmit}
handleCancel={onClose}
>
<form className="bruno-form" onSubmit={e => e.preventDefault()}>
<div>
<label htmlFor="name" className="block font-semibold">
Environment Name
</label>
<input
id="environment-name"
type="text"
name="name"
ref={inputRef}
className="block textbox mt-2 w-full"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
onChange={formik.handleChange}
value={formik.values.name || ''}
/>
{formik.touched.name && formik.errors.name ? (
<div className="text-red-500">{formik.errors.name}</div>
) : null}
</div>
</form>
</Modal>
</Portal>
);
};
export default RenameEnvironment;

View File

@ -0,0 +1,13 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
button.btn-create-environment {
&:hover {
span {
text-decoration: underline;
}
}
}
`;
export default StyledWrapper;

View File

@ -0,0 +1,78 @@
import Modal from 'components/Modal/index';
import React, { useState } from 'react';
import CreateEnvironment from './CreateEnvironment';
import EnvironmentList from './EnvironmentList';
import StyledWrapper from './StyledWrapper';
import { IconFileAlert } from '@tabler/icons';
import ImportEnvironment from './ImportEnvironment/index';
export const SharedButton = ({ children, className, onClick }) => {
return (
<button
type="button"
onClick={onClick}
className={`rounded bg-transparent px-2.5 py-2 w-fit text-xs font-semibold text-zinc-900 dark:text-zinc-50 shadow-sm ring-1 ring-inset ring-zinc-300 dark:ring-zinc-500 hover:bg-gray-50 dark:hover:bg-zinc-700
${className}`}
>
{children}
</button>
);
};
const DefaultTab = ({ setTab }) => {
return (
<div className="text-center items-center flex flex-col">
<IconFileAlert size={64} strokeWidth={1} />
<span className="font-semibold mt-2">No Global Environments found</span>
<div className="flex items-center justify-center mt-6">
<SharedButton onClick={() => setTab('create')}>
<span>Create Global Environment</span>
</SharedButton>
<span className="mx-4">Or</span>
<SharedButton onClick={() => setTab('import')}>
<span>Import Environment</span>
</SharedButton>
</div>
</div>
);
};
const EnvironmentSettings = ({ globalEnvironments, activeGlobalEnvironmentUid, onClose }) => {
const [isModified, setIsModified] = useState(false);
const environments = globalEnvironments;
const [selectedEnvironment, setSelectedEnvironment] = useState(null);
const [tab, setTab] = useState('default');
if (!environments || !environments.length) {
return (
<StyledWrapper>
<Modal size="md" title="Global Environments" handleCancel={onClose} hideCancel={true} hideFooter={true}>
{tab === 'create' ? (
<CreateEnvironment onClose={() => setTab('default')} />
) : tab === 'import' ? (
<ImportEnvironment onClose={() => setTab('default')} />
) : (
<></>
)}
<DefaultTab setTab={setTab} />
</Modal>
</StyledWrapper>
);
}
return (
<Modal size="lg" title="Global Environments" handleCancel={onClose} hideFooter={true}>
<EnvironmentList
environments={globalEnvironments}
activeEnvironmentUid={activeGlobalEnvironmentUid}
selectedEnvironment={selectedEnvironment}
setSelectedEnvironment={setSelectedEnvironment}
isModified={isModified}
setIsModified={setIsModified}
/>
</Modal>
);
};
export default EnvironmentSettings;

View File

@ -5,7 +5,7 @@ import { defineCodeMirrorBrunoVariablesMode } from 'utils/common/codemirror';
import StyledWrapper from './StyledWrapper';
let CodeMirror;
const SERVER_RENDERED = typeof navigator === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
if (!SERVER_RENDERED) {
CodeMirror = require('codemirror');

View File

@ -1,19 +0,0 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.collection-dropdown {
color: rgb(110 110 110);
&:hover {
color: inherit;
}
.tippy-box {
top: -0.5rem;
position: relative;
user-select: none;
}
}
`;
export default StyledWrapper;

View File

@ -1,60 +0,0 @@
import React, { useState, forwardRef, useRef } from 'react';
import Dropdown from '../Dropdown';
import { faCaretDown } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { IconBox, IconSearch, IconDots } from '@tabler/icons';
import StyledWrapper from './StyledWrapper';
const Navbar = () => {
const [modalOpen, setModalOpen] = useState(false);
const menuDropdownTippyRef = useRef();
const onMenuDropdownCreate = (ref) => (menuDropdownTippyRef.current = ref);
const MenuIcon = forwardRef((props, ref) => {
return (
<div ref={ref} className="dropdown-icon cursor-pointer">
<IconDots size={22} />
</div>
);
});
return (
<StyledWrapper className="px-2 py-2 flex items-center">
<div>
<span className="ml-2">Collections</span>
{/* <FontAwesomeIcon className="ml-2" icon={faCaretDown} style={{fontSize: 13}}/> */}
</div>
<div className="collection-dropdown flex flex-grow items-center justify-end">
<Dropdown onCreate={onMenuDropdownCreate} icon={<MenuIcon />} placement="bottom-start">
<div
className="dropdown-item"
onClick={(e) => {
menuDropdownTippyRef.current.hide();
setModalOpen(true);
}}
>
Create Collection
</div>
<div
className="dropdown-item"
onClick={(e) => {
menuDropdownTippyRef.current.hide();
}}
>
Import Collection
</div>
<div
className="dropdown-item"
onClick={(e) => {
menuDropdownTippyRef.current.hide();
}}
>
Settings
</div>
</Dropdown>
</div>
</StyledWrapper>
);
};
export default Navbar;

View File

@ -34,7 +34,7 @@ const StyledWrapper = styled.div`
.key-button {
display: inline-block;
color: ${(props) => props.theme.colors.text.white};
color: ${(props) => props.theme.table.input.color};
border-radius: 4px;
padding: 1px 5px;
font-family: monospace;

View File

@ -17,9 +17,11 @@ import { find, get } from 'lodash';
import Documentation from 'components/Documentation/index';
const ContentIndicator = () => {
return <sup className="ml-[.125rem] opacity-80 font-medium">
<DotIcon width="10"></DotIcon>
</sup>
return (
<sup className="ml-[.125rem] opacity-80 font-medium">
<DotIcon width="10"></DotIcon>
</sup>
);
};
const HttpRequestPane = ({ item, collection, leftPaneWidth }) => {
@ -100,6 +102,7 @@ const HttpRequestPane = ({ item, collection, leftPaneWidth }) => {
const docs = getPropertyFromDraftOrRequest('request.docs');
const requestVars = getPropertyFromDraftOrRequest('request.vars.req');
const responseVars = getPropertyFromDraftOrRequest('request.vars.res');
const auth = getPropertyFromDraftOrRequest('request.auth');
const activeParamsLength = params.filter((param) => param.enabled).length;
const activeHeadersLength = headers.filter((header) => header.enabled).length;
@ -125,6 +128,7 @@ const HttpRequestPane = ({ item, collection, leftPaneWidth }) => {
</div>
<div className={getTabClassname('auth')} role="tab" onClick={() => selectTab('auth')}>
Auth
{auth.mode !== 'none' && <ContentIndicator />}
</div>
<div className={getTabClassname('vars')} role="tab" onClick={() => selectTab('vars')}>
Vars

View File

@ -50,6 +50,18 @@ const StyledWrapper = styled.div`
.cm-variable-invalid {
color: red;
}
.CodeMirror-search-hint {
display: inline;
}
.cm-s-default span.cm-property {
color: #1f61a0 !important;
}
.cm-s-default span.cm-variable {
color: #397d13 !important;
}
`;
export default StyledWrapper;

View File

@ -19,7 +19,7 @@ import { IconWand } from '@tabler/icons';
import onHasCompletion from './onHasCompletion';
let CodeMirror;
const SERVER_RENDERED = typeof navigator === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
if (!SERVER_RENDERED) {
CodeMirror = require('codemirror');
@ -209,7 +209,7 @@ export default class QueryEditor extends React.Component {
return (
<>
<StyledWrapper
className="h-full w-full flex flex-col relative"
className="h-full w-full flex flex-col relative graphiql-container"
aria-label="Query Editor"
font={this.props.font}
fontSize={this.props.fontSize}

View File

@ -9,7 +9,6 @@ import StyledWrapper from './StyledWrapper';
import { updateRequestBody } from 'providers/ReduxStore/slices/collections/index';
import { toastError } from 'utils/common/error';
import { format, applyEdits } from 'jsonc-parser';
import { parse, stringify } from 'lossless-json';
import xmlFormat from 'xml-formatter';
const RequestBodyMode = ({ item, collection }) => {

View File

@ -1,18 +0,0 @@
import styled from 'styled-components';
const Wrapper = styled.div`
.folder-list {
border: 1px solid #ccc;
border-radius: 5px;
.folder-name {
padding-block: 8px;
padding-inline: 12px;
cursor: pointer;
&:hover {
background-color: #e8e8e8;
}
}
`;
export default Wrapper;

View File

@ -1,55 +0,0 @@
import React, { useState, useEffect } from 'react';
import { faFolder } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import StyledWrapper from './StyledWrapper';
import Modal from 'components//Modal';
const SaveRequest = ({ items, onClose }) => {
const [showFolders, setShowFolders] = useState([]);
useEffect(() => {
setShowFolders(items || []);
}, [items]);
const handleFolderClick = (folder) => {
let subFolders = [];
if (folder.items && folder.items.length) {
for (let item of folder.items) {
if (item.items) {
subFolders.push(item);
}
}
if (subFolders.length) {
setShowFolders(subFolders);
}
}
};
return (
<StyledWrapper>
<Modal
size="md"
title="Save Request"
confirmText="Save"
cancelText="Cancel"
handleCancel={onClose}
handleConfirm={onClose}
>
<p className="mb-2">Select a folder to save request:</p>
<div className="folder-list">
{showFolders && showFolders.length
? showFolders.map((folder) => (
<div key={folder.uid} className="folder-name" onClick={() => handleFolderClick(folder)}>
<FontAwesomeIcon className="mr-3 text-gray-500" icon={faFolder} style={{ fontSize: 20 }} />
{folder.name}
</div>
))
: null}
</div>
</Modal>
</StyledWrapper>
);
};
export default SaveRequest;

View File

@ -20,6 +20,8 @@ import { DocExplorer } from '@usebruno/graphql-docs';
import StyledWrapper from './StyledWrapper';
import SecuritySettings from 'components/SecuritySettings';
import FolderSettings from 'components/FolderSettings';
import { getGlobalEnvironmentVariables } from 'utils/collections/index';
import { produce } from 'immer';
const MIN_LEFT_PANE_WIDTH = 300;
const MIN_RIGHT_PANE_WIDTH = 350;
@ -32,11 +34,25 @@ const RequestTabPanel = () => {
const dispatch = useDispatch();
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const collections = useSelector((state) => state.collections.collections);
const screenWidth = useSelector((state) => state.app.screenWidth);
let asideWidth = useSelector((state) => state.app.leftSidebarWidth);
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments);
const _collections = useSelector((state) => state.collections.collections);
// merge `globalEnvironmentVariables` into the active collection and rebuild `collections` immer proxy object
let collections = produce(_collections, draft => {
let collection = find(draft, (c) => c.uid === focusedTab?.collectionUid);
if (collection) {
// add selected global env variables to the collection object
const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid });
collection.globalEnvironmentVariables = globalEnvironmentVariables;
}
});
let collection = find(collections, (c) => c.uid === focusedTab?.collectionUid);
const screenWidth = useSelector((state) => state.app.screenWidth);
let asideWidth = useSelector((state) => state.app.leftSidebarWidth);
const [leftPaneWidth, setLeftPaneWidth] = useState(
focusedTab && focusedTab.requestPaneWidth ? focusedTab.requestPaneWidth : (screenWidth - asideWidth) / 2.2
); // 2.2 so that request pane is relatively smaller
@ -117,7 +133,6 @@ const RequestTabPanel = () => {
return <div className="pb-4 px-4">An error occurred!</div>;
}
let collection = find(collections, (c) => c.uid === focusedTab.collectionUid);
if (!collection || !collection.uid) {
return <div className="pb-4 px-4">Collection not found!</div>;
}

View File

@ -2,6 +2,7 @@ import React from 'react';
import { uuid } from 'utils/common';
import { IconFiles, IconRun, IconEye, IconSettings } from '@tabler/icons';
import EnvironmentSelector from 'components/Environments/EnvironmentSelector';
import GlobalEnvironmentSelector from 'components/GlobalEnvironments/EnvironmentSelector';
import { addTab } from 'providers/ReduxStore/slices/tabs';
import { useDispatch } from 'react-redux';
import ToolHint from 'components/ToolHint';
@ -48,7 +49,7 @@ const CollectionToolBar = ({ collection }) => {
<IconFiles size={18} strokeWidth={1.5} />
<span className="ml-2 mr-4 font-semibold">{collection?.name}</span>
</div>
<div className="flex flex-1 items-center justify-end">
<div className="flex flex-3 items-center justify-end">
<span className="mr-2">
<JsSandboxMode collection={collection} />
</span>
@ -67,6 +68,9 @@ const CollectionToolBar = ({ collection }) => {
<IconSettings className="cursor-pointer" size={18} strokeWidth={1.5} onClick={viewCollectionSettings} />
</ToolHint>
</span>
<span>
<GlobalEnvironmentSelector />
</span>
<EnvironmentSelector collection={collection} />
</div>
</div>

View File

@ -1,12 +1,41 @@
import React, { useState, useEffect } from 'react';
import CodeEditor from 'components/CodeEditor/index';
import { get } from 'lodash';
import { useDispatch, useSelector } from 'react-redux';
import { sendRequest } from 'providers/ReduxStore/slices/collections/actions';
import { Document, Page } from 'react-pdf';
import { useState } from 'react';
import 'pdfjs-dist/build/pdf.worker';
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)}
/>
);
});
const QueryResultPreview = ({
previewTab,
@ -71,9 +100,7 @@ const QueryResultPreview = ({
);
}
case 'preview-video': {
return (
<video controls src={`data:${contentType.replace(/\;(.*)/, '')};base64,${dataBuffer}`} className="mx-auto" />
);
return <VideoPreview contentType={contentType} dataBuffer={dataBuffer} />;
}
default:
case 'raw': {

View File

@ -8,19 +8,27 @@ import { useSelector } from 'react-redux';
import { CopyToClipboard } from 'react-copy-to-clipboard';
import toast from 'react-hot-toast';
import { IconCopy } from '@tabler/icons';
import { findCollectionByItemUid } from '../../../../../../../utils/collections/index';
import { findCollectionByItemUid, getGlobalEnvironmentVariables } from '../../../../../../../utils/collections/index';
import { getAuthHeaders } from '../../../../../../../utils/codegenerator/auth';
import { cloneDeep } from 'lodash';
const CodeView = ({ language, item }) => {
const { displayedTheme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments);
const { target, client, language: lang } = language;
const requestHeaders = item.draft ? get(item, 'draft.request.headers') : get(item, 'request.headers');
const collection = findCollectionByItemUid(
let _collection = findCollectionByItemUid(
useSelector((state) => state.collections.collections),
item.uid
);
let collection = cloneDeep(_collection);
// add selected global env variables to the collection object
const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid });
collection.globalEnvironmentVariables = globalEnvironmentVariables;
const collectionRootAuth = collection?.root?.request?.auth;
const requestAuth = item.draft ? get(item, 'draft.request.auth') : get(item, 'request.auth');

View File

@ -3,15 +3,20 @@ import { useState } from 'react';
import CodeView from './CodeView';
import StyledWrapper from './StyledWrapper';
import { isValidUrl } from 'utils/url';
import { find, get } from 'lodash';
import { get } from 'lodash';
import { findEnvironmentInCollection } from 'utils/collections';
import { interpolateUrl, interpolateUrlPathParams } from 'utils/url/index';
import { getLanguages } from 'utils/codegenerator/targets';
import { useSelector } from 'react-redux';
import { getGlobalEnvironmentVariables } from 'utils/collections/index';
const GenerateCodeItem = ({ collection, item, onClose }) => {
const languages = getLanguages();
const environment = findEnvironmentInCollection(collection, collection.activeEnvironmentUid);
const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments);
const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid });
const environment = findEnvironmentInCollection(collection, collection?.activeEnvironmentUid);
let envVars = {};
if (environment) {
const vars = get(environment, 'variables', []);
@ -27,6 +32,7 @@ const GenerateCodeItem = ({ collection, item, onClose }) => {
// interpolate the url
const interpolatedUrl = interpolateUrl({
url: requestUrl,
globalEnvironmentVariables,
envVars,
runtimeVariables: collection.runtimeVariables,
processEnvVars: collection.processEnvVariables

View File

@ -8,7 +8,7 @@ import StyledWrapper from './StyledWrapper';
import { useTheme } from 'providers/Theme/index';
let posthogClient = null;
const posthogApiKey = 'phc_7gtqSrrdZRohiozPMLIacjzgHbUlhalW1Bu16uYijMR';
const posthogApiKey = process.env.NEXT_PUBLIC_POSTHOG_API_KEY;
const getPosthogClient = () => {
if (posthogClient) {
return posthogClient;

View File

@ -39,6 +39,14 @@ const StyledWrapper = styled.div`
textarea.curl-command {
min-height: 150px;
}
.dropdown {
width: fit-content;
.dropdown-item {
padding: 0.2rem 0.6rem !important;
}
}
`;
export default StyledWrapper;

View File

@ -1,4 +1,4 @@
import React, { useRef, useEffect, useCallback } from 'react';
import React, { useRef, useEffect, useCallback, forwardRef, useState } from 'react';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import toast from 'react-hot-toast';
@ -12,6 +12,8 @@ import HttpMethodSelector from 'components/RequestPane/QueryUrl/HttpMethodSelect
import { getDefaultRequestPaneTab } from 'utils/collections';
import StyledWrapper from './StyledWrapper';
import { getRequestFromCurlCommand } from 'utils/curl';
import Dropdown from 'components/Dropdown';
import { IconCaretDown } from '@tabler/icons';
const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
const dispatch = useDispatch();
@ -19,6 +21,39 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
const {
brunoConfig: { presets: collectionPresets = {} }
} = collection;
const [curlRequestTypeDetected, setCurlRequestTypeDetected] = useState(null);
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const Icon = forwardRef((props, ref) => {
return (
<div ref={ref} className="flex items-center justify-end auth-type-label select-none">
{curlRequestTypeDetected === 'http-request' ? "HTTP" : "GraphQL"}
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
</div>
);
});
// This function analyzes a given cURL command string and determines whether the request is a GraphQL or HTTP request.
const identifyCurlRequestType = (url, headers, body) => {
if (url.endsWith('/graphql')) {
setCurlRequestTypeDetected('graphql-request');
return;
}
const contentType = headers?.find((h) => h.name.toLowerCase() === 'content-type')?.value;
if (contentType && contentType.includes('application/graphql')) {
setCurlRequestTypeDetected('graphql-request');
return;
}
setCurlRequestTypeDetected('http-request');
};
const curlRequestTypeChange = (type) => {
setCurlRequestTypeDetected(type);
};
const getRequestType = (collectionPresets) => {
if (!collectionPresets || !collectionPresets.requestType) {
@ -99,11 +134,11 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
})
.catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request'));
} else if (values.requestType === 'from-curl') {
const request = getRequestFromCurlCommand(values.curlCommand);
const request = getRequestFromCurlCommand(values.curlCommand, curlRequestTypeDetected);
dispatch(
newHttpRequest({
requestName: values.requestName,
requestType: 'http-request',
requestType: curlRequestTypeDetected,
requestUrl: request.url,
requestMethod: request.method,
collectionUid: collection.uid,
@ -158,6 +193,12 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
formik.setFieldValue('requestType', 'from-curl');
formik.setFieldValue('curlCommand', pastedData);
// Identify the request type
const request = getRequestFromCurlCommand(pastedData);
if (request) {
identifyCurlRequestType(request.url, request.headers, request.body);
}
// Prevent the default paste behavior to avoid pasting into the textarea
event.preventDefault();
}
@ -165,6 +206,18 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
[formik]
);
const handleCurlCommandChange = (event) => {
formik.handleChange(event);
if (event.target.name === 'curlCommand') {
const curlCommand = event.target.value;
const request = getRequestFromCurlCommand(curlCommand);
if (request) {
identifyCurlRequestType(request.url, request.headers, request.body);
}
}
};
return (
<StyledWrapper>
<Modal size="md" title="New Request" confirmText="Create" handleConfirm={onSubmit} handleCancel={onClose}>
@ -279,15 +332,37 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
</>
) : (
<div className="mt-4">
<label htmlFor="request-url" className="block font-semibold">
cURL Command
</label>
<div className="flex justify-between">
<label htmlFor="request-url" className="block font-semibold">
cURL Command
</label>
<Dropdown className="dropdown" onCreate={onDropdownCreate} icon={<Icon />} placement="bottom-end">
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
curlRequestTypeChange('http-request');
}}
>
HTTP
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
curlRequestTypeChange('graphql-request');
}}
>
GraphQL
</div>
</Dropdown>
</div>
<textarea
name="curlCommand"
placeholder="Enter cURL request here.."
className="block textbox w-full mt-4 curl-command"
value={formik.values.curlCommand}
onChange={formik.handleChange}
onChange={handleCurlCommandChange}
></textarea>
{formik.touched.curlCommand && formik.errors.curlCommand ? (
<div className="text-red-500">{formik.errors.curlCommand}</div>

View File

@ -1,7 +1,6 @@
import TitleBar from './TitleBar';
import Collections from './Collections';
import StyledWrapper from './StyledWrapper';
import GitHubButton from 'react-github-btn';
import Preferences from 'components/Preferences';
import Cookies from 'components/Cookies';
import ToolHint from 'components/ToolHint';
@ -185,7 +184,7 @@ const Sidebar = () => {
Star
</GitHubButton> */}
</div>
<div className="flex flex-grow items-center justify-end text-xs mr-2">v1.31.0</div>
<div className="flex flex-grow items-center justify-end text-xs mr-2">v1.34.2</div>
</div>
</div>
</div>

View File

@ -6,7 +6,7 @@ import StyledWrapper from './StyledWrapper';
import { IconEye, IconEyeOff } from '@tabler/icons';
let CodeMirror;
const SERVER_RENDERED = typeof navigator === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
if (!SERVER_RENDERED) {
CodeMirror = require('codemirror');

View File

@ -1,74 +0,0 @@
import styled from 'styled-components';
const Wrapper = styled.div`
&.bruno-toast {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
display: flex;
justify-content: center;
}
.bruno-toast-card {
-webkit-animation-duration: 0.85s;
animation-duration: 0.85s;
-webkit-animation-delay: 0.1s;
animation-delay: 0.1s;
border-radius: var(--border-radius);
position: relative;
max-width: calc(100% - var(--spacing-base-unit));
margin: 3vh 10vw;
animation: fade-and-slide-in-from-top 0.5s forwards cubic-bezier(0.19, 1, 0.22, 1);
}
.notification-toast-content {
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 5px;
border-radius: 4px;
}
.alert {
position: relative;
padding: 0.25rem 0.75rem;
border: 1px solid transparent;
border-radius: 0.25rem;
display: flex;
justify-content: space-between;
}
.alert-error {
color: #721c24;
background-color: #f8d7da;
border-color: #f5c6cb;
}
.alert-info {
color: #004085;
background-color: #cce5ff;
border-color: #b8daff;
}
.alert-warning {
color: #856404;
background-color: #fff3cd;
border-color: #ffeeba;
}
.alert-success {
color: #155724;
background-color: #d4edda;
border-color: #c3e6cb;
}
.closeToast {
cursor: pointer;
padding-left: 10px;
}
`;
export default Wrapper;

View File

@ -1,33 +0,0 @@
import React, { useEffect } from 'react';
import StyledWrapper from './StyledWrapper';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faTimes } from '@fortawesome/free-solid-svg-icons';
const ToastContent = ({ type, text, handleClose }) => (
<div className={`alert alert-${type}`} role="alert">
<div> {text} </div>
<div onClick={handleClose} className="closeToast">
<FontAwesomeIcon size="xs" icon={faTimes} />
</div>
</div>
);
const Toast = ({ text, type, duration, handleClose }) => {
let lifetime = duration ? duration : 3000;
useEffect(() => {
if (text) {
setTimeout(handleClose, lifetime);
}
}, [text]);
return (
<StyledWrapper className="bruno-toast">
<div className="bruno-toast-card">
<ToastContent type={type} text={text} handleClose={handleClose}></ToastContent>
</div>
</StyledWrapper>
);
};
export default Toast;

View File

@ -10,7 +10,8 @@ const ToolHint = ({
tooltipStyle = {},
place = 'top',
offset,
theme = null
theme = null,
className = ''
}) => {
const { theme: contextTheme } = useTheme();
const appliedTheme = theme || contextTheme;
@ -22,13 +23,14 @@ const ToolHint = ({
...tooltipStyle,
fontSize: '0.75rem',
padding: '0.25rem 0.5rem',
zIndex: 9999,
backgroundColor: toolhintBackgroundColor,
color: toolhintTextColor
};
return (
<>
<span id={toolhintId}>{children}</span>
<span id={toolhintId} className={className}>{children}</span>
<StyledWrapper theme={appliedTheme}>
<ReactToolHint
anchorId={toolhintId}

View File

@ -231,6 +231,11 @@ const GlobalStyle = createGlobalStyle`
.CodeMirror-brunoVarInfo p {
margin: 1em 0;
}
.CodeMirror-hint-active {
background: #08f !important;
color: #fff !important;
}
`;
export default GlobalStyle;

View File

@ -10,7 +10,7 @@ import 'codemirror/theme/material.css';
import 'codemirror/theme/monokai.css';
import 'codemirror/addon/scroll/simplescrollbars.css';
const SERVER_RENDERED = typeof navigator === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
if (!SERVER_RENDERED) {
require('codemirror/mode/javascript/javascript');
require('codemirror/mode/xml/xml');

View File

@ -8,7 +8,6 @@ import ReduxStore from 'providers/ReduxStore';
import ThemeProvider from 'providers/Theme/index';
import ErrorBoundary from './ErrorBoundary';
import '../styles/app.scss';
import '../styles/globals.css';
import 'codemirror/lib/codemirror.css';
import 'graphiql/graphiql.min.css';
@ -23,13 +22,15 @@ import '@fontsource/inter/600.css';
import '@fontsource/inter/700.css';
import '@fontsource/inter/800.css';
import '@fontsource/inter/900.css';
import { setupPolyfills } from 'utils/common/setupPolyfills';
setupPolyfills();
function SafeHydrate({ children }) {
return <div suppressHydrationWarning>{typeof window === 'undefined' ? null : children}</div>;
}
function NoSsr({ children }) {
const SERVER_RENDERED = typeof navigator === 'undefined';
const SERVER_RENDERED = typeof window === 'undefined';
if (SERVER_RENDERED) {
return null;

View File

@ -19,10 +19,11 @@ import {
runRequestEvent,
scriptEnvironmentUpdateEvent
} from 'providers/ReduxStore/slices/collections';
import { collectionAddEnvFileEvent, openCollectionEvent, hydrateCollectionsWithUiStateSnapshot } from 'providers/ReduxStore/slices/collections/actions';
import { collectionAddEnvFileEvent, openCollectionEvent, hydrateCollectionWithUiStateSnapshot } from 'providers/ReduxStore/slices/collections/actions';
import toast from 'react-hot-toast';
import { useDispatch } from 'react-redux';
import { isElectron } from 'utils/common/platform';
import { globalEnvironmentsUpdateEvent, updateGlobalEnvironments } from 'providers/ReduxStore/slices/global-environments';
const useIpcEvents = () => {
const dispatch = useDispatch();
@ -109,6 +110,10 @@ const useIpcEvents = () => {
dispatch(scriptEnvironmentUpdateEvent(val));
});
const removeGlobalEnvironmentVariablesUpdateListener = ipcRenderer.on('main:global-environment-variables-update', (val) => {
dispatch(globalEnvironmentsUpdateEvent(val));
});
const removeCollectionRenamedListener = ipcRenderer.on('main:collection-renamed', (val) => {
dispatch(collectionRenamedEvent(val));
});
@ -149,8 +154,12 @@ const useIpcEvents = () => {
dispatch(updateCookies(val));
});
const removeGlobalEnvironmentsUpdatesListener = ipcRenderer.on('main:load-global-environments', (val) => {
dispatch(updateGlobalEnvironments(val));
});
const removeSnapshotHydrationListener = ipcRenderer.on('main:hydrate-app-with-ui-state-snapshot', (val) => {
dispatch(hydrateCollectionsWithUiStateSnapshot(val));
dispatch(hydrateCollectionWithUiStateSnapshot(val));
})
return () => {
@ -159,6 +168,7 @@ const useIpcEvents = () => {
removeCollectionAlreadyOpenedListener();
removeDisplayErrorListener();
removeScriptEnvUpdateListener();
removeGlobalEnvironmentVariablesUpdateListener();
removeCollectionRenamedListener();
removeRunFolderEventListener();
removeRunRequestEventListener();
@ -169,6 +179,7 @@ const useIpcEvents = () => {
removePreferencesUpdatesListener();
removeCookieUpdateListener();
removeSystemProxyEnvUpdatesListener();
removeGlobalEnvironmentsUpdatesListener();
removeSnapshotHydrationListener();
};
}, [isElectron]);

View File

@ -13,7 +13,7 @@ import platformLib from 'platform';
import { uuid } from 'utils/common';
const { publicRuntimeConfig } = getConfig();
const posthogApiKey = 'phc_7gtqSrrdZRohiozPMLIacjzgHbUlhalW1Bu16uYijMR';
const posthogApiKey = process.env.NEXT_PUBLIC_POSTHOG_API_KEY;
let posthogClient = null;
const isPlaywrightTestRunning = () => {
@ -60,7 +60,7 @@ const trackStart = () => {
event: 'start',
properties: {
os: platformLib.os.family,
version: '1.31.0'
version: '1.34.2'
}
});
};

View File

@ -3,7 +3,6 @@ import toast from 'react-hot-toast';
import find from 'lodash/find';
import Mousetrap from 'mousetrap';
import { useSelector, useDispatch } from 'react-redux';
import SaveRequest from 'components/RequestPane/SaveRequest';
import EnvironmentSettings from 'components/Environments/EnvironmentSettings';
import NetworkError from 'components/ResponsePane/NetworkError';
import NewRequest from 'components/Sidebar/NewRequest';
@ -20,19 +19,9 @@ export const HotkeysProvider = (props) => {
const collections = useSelector((state) => state.collections.collections);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const isEnvironmentSettingsModalOpen = useSelector((state) => state.app.isEnvironmentSettingsModalOpen);
const [showSaveRequestModal, setShowSaveRequestModal] = useState(false);
const [showEnvSettingsModal, setShowEnvSettingsModal] = useState(false);
const [showNewRequestModal, setShowNewRequestModal] = useState(false);
const getCurrentCollectionItems = () => {
const activeTab = find(tabs, (t) => t.uid === activeTabUid);
if (activeTab) {
const collection = findCollectionByUid(collections, activeTab.collectionUid);
return collection ? collection.items : [];
}
};
const getCurrentCollection = () => {
const activeTab = find(tabs, (t) => t.uid === activeTabUid);
if (activeTab) {
@ -57,9 +46,6 @@ export const HotkeysProvider = (props) => {
dispatch(saveRequest(activeTab.uid, activeTab.collectionUid));
} else if (activeTab.type === 'collection-settings') {
dispatch(saveCollectionRoot(collection.uid));
} else {
// todo: when ephermal requests go live
// setShowSaveRequestModal(true);
}
}
}
@ -218,9 +204,6 @@ export const HotkeysProvider = (props) => {
return (
<HotkeysContext.Provider {...props} value="hotkey">
{showSaveRequestModal && (
<SaveRequest items={getCurrentCollectionItems()} onClose={() => setShowSaveRequestModal(false)} />
)}
{showEnvSettingsModal && (
<EnvironmentSettings collection={getCurrentCollection()} onClose={() => setShowEnvSettingsModal(false)} />
)}

View File

@ -6,6 +6,7 @@ import appReducer from './slices/app';
import collectionsReducer from './slices/collections';
import tabsReducer from './slices/tabs';
import notificationsReducer from './slices/notifications';
import globalEnvironmentsReducer from './slices/global-environments';
const { publicRuntimeConfig } = getConfig();
const isDevEnv = () => {
@ -22,7 +23,8 @@ export const store = configureStore({
app: appReducer,
collections: collectionsReducer,
tabs: tabsReducer,
notifications: notificationsReducer
notifications: notificationsReducer,
globalEnvironments: globalEnvironmentsReducer
},
middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(middleware)
});

View File

@ -44,6 +44,7 @@ import { parsePathParams, parseQueryParams, splitOnFirst } from 'utils/url/index
import { sendCollectionOauth2Request as _sendCollectionOauth2Request } from 'utils/network/index';
import { name } from 'file-loader';
import slash from 'utils/common/slash';
import { getGlobalEnvironmentVariables } from 'utils/collections/index';
import { findCollectionByPathname, findEnvironmentInCollectionByName } from 'utils/collections/index';
export const renameCollection = (newName, collectionUid) => (dispatch, getState) => {
@ -184,6 +185,7 @@ export const saveFolderRoot = (collectionUid, folderUid) => (dispatch, getState)
export const sendCollectionOauth2Request = (collectionUid, itemUid) => (dispatch, getState) => {
const state = getState();
const { globalEnvironments, activeGlobalEnvironmentUid } = state.globalEnvironments;
const collection = findCollectionByUid(state.collections.collections, collectionUid);
return new Promise((resolve, reject) => {
@ -191,11 +193,15 @@ export const sendCollectionOauth2Request = (collectionUid, itemUid) => (dispatch
return reject(new Error('Collection not found'));
}
const collectionCopy = cloneDeep(collection);
let collectionCopy = cloneDeep(collection);
// add selected global env variables to the collection object
const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid });
collectionCopy.globalEnvironmentVariables = globalEnvironmentVariables;
const environment = findEnvironmentInCollection(collectionCopy, collection.activeEnvironmentUid);
_sendCollectionOauth2Request(collection, environment, collectionCopy.runtimeVariables)
_sendCollectionOauth2Request(collectionCopy, environment, collectionCopy.runtimeVariables)
.then((response) => {
if (response?.data?.error) {
toast.error(response?.data?.error);
@ -213,6 +219,7 @@ export const sendCollectionOauth2Request = (collectionUid, itemUid) => (dispatch
export const sendRequest = (item, collectionUid) => (dispatch, getState) => {
const state = getState();
const { globalEnvironments, activeGlobalEnvironmentUid } = state.globalEnvironments;
const collection = findCollectionByUid(state.collections.collections, collectionUid);
return new Promise((resolve, reject) => {
@ -221,7 +228,11 @@ export const sendRequest = (item, collectionUid) => (dispatch, getState) => {
}
const itemCopy = cloneDeep(item || {});
const collectionCopy = cloneDeep(collection);
let collectionCopy = cloneDeep(collection);
// add selected global env variables to the collection object
const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid });
collectionCopy.globalEnvironmentVariables = globalEnvironmentVariables;
const environment = findEnvironmentInCollection(collectionCopy, collectionCopy.activeEnvironmentUid);
sendNetworkRequest(itemCopy, collectionCopy, environment, collectionCopy.runtimeVariables)
@ -286,6 +297,7 @@ export const cancelRunnerExecution = (cancelTokenUid) => (dispatch) => {
export const runCollectionFolder = (collectionUid, folderUid, recursive, delay) => (dispatch, getState) => {
const state = getState();
const { globalEnvironments, activeGlobalEnvironmentUid } = state.globalEnvironments;
const collection = findCollectionByUid(state.collections.collections, collectionUid);
return new Promise((resolve, reject) => {
@ -293,7 +305,12 @@ export const runCollectionFolder = (collectionUid, folderUid, recursive, delay)
return reject(new Error('Collection not found'));
}
const collectionCopy = cloneDeep(collection);
let collectionCopy = cloneDeep(collection);
// add selected global env variables to the collection object
const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid });
collectionCopy.globalEnvironmentVariables = globalEnvironmentVariables;
const folder = findItemInCollection(collectionCopy, folderUid);
if (folderUid && !folder) {
@ -971,17 +988,19 @@ export const selectEnvironment = (environmentUid, collectionUid) => (dispatch, g
}
const collectionCopy = cloneDeep(collection);
if (environmentUid) {
const environment = findEnvironmentInCollection(collectionCopy, environmentUid);
if (environment) {
ipcRenderer.invoke('renderer:update-ui-state-snapshot', { type: 'COLLECTION_ENVIRONMENT', data: { collectionPath: collection?.pathname, environmentName: environment?.name } })
dispatch(_selectEnvironment({ environmentUid, collectionUid }));
resolve();
}
else {
return reject(new Error('Environment not found'));
}
}
const environmentName = environmentUid
? findEnvironmentInCollection(collectionCopy, environmentUid)?.name
: null;
if (environmentUid && !environmentName) {
return reject(new Error('Environment not found'));
}
ipcRenderer.invoke('renderer:update-ui-state-snapshot', { type: 'COLLECTION_ENVIRONMENT', data: { collectionPath: collection?.pathname, environmentName }});
dispatch(_selectEnvironment({ environmentUid, collectionUid }));
resolve();
});
};
@ -1146,7 +1165,7 @@ export const saveCollectionSecurityConfig = (collectionUid, securityConfig) => (
};
export const hydrateCollectionsWithUiStateSnapshot = (payload) => (dispatch, getState) => {
export const hydrateCollectionWithUiStateSnapshot = (payload) => (dispatch, getState) => {
const collectionSnapshotData = payload;
return new Promise((resolve, reject) => {
const state = getState();

View File

@ -0,0 +1,227 @@
import { createSlice } from '@reduxjs/toolkit';
import { stringifyIfNot, uuid } from 'utils/common/index';
import { environmentSchema } from '@usebruno/schema';
import { cloneDeep } from 'lodash';
const initialState = {
globalEnvironments: [],
activeGlobalEnvironmentUid: null
};
export const globalEnvironmentsSlice = createSlice({
name: 'global-environments',
initialState,
reducers: {
updateGlobalEnvironments: (state, action) => {
state.globalEnvironments = action.payload?.globalEnvironments;
state.activeGlobalEnvironmentUid = action.payload?.activeGlobalEnvironmentUid;
},
_addGlobalEnvironment: (state, action) => {
const { name, uid, variables = [] } = action.payload;
if (name?.length) {
state.globalEnvironments.push({
uid,
name,
variables
});
}
},
_saveGlobalEnvironment: (state, action) => {
const { environmentUid: globalEnvironmentUid, variables } = action.payload;
if (globalEnvironmentUid) {
const environment = state.globalEnvironments.find(env => env?.uid == globalEnvironmentUid);
if (environment) {
environment.variables = variables;
}
}
},
_renameGlobalEnvironment: (state, action) => {
const { environmentUid: globalEnvironmentUid, name } = action.payload;
if (globalEnvironmentUid) {
const environment = state.globalEnvironments.find(env => env?.uid == globalEnvironmentUid);
if (environment) {
environment.name = name;
}
}
},
_copyGlobalEnvironment: (state, action) => {
const { name, uid, variables } = action.payload;
if (name?.length && uid) {
state.globalEnvironments.push({
uid,
name,
variables
});
}
},
_selectGlobalEnvironment: (state, action) => {
const { environmentUid: globalEnvironmentUid } = action.payload;
if (globalEnvironmentUid) {
const environment = state.globalEnvironments.find(env => env?.uid == globalEnvironmentUid);
if (environment) {
state.activeGlobalEnvironmentUid = globalEnvironmentUid;
}
} else {
state.activeGlobalEnvironmentUid = null;
}
},
_deleteGlobalEnvironment: (state, action) => {
const { environmentUid: uid } = action.payload;
if (uid) {
state.globalEnvironments = state.globalEnvironments.filter(env => env?.uid !== uid);
if (uid === state.activeGlobalEnvironmentUid) {
state.activeGlobalEnvironmentUid = null;
}
}
}
}
});
export const {
updateGlobalEnvironments,
_addGlobalEnvironment,
_saveGlobalEnvironment,
_renameGlobalEnvironment,
_copyGlobalEnvironment,
_selectGlobalEnvironment,
_deleteGlobalEnvironment
} = globalEnvironmentsSlice.actions;
export const addGlobalEnvironment = ({ name, variables = [] }) => (dispatch, getState) => {
return new Promise((resolve, reject) => {
const uid = uuid();
ipcRenderer
.invoke('renderer:create-global-environment', { name, uid, variables })
.then(() => dispatch(_addGlobalEnvironment({ name, uid, variables })))
.then(resolve)
.catch(reject);
});
};
export const copyGlobalEnvironment = ({ name, environmentUid: baseEnvUid }) => (dispatch, getState) => {
return new Promise((resolve, reject) => {
const state = getState();
const globalEnvironments = state.globalEnvironments.globalEnvironments;
const baseEnv = globalEnvironments?.find(env => env?.uid == baseEnvUid)
const uid = uuid();
ipcRenderer
.invoke('renderer:create-global-environment', { uid, name, variables: baseEnv.variables })
.then(() => dispatch(_copyGlobalEnvironment({ name, uid, variables: baseEnv.variables })))
.then(resolve)
.catch(reject);
});
};
export const renameGlobalEnvironment = ({ name: newName, environmentUid }) => (dispatch, getState) => {
return new Promise((resolve, reject) => {
const state = getState();
const globalEnvironments = state.globalEnvironments.globalEnvironments;
const environment = globalEnvironments?.find(env => env?.uid == environmentUid)
if (!environment) {
return reject(new Error('Environment not found'));
}
environmentSchema
.validate(environment)
.then(() => ipcRenderer.invoke('renderer:rename-global-environment', { name: newName, environmentUid }))
.then(() => dispatch(_renameGlobalEnvironment({ name: newName, environmentUid })))
.then(resolve)
.catch(reject);
});
};
export const saveGlobalEnvironment = ({ variables, environmentUid }) => (dispatch, getState) => {
return new Promise((resolve, reject) => {
const state = getState();
const globalEnvironments = state.globalEnvironments.globalEnvironments;
const environment = globalEnvironments?.find(env => env?.uid == environmentUid);
if (!environment) {
return reject(new Error('Environment not found'));
}
environmentSchema
.validate(environment)
.then(() => ipcRenderer.invoke('renderer:save-global-environment', {
environmentUid,
variables
}))
.then(() => dispatch(_saveGlobalEnvironment({ environmentUid, variables })))
.then(resolve)
.catch((error) => {
reject(error);
});
});
};
export const selectGlobalEnvironment = ({ environmentUid }) => (dispatch, getState) => {
return new Promise((resolve, reject) => {
ipcRenderer
.invoke('renderer:select-global-environment', { environmentUid })
.then(() => dispatch(_selectGlobalEnvironment({ environmentUid })))
.then(resolve)
.catch(reject);
});
};
export const deleteGlobalEnvironment = ({ environmentUid }) => (dispatch, getState) => {
return new Promise((resolve, reject) => {
ipcRenderer
.invoke('renderer:delete-global-environment', { environmentUid })
.then(() => dispatch(_deleteGlobalEnvironment({ environmentUid })))
.then(resolve)
.catch(reject);
});
};
export const globalEnvironmentsUpdateEvent = ({ globalEnvironmentVariables }) => (dispatch, getState) => {
return new Promise((resolve, reject) => {
if (!globalEnvironmentVariables) resolve();
const state = getState();
const globalEnvironments = state?.globalEnvironments?.globalEnvironments || [];
const environmentUid = state?.globalEnvironments?.activeGlobalEnvironmentUid;
const environment = globalEnvironments?.find(env => env?.uid == environmentUid);
if (!environment || !environmentUid) {
return resolve();
}
let variables = cloneDeep(environment?.variables);
// update existing values
variables = variables?.map?.(variable => ({
...variable,
value: stringifyIfNot(globalEnvironmentVariables?.[variable?.name])
}));
// add new env values
Object.entries(globalEnvironmentVariables)?.forEach?.(([key, value]) => {
let isAnExistingVariable = variables?.find(v => v?.name == key)
if (!isAnExistingVariable) {
variables.push({
uid: uuid(),
name: key,
value: stringifyIfNot(value),
type: 'text',
secret: false,
enabled: true
});
}
});
environmentSchema
.validate(environment)
.then(() => ipcRenderer.invoke('renderer:save-global-environment', {
environmentUid,
variables
}))
.then(() => dispatch(_saveGlobalEnvironment({ environmentUid, variables })))
.then(resolve)
.catch((error) => {
reject(error);
});
});
}
export default globalEnvironmentsSlice.reducer;

View File

@ -24,6 +24,117 @@
--color-method-head: rgb(52 52 52);
}
:root,.graphiql-container,.CodeMirror-info,.CodeMirror-lint-tooltip,reach-portal {
/* Required CSS variables after upgrading GraphiQL from v1.5.9 to v2.4.7 */
/* Colors */
--color-primary: 0, 0%, 0% !important;
--color-secondary: 0, 0%, 0% !important;
--color-tertiary: 0, 0%, 0% !important;
--color-info: 0, 0%, 0% !important;
--color-success: 0, 0%, 0% !important;
--color-warning: 0, 0%, 0% !important;
--color-error: 0, 0%, 0% !important;
--color-neutral: 0, 0%, 0% !important;
--color-base: 0, 0%, 100% !important;
/* Color alpha values */
--alpha-secondary: 0.76 !important;
--alpha-tertiary: 0.5 !important;
--alpha-background-heavy: 0.15 !important;
--alpha-background-medium: 0.1 !important;
--alpha-background-light: 0.07 !important;
--font-family: Consolas,Inconsolata,Droid Sans Mono,Monaco,monospace;
--font-family-mono: 'Fira Code', monospace;
--font-size-hint: .75rem;
--font-size-inline-code: .8125rem;
--font-size-body: .8rem;
--font-size-h4: 1.125rem;
--font-size-h3: 1.375rem;
--font-size-h2: 1.8125rem;
--font-weight-regular: 400;
--font-weight-medium: 500;
--line-height: 1.5;
--px-2: 0px;
--px-4: 0px;
--px-6: 2px;
--px-8: 8px;
--px-10: 10px;
--px-12: 12px;
--px-16: 16px;
--px-20: 20px;
--px-24: 24px;
--border-radius-2: 0px !important;
--border-radius-4: 0px !important;
--border-radius-8: 0px !important;
--border-radius-12: 0px !important;
--popover-box-shadow: 0px 0px 1px #000 !important;
--popover-border: none;
--sidebar-width: 60px;
--toolbar-width: 40px;
--session-header-height: 51px
}
/* Required CSS variables after upgrading GraphiQL from v1.5.9 to v2.4.7 */
.graphiql-container, .CodeMirror-info, .CodeMirror-lint-tooltip, reach-portal {
/* General Colors */
--color-primary: 0, 0%, 0% !important;
--color-secondary: 0, 0%, 0% !important;
--color-tertiary: 0, 0%, 0% !important;
--color-info: 0, 0%, 0% !important;
--color-success: 0, 0%, 0% !important;
--color-warning: 0, 0%, 0% !important;
--color-error: 0, 0%, 0% !important;
--color-base: 0, 0%, 100% !important;
--color-neutral: 0, 0%, 60% !important;
/* Color alpha values */
--alpha-secondary: 0.76 !important;
--alpha-tertiary: 0.5 !important;
--alpha-background-heavy: 0.15 !important;
--alpha-background-medium: 0.1 !important;
--alpha-background-light: 0.07 !important;
--font-family: Consolas,Inconsolata,Droid Sans Mono,Monaco,monospace;
--font-family-mono: 'Fira Code', monospace;
--font-size-hint: .75rem;
--font-size-inline-code: .8125rem;
--font-size-body: .9375rem;
--font-size-h4: 1.125rem;
--font-size-h3: 1.375rem;
--font-size-h2: 1.8125rem;
--font-weight-regular: 400;
--font-weight-medium: 500;
--line-height: 1.5;
--px-2: 2px !important;
--px-4: 4px !important;
--px-6: 6px !important;
--px-8: 8px !important;
--px-10: 10px !important;
--px-12: 12px !important;
--px-16: 16px !important;
--px-20: 20px !important;
--px-24: 24px !important;
--border-radius-2: 2px !important;
--border-radius-4: 2px !important;
--border-radius-8: 2px !important;
--border-radius-12: 2px !important;
--popover-box-shadow: 0px 0px 1px #000 !important;
--popover-border: none;
--sidebar-width: 60px;
--toolbar-width: 40px;
--session-header-height: 51px
}
.CodeMirror-dialog {
--px-4: 0px !important;
--px-12: 2px !important;
}
.graphiql-container {
background: transparent !important;
}
html,
body {
margin: 0;

View File

@ -19,16 +19,23 @@ const createContentType = (mode) => {
}
};
/**
* Creates a list of enabled headers for the request, ensuring no duplicate content-type headers.
*
* @param {Object} request - The request object.
* @param {Object[]} headers - The array of header objects, each containing name, value, and enabled properties.
* @returns {Object[]} - An array of enabled headers with normalized names and values.
*/
const createHeaders = (request, headers) => {
const enabledHeaders = headers
.filter((header) => header.enabled)
.map((header) => ({
name: header.name,
name: header.name.toLowerCase(),
value: header.value
}));
const contentType = createContentType(request.body?.mode);
if (contentType !== '') {
if (contentType !== '' && !enabledHeaders.some((header) => header.name === 'content-type')) {
enabledHeaders.push({ name: 'content-type', value: contentType });
}

View File

@ -1,5 +1,5 @@
let CodeMirror;
const SERVER_RENDERED = typeof navigator === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
if (!SERVER_RENDERED) {
CodeMirror = require('codemirror');

View File

@ -12,7 +12,7 @@ import brunoCommon from '@usebruno/common';
const { interpolate } = brunoCommon;
let CodeMirror;
const SERVER_RENDERED = typeof navigator === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
const { get } = require('lodash');
if (!SERVER_RENDERED) {
@ -44,8 +44,11 @@ if (!SERVER_RENDERED) {
const into = document.createElement('div');
const descriptionDiv = document.createElement('div');
descriptionDiv.className = 'info-description';
descriptionDiv.appendChild(document.createTextNode(variableValue));
if (options?.variables?.maskedEnvVariables?.includes(variableName)) {
descriptionDiv.appendChild(document.createTextNode('*****'));
} else {
descriptionDiv.appendChild(document.createTextNode(variableValue));
}
into.appendChild(descriptionDiv);
return into;

View File

@ -6,7 +6,7 @@
*/
let CodeMirror;
const SERVER_RENDERED = typeof navigator === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
if (!SERVER_RENDERED) {
CodeMirror = require('codemirror');

View File

@ -786,6 +786,19 @@ export const getDefaultRequestPaneTab = (item) => {
}
};
export const getGlobalEnvironmentVariables = ({ globalEnvironments, activeGlobalEnvironmentUid }) => {
let variables = {};
const environment = globalEnvironments?.find(env => env?.uid === activeGlobalEnvironmentUid);
if (environment) {
each(environment.variables, (variable) => {
if (variable.name && variable.value && variable.enabled) {
variables[variable.name] = variable.value;
}
});
}
return variables;
};
export const getEnvironmentVariables = (collection) => {
let variables = {};
if (collection) {
@ -802,6 +815,24 @@ export const getEnvironmentVariables = (collection) => {
return variables;
};
export const getEnvironmentVariablesMasked = (collection) => {
// Return an empty array if the collection is invalid or not provided
if (!collection || !collection.activeEnvironmentUid) {
return [];
}
// Find the active environment in the collection
const environment = findEnvironmentInCollection(collection, collection.activeEnvironmentUid);
if (!environment || !environment.variables) {
return [];
}
// Filter the environment variables to get only the masked (secret) ones
return environment.variables
.filter((variable) => variable.name && variable.value && variable.enabled && variable.secret)
.map((variable) => variable.name);
};
const getPathParams = (item) => {
let pathParams = {};
if (item && item.request && item.request.params) {
@ -828,14 +859,24 @@ export const getTotalRequestCountInCollection = (collection) => {
};
export const getAllVariables = (collection, item) => {
if(!collection) return {};
const envVariables = getEnvironmentVariables(collection);
const requestTreePath = getTreePathFromCollectionToItem(collection, item);
let { collectionVariables, folderVariables, requestVariables } = mergeVars(collection, requestTreePath);
const pathParams = getPathParams(item);
const { globalEnvironmentVariables = {} } = collection;
const { processEnvVariables = {}, runtimeVariables = {} } = collection;
const mergedVariables = {
...folderVariables,
...requestVariables,
...runtimeVariables
};
const maskedEnvVariables = getEnvironmentVariablesMasked(collection);
const filteredMaskedEnvVariables = maskedEnvVariables.filter((key) => !(key in mergedVariables));
return {
...globalEnvironmentVariables,
...collectionVariables,
...envVariables,
...folderVariables,
@ -844,6 +885,7 @@ export const getAllVariables = (collection, item) => {
pathParams: {
...pathParams
},
maskedEnvVariables: filteredMaskedEnvVariables,
process: {
env: {
...processEnvVariables

View File

@ -1,7 +1,7 @@
import get from 'lodash/get';
let CodeMirror;
const SERVER_RENDERED = typeof navigator === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
if (!SERVER_RENDERED) {
CodeMirror = require('codemirror');
@ -52,17 +52,33 @@ export class MaskedEditor {
/** Replaces all rendered characters, with the provided character. */
maskContent = () => {
const content = this.editor.getValue();
const lineCount = this.editor.lineCount();
if (lineCount === 0) return;
this.editor.operation(() => {
// Clear previous masked text
this.editor.getAllMarks().forEach((mark) => mark.clear());
// Apply new masked text
for (let i = 0; i < content.length; i++) {
if (content[i] !== '\n') {
const maskedNode = document.createTextNode(this.maskChar);
if (content.length <= 500) {
for (let i = 0; i < content.length; i++) {
if (content[i] !== '\n') {
const maskedNode = document.createTextNode(this.maskChar);
this.editor.markText(
{ line: this.editor.posFromIndex(i).line, ch: this.editor.posFromIndex(i).ch },
{ line: this.editor.posFromIndex(i + 1).line, ch: this.editor.posFromIndex(i + 1).ch },
{ replacedWith: maskedNode, handleMouseEvents: true }
);
}
}
} else {
for (let line = 0; line < lineCount; line++) {
const lineLength = this.editor.getLine(line).length;
const maskedNode = document.createTextNode('*'.repeat(lineLength));
this.editor.markText(
{ line: this.editor.posFromIndex(i).line, ch: this.editor.posFromIndex(i).ch },
{ line: this.editor.posFromIndex(i + 1).line, ch: this.editor.posFromIndex(i + 1).ch },
{ replacedWith: maskedNode, handleMouseEvents: true }
{ line, ch: 0 },
{ line, ch: lineLength },
{ replacedWith: maskedNode, handleMouseEvents: false }
);
}
}

View File

@ -158,3 +158,11 @@ export const humanizeDate = (dateString) => {
day: 'numeric'
});
};
export const generateUidBasedOnHash = (str) => {
const hash = simpleHash(str);
return `${hash}`.padEnd(21, '0');
};
export const stringifyIfNot = v => typeof v === 'string' ? v : String(v);

View File

@ -0,0 +1,24 @@
export const setupPolyfills = () => {
// polyfill required to make react-pdf
if (typeof Promise.withResolvers === "undefined") {
if (typeof window !== 'undefined') {
window.Promise.withResolvers = function () {
let resolve, reject
const promise = new Promise((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
} else {
global.Promise.withResolvers = function () {
let resolve, reject
const promise = new Promise((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
}
}
}

View File

@ -36,6 +36,12 @@ function getQueries(request) {
return queries;
}
/**
* Converts request data to a string based on its content type.
*
* @param {Object} request - The request object containing data and headers.
* @returns {Object} An object containing the data string.
*/
function getDataString(request) {
if (typeof request.data === 'number') {
request.data = request.data.toString();
@ -44,7 +50,13 @@ function getDataString(request) {
const contentType = getContentType(request.headers);
if (contentType && contentType.includes('application/json')) {
return { data: request.data.toString() };
try {
const parsedData = JSON.parse(request.data);
return { data: JSON.stringify(parsedData) };
} catch (error) {
console.error('Failed to parse JSON data:', error);
return { data: request.data.toString() };
}
}
const parsedQueryString = querystring.parse(request.data, { sort: false });

View File

@ -2,7 +2,7 @@ import { forOwn } from 'lodash';
import { convertToCodeMirrorJson } from 'utils/common';
import curlToJson from './curl-to-json';
export const getRequestFromCurlCommand = (curlCommand) => {
export const getRequestFromCurlCommand = (curlCommand, requestType = 'http-request') => {
const parseFormData = (parsedBody) => {
const formData = [];
forOwn(parsedBody, (value, key) => {
@ -12,6 +12,22 @@ export const getRequestFromCurlCommand = (curlCommand) => {
return formData;
};
const parseGraphQL = (text) => {
try {
const graphql = JSON.parse(text);
return {
query: graphql.query,
variables: JSON.stringify(graphql.variables, null, 2)
};
} catch (e) {
return {
query: '',
variables: ''
};
}
};
try {
if (!curlCommand || typeof curlCommand !== 'string' || curlCommand.length === 0) {
return null;
@ -24,6 +40,8 @@ export const getRequestFromCurlCommand = (curlCommand) => {
Object.keys(parsedHeaders).map((key) => ({ name: key, value: parsedHeaders[key], enabled: true }));
const contentType = headers?.find((h) => h.name.toLowerCase() === 'content-type')?.value;
const parsedBody = request.data;
const body = {
mode: 'none',
json: null,
@ -31,11 +49,15 @@ export const getRequestFromCurlCommand = (curlCommand) => {
xml: null,
sparql: null,
multipartForm: null,
formUrlEncoded: null
formUrlEncoded: null,
graphql: null
};
const parsedBody = request.data;
if (parsedBody && contentType && typeof contentType === 'string') {
if (contentType.includes('application/json')) {
if (requestType === 'graphql-request' && (contentType.includes('application/json') || contentType.includes('application/graphql'))) {
body.mode = 'graphql';
body.graphql = parseGraphQL(parsedBody);
} else if (contentType.includes('application/json')) {
body.mode = 'json';
body.json = convertToCodeMirrorJson(parsedBody);
} else if (contentType.includes('text/xml')) {

View File

@ -17,7 +17,7 @@ const readFile = (files) => {
} catch (jsonError) {
// not a valid JSOn, try yaml
try {
const parsedData = jsyaml.load(e.target.result);
const parsedData = jsyaml.load(e.target.result, { schema: jsyaml.CORE_SCHEMA });
resolve(parsedData);
} catch (yamlError) {
console.error('Error parsing the file :', jsonError, yamlError);
@ -60,6 +60,7 @@ const addSuffixToDuplicateName = (item, index, allItems) => {
const regexVariable = new RegExp('{{.*?}}', 'g');
const normalizeVariables = (value) => {
value = value || '';
const variables = value.match(regexVariable) || [];
each(variables, (variable) => {
value = value.replace(variable, variable.replace('_.', '').replaceAll(' ', ''));

View File

@ -271,7 +271,7 @@ const resolveRefs = (spec, components = spec?.components, visitedItems = new Set
// Recursively resolve references in nested objects
for (const prop in spec) {
spec[prop] = resolveRefs(spec[prop], components, visitedItems);
spec[prop] = resolveRefs(spec[prop], components, new Set(visitedItems));
}
return spec;
@ -316,7 +316,7 @@ const getDefaultUrl = (serverObject) => {
url = url.replace(`{${variableName}}`, sub);
});
}
return url.endsWith('/') ? url : `${url}/`;
return url.endsWith('/') ? url.slice(0, -1) : url;
};
const getSecurity = (apiSpec) => {
@ -353,7 +353,7 @@ const openAPIRuntimeExpressionToScript = (expression) => {
return expression;
};
const parseOpenApiCollection = (data) => {
export const parseOpenApiCollection = (data) => {
const brunoCollection = {
name: '',
uid: uuid(),
@ -373,7 +373,7 @@ const parseOpenApiCollection = (data) => {
// Currently parsing of openapi spec is "do your best", that is
// allows "invalid" openapi spec
// assumes v3 if not defined. v2 no supported yet
// Assumes v3 if not defined. v2 is not supported yet
if (collectionData.openapi && !collectionData.openapi.startsWith('3')) {
reject(new BrunoError('Only OpenAPI v3 is supported currently.'));
return;
@ -382,7 +382,28 @@ const parseOpenApiCollection = (data) => {
// TODO what if info.title not defined?
brunoCollection.name = collectionData.info.title;
let servers = collectionData.servers || [];
let baseUrl = servers[0] ? getDefaultUrl(servers[0]) : '';
// Create environments based on the servers
servers.forEach((server, index) => {
let baseUrl = getDefaultUrl(server);
let environmentName = server.description ? server.description : `Environment ${index + 1}`;
brunoCollection.environments.push({
uid: uuid(),
name: environmentName,
variables: [
{
uid: uuid(),
name: 'baseUrl',
value: baseUrl,
type: 'text',
enabled: true,
secret: false
},
]
});
});
let securityConfig = getSecurity(collectionData);
let allRequests = Object.entries(collectionData.paths)
@ -399,7 +420,7 @@ const parseOpenApiCollection = (data) => {
path: path.replace(/{([^}]+)}/g, ':$1'), // Replace placeholders enclosed in curly braces with colons
operationObject: operationObject,
global: {
server: baseUrl,
server: '{{baseUrl}}',
security: securityConfig
}
};

View File

@ -0,0 +1,67 @@
import { parseOpenApiCollection } from './openapi-collection';
import { uuid } from 'utils/common';
jest.mock('utils/common');
describe('openapi importer util functions', () => {
afterEach(jest.clearAllMocks);
it('should convert openapi object to bruno collection correctly', async () => {
const input = {
openapi: '3.0.3',
info: {
title: 'Sample API with Multiple Servers',
description: 'API spec with multiple servers.',
version: '1.0.0'
},
servers: [
{ url: 'https://api.example.com/v1', description: 'Production Server' },
{ url: 'https://staging-api.example.com/v1', description: 'Staging Server' },
{ url: 'http://localhost:3000/v1', description: 'Local Server' }
],
paths: {
'/users': {
get: {
summary: 'Get a list of users',
parameters: [
{ name: 'page', in: 'query', required: false, schema: { type: 'integer' } },
{ name: 'limit', in: 'query', required: false, schema: { type: 'integer' } }
],
responses: {
'200': { description: 'A list of users' }
}
}
}
}
};
const expectedOutput = {
name: 'Sample API with Multiple Servers',
version: '1',
items: [
{
name: 'Get a list of users',
type: 'http-request',
request: {
url: '{{baseUrl}}/users',
method: 'GET',
params: [
{ name: 'page', value: '', enabled: false, type: 'query' },
{ name: 'limit', value: '', enabled: false, type: 'query' }
]
}
}
],
environments: [
{ name: 'Production Server', variables: [{ name: 'baseUrl', value: 'https://api.example.com/v1' }] },
{ name: 'Staging Server', variables: [{ name: 'baseUrl', value: 'https://staging-api.example.com/v1' }] },
{ name: 'Local Server', variables: [{ name: 'baseUrl', value: 'http://localhost:3000/v1' }] }
]
};
const result = await parseOpenApiCollection(input);
expect(result).toMatchObject(expectedOutput);
expect(uuid).toHaveBeenCalledTimes(10);
});
});

View File

@ -97,6 +97,75 @@ const constructUrl = (url) => {
let translationLog = {};
/* struct of translation log
{
[collectionName]: {
script: [index1, index2],
test: [index1, index2]
}
}
*/
const pushTranslationLog = (type, index) => {
if (!translationLog[i.name]) {
translationLog[i.name] = {};
}
if (!translationLog[i.name][type]) {
translationLog[i.name][type] = [];
}
translationLog[i.name][type].push(index + 1);
};
const importScriptsFromEvents = (events, requestObject, options, pushTranslationLog) => {
events.forEach((event) => {
if (event.script && event.script.exec) {
if (event.listen === 'prerequest') {
if (!requestObject.script) {
requestObject.script = {};
}
if (Array.isArray(event.script.exec) && event.script.exec.length > 0) {
requestObject.script.req = event.script.exec
.map((line, index) =>
options.enablePostmanTranslations.enabled
? postmanTranslation(line, () => pushTranslationLog('script', index))
: `// ${line}`
)
.join('\n');
} else if (typeof event.script.exec === 'string') {
requestObject.script.req = options.enablePostmanTranslations.enabled
? postmanTranslation(event.script.exec, () => pushTranslationLog('script', 0))
: `// ${event.script.exec}`;
} else {
console.warn('Unexpected event.script.exec type', typeof event.script.exec);
}
}
if (event.listen === 'test') {
if (!requestObject.tests) {
requestObject.tests = {};
}
if (Array.isArray(event.script.exec) && event.script.exec.length > 0) {
requestObject.tests = event.script.exec
.map((line, index) =>
options.enablePostmanTranslations.enabled
? postmanTranslation(line, () => pushTranslationLog('test', index))
: `// ${line}`
)
.join('\n');
} else if (typeof event.script.exec === 'string') {
requestObject.tests = options.enablePostmanTranslations.enabled
? postmanTranslation(event.script.exec, () => pushTranslationLog('test', 0))
: `// ${event.script.exec}`;
} else {
console.warn('Unexpected event.script.exec type', typeof event.script.exec);
}
}
}
});
};
const importPostmanV2CollectionItem = (brunoParent, item, parentAuth, options) => {
brunoParent.items = brunoParent.items || [];
const folderMap = {};
@ -117,13 +186,35 @@ const importPostmanV2CollectionItem = (brunoParent, item, parentAuth, options) =
uid: uuid(),
name: folderName,
type: 'folder',
items: []
items: [],
root: {
meta: {
name: folderName
},
request: {
auth: {
mode: 'none',
basic: null,
bearer: null,
awsv4: null
},
headers: [],
script: {},
tests: '',
vars: {}
}
}
};
brunoParent.items.push(brunoFolderItem);
folderMap[folderName] = brunoFolderItem;
if (i.item && i.item.length) {
importPostmanV2CollectionItem(brunoFolderItem, i.item, i.auth ?? parentAuth, options);
}
if (i.event) {
importScriptsFromEvents(i.event, brunoFolderItem.root.request, options, pushTranslationLog);
}
brunoParent.items.push(brunoFolderItem);
folderMap[folderName] = brunoFolderItem;
} else {
if (i.request) {
const baseRequestName = i.name;
@ -163,32 +254,14 @@ const importPostmanV2CollectionItem = (brunoParent, item, parentAuth, options) =
docs: i.request.description
}
};
/* struct of translation log
{
[collectionName]: {
script: [index1, index2],
test: [index1, index2]
}
}
*/
// type could be script or test
const pushTranslationLog = (type, index) => {
if (!translationLog[i.name]) {
translationLog[i.name] = {};
}
if (!translationLog[i.name][type]) {
translationLog[i.name][type] = [];
}
translationLog[i.name][type].push(index + 1);
};
if (i.event) {
i.event.forEach((event) => {
if (event.listen === 'prerequest' && event.script && event.script.exec) {
if (!brunoRequestItem.request.script) {
brunoRequestItem.request.script = {};
}
if (Array.isArray(event.script.exec)) {
if (Array.isArray(event.script.exec) && event.script.exec.length > 0) {
brunoRequestItem.request.script.req = event.script.exec
.map((line, index) =>
options.enablePostmanTranslations.enabled
@ -196,17 +269,19 @@ const importPostmanV2CollectionItem = (brunoParent, item, parentAuth, options) =
: `// ${line}`
)
.join('\n');
} else {
} else if (typeof event.script.exec === 'string') {
brunoRequestItem.request.script.req = options.enablePostmanTranslations.enabled
? postmanTranslation(event.script.exec[0], () => pushTranslationLog('script', 0))
: `// ${event.script.exec[0]} `;
? postmanTranslation(event.script.exec, () => pushTranslationLog('script', 0))
: `// ${event.script.exec}`;
} else {
console.warn('Unexpected event.script.exec type', typeof event.script.exec);
}
}
if (event.listen === 'test' && event.script && event.script.exec) {
if (!brunoRequestItem.request.tests) {
brunoRequestItem.request.tests = {};
}
if (Array.isArray(event.script.exec)) {
if (Array.isArray(event.script.exec) && event.script.exec.length > 0) {
brunoRequestItem.request.tests = event.script.exec
.map((line, index) =>
options.enablePostmanTranslations.enabled
@ -214,10 +289,12 @@ const importPostmanV2CollectionItem = (brunoParent, item, parentAuth, options) =
: `// ${line}`
)
.join('\n');
} else {
} else if (typeof event.script.exec === 'string') {
brunoRequestItem.request.tests = options.enablePostmanTranslations.enabled
? postmanTranslation(event.script.exec[0], () => pushTranslationLog('test', 0))
: `// ${event.script.exec[0]} `;
? postmanTranslation(event.script.exec, () => pushTranslationLog('test', 0))
: `// ${event.script.exec}`;
} else {
console.warn('Unexpected event.script.exec type', typeof event.script.exec);
}
}
});
@ -393,9 +470,30 @@ const importPostmanV2Collection = (collection, options) => {
uid: uuid(),
version: '1',
items: [],
environments: []
environments: [],
root: {
meta: {
name: collection.info.name
},
request: {
auth: {
mode: 'none',
basic: null,
bearer: null,
awsv4: null
},
headers: [],
script: {},
tests: '',
vars: {}
}
}
};
if (collection.event) {
importScriptsFromEvents(collection.event, brunoCollection.root.request, options, pushTranslationLog);
}
importPostmanV2CollectionItem(brunoCollection, collection.item, collection.auth, options);
return brunoCollection;

View File

@ -11,6 +11,9 @@ describe('postmanTranslation function', () => {
pm.collectionVariables.set('key', 'value');
const data = pm.response.json();
pm.expect(pm.environment.has('key')).to.be.true;
postman.setEnvironmentVariable('key', 'value');
postman.getEnvironmentVariable('key');
postman.clearEnvironmentVariable('key');
`;
const expectedOutput = `
bru.getEnvVar('key');
@ -21,6 +24,9 @@ describe('postmanTranslation function', () => {
bru.setVar('key', 'value');
const data = res.getBody();
expect(bru.getEnvVar('key') !== undefined && bru.getEnvVar('key') !== null).to.be.true;
bru.setEnvVar('key', 'value');
bru.getEnvVar('key');
bru.deleteEnvVar('key');
`;
expect(postmanTranslation(inputScript)).toBe(expectedOutput);
});
@ -151,3 +157,13 @@ test('should handle response commands', () => {
`;
expect(postmanTranslation(inputScript)).toBe(expectedOutput);
});
test('should handle tests object', () => {
const inputScript = `
tests['Status code is 200'] = responseCode.code === 200;
`;
const expectedOutput = `
test("Status code is 200", function() { expect(Boolean(responseCode.code === 200)).to.be.true; });
`;
expect(postmanTranslation(inputScript)).toBe(expectedOutput);
});

View File

@ -5,6 +5,8 @@ const replacements = {
'pm\\.variables\\.set\\(': 'bru.setVar(',
'pm\\.collectionVariables\\.get\\(': 'bru.getVar(',
'pm\\.collectionVariables\\.set\\(': 'bru.setVar(',
'pm\\.collectionVariables\\.has\\(': 'bru.hasVar(',
'pm\\.collectionVariables\\.unset\\(': 'bru.deleteVar(',
'pm\\.setNextRequest\\(': 'bru.setNextRequest(',
'pm\\.test\\(': 'test(',
'pm.response.to.have\\.status\\(': 'expect(res.getStatus()).to.equal(',
@ -15,7 +17,13 @@ const replacements = {
'pm\\.response\\.code': 'res.getStatus()',
'pm\\.response\\.text\\(': 'res.getBody()?.toString(',
'pm\\.expect\\.fail\\(': 'expect.fail(',
'pm\\.response\\.responseTime': 'res.getResponseTime()'
'pm\\.response\\.responseTime': 'res.getResponseTime()',
'pm\\.environment\\.name': 'bru.getEnvName()',
"tests\\['([^']+)'\\]\\s*=\\s*([^;]+);": 'test("$1", function() { expect(Boolean($2)).to.be.true; });',
// deprecated translations
'postman\\.setEnvironmentVariable\\(': 'bru.setEnvVar(',
'postman\\.getEnvironmentVariable\\(': 'bru.getEnvVar(',
'postman\\.clearEnvironmentVariable\\(': 'bru.deleteEnvVar(',
};
const extendedReplacements = Object.keys(replacements).reduce((acc, key) => {

View File

@ -108,12 +108,13 @@ export const isValidUrl = (url) => {
}
};
export const interpolateUrl = ({ url, envVars, runtimeVariables, processEnvVars }) => {
export const interpolateUrl = ({ url, globalEnvironmentVariables = {}, envVars, runtimeVariables, processEnvVars }) => {
if (!url || !url.length || typeof url !== 'string') {
return;
}
return interpolate(url, {
...globalEnvironmentVariables,
...envVars,
...runtimeVariables,
process: {

View File

@ -24,12 +24,12 @@
"package.json"
],
"dependencies": {
"@aws-sdk/credential-providers": "3.525.0",
"@aws-sdk/credential-providers": "3.658.1",
"@usebruno/common": "0.1.0",
"@usebruno/js": "0.12.0",
"@usebruno/lang": "0.12.0",
"aws4-axios": "^3.3.0",
"axios": "^1.5.1",
"axios": "1.7.5",
"chai": "^4.3.7",
"chalk": "^3.0.0",
"decomment": "^0.9.5",
@ -37,12 +37,12 @@
"fs-extra": "^10.1.0",
"http-proxy-agent": "^7.0.0",
"https-proxy-agent": "^7.0.2",
"inquirer": "^9.1.4",
"json-bigint": "^1.0.0",
"lodash": "^4.17.21",
"qs": "^6.11.0",
"socks-proxy-agent": "^8.0.2",
"vm2": "^3.9.13",
"tough-cookie": "^4.1.3",
"@usebruno/vm2": "^3.9.13",
"xmlbuilder": "^15.1.1",
"yargs": "^17.6.2"
}

View File

@ -211,6 +211,11 @@ const builder = async (yargs) => {
description:
'The specified custom CA certificate (--cacert) will be used exclusively and the default truststore is ignored, if this option is specified. Evaluated in combination with "--cacert" only.'
})
.option('disable-cookies', {
type: 'boolean',
default: false,
description: 'Automatically save and sent cookies with requests'
})
.option('env', {
describe: 'Environment variables',
type: 'string'
@ -259,10 +264,30 @@ const builder = async (yargs) => {
type: 'boolean',
description: 'Stop execution after a failure of a request, test, or assertion'
})
.option('reporter-skip-all-headers', {
type: 'boolean',
description: 'Omit headers from the reporter output',
default: false
})
.option('reporter-skip-headers', {
type: 'array',
description: 'Skip specific headers from the reporter output',
default: []
})
.option('client-cert-config', {
type: 'string',
description: 'Path to the Client certificate config file used for securing the connection in the request'
})
.example('$0 run request.bru', 'Run a request')
.example('$0 run request.bru --env local', 'Run a request with the environment set to local')
.example('$0 run folder', 'Run all requests in a folder')
.example('$0 run folder -r', 'Run all requests in a folder recursively')
.example('$0 run --reporter-skip-all-headers', 'Run all requests in a folder recursively with omitted headers from the reporter output')
.example(
'$0 run --reporter-skip-headers "Authorization"',
'Run all requests in a folder recursively with skipped headers from the reporter output'
)
.example(
'$0 run request.bru --env local --env-var secret=xxx',
'Run a request with the environment set to local and overwrite the variable secret with value xxx'
@ -292,7 +317,8 @@ const builder = async (yargs) => {
.example(
'$0 run folder --cacert myCustomCA.pem --ignore-truststore',
'Use a custom CA certificate exclusively when validating the peers of the requests in the specified folder.'
);
)
.example('$0 run --client-cert-config client-cert-config.json', 'Run a request with Client certificate configurations');
};
const handler = async function (argv) {
@ -301,6 +327,7 @@ const handler = async function (argv) {
filename,
cacert,
ignoreTruststore,
disableCookies,
env,
envVar,
insecure,
@ -312,7 +339,10 @@ const handler = async function (argv) {
reporterHtml,
sandbox,
testsOnly,
bail
bail,
reporterSkipAllHeaders,
reporterSkipHeaders,
clientCertConfig
} = argv;
const collectionPath = process.cwd();
@ -330,6 +360,41 @@ const handler = async function (argv) {
const brunoConfig = JSON.parse(brunoConfigFile);
const collectionRoot = getCollectionRoot(collectionPath);
if (clientCertConfig) {
try {
const clientCertConfigExists = await exists(clientCertConfig);
if (!clientCertConfigExists) {
console.error(chalk.red(`Client Certificate Config file "${clientCertConfig}" does not exist.`));
process.exit(constants.EXIT_STATUS.ERROR_FILE_NOT_FOUND);
}
const clientCertConfigFileContent = fs.readFileSync(clientCertConfig, 'utf8');
let clientCertConfigJson;
try {
clientCertConfigJson = JSON.parse(clientCertConfigFileContent);
} catch (err) {
console.error(chalk.red(`Failed to parse Client Certificate Config JSON: ${err.message}`));
process.exit(constants.EXIT_STATUS.ERROR_INVALID_JSON);
}
if (clientCertConfigJson?.enabled && Array.isArray(clientCertConfigJson?.certs)) {
if (brunoConfig.clientCertificates) {
brunoConfig.clientCertificates.certs.push(...clientCertConfigJson.certs);
} else {
brunoConfig.clientCertificates = { certs: clientCertConfigJson.certs };
}
console.log(chalk.green(`Client certificates has been added`));
} else {
console.warn(chalk.yellow(`Client certificate configuration is enabled, but it either contains no valid "certs" array or the added configuration has been set to false`));
}
} catch (err) {
console.error(chalk.red(`Unexpected error: ${err.message}`));
process.exit(constants.EXIT_STATUS.ERROR_UNKNOWN);
}
}
if (filename && filename.length) {
const pathExists = await exists(filename);
if (!pathExists) {
@ -392,6 +457,9 @@ const handler = async function (argv) {
if (insecure) {
options['insecure'] = true;
}
if (disableCookies) {
options['disableCookies'] = true;
}
if (cacert && cacert.length) {
if (insecure) {
console.error(chalk.red(`Ignoring the cacert option since insecure connections are enabled`));
@ -525,6 +593,35 @@ const handler = async function (argv) {
suitename: bruFilepath.replace('.bru', '')
});
if (reporterSkipAllHeaders) {
results.forEach((result) => {
result.request.headers = {};
result.response.headers = {};
});
}
const deleteHeaderIfExists = (headers, header) => {
if (headers && headers[header]) {
delete headers[header];
}
};
if (reporterSkipHeaders?.length) {
results.forEach((result) => {
if (result.request?.headers) {
reporterSkipHeaders.forEach((header) => {
deleteHeaderIfExists(result.request.headers, header);
});
}
if (result.response?.headers) {
reporterSkipHeaders.forEach((header) => {
deleteHeaderIfExists(result.response.headers, header);
});
}
});
}
// bail if option is set and there is a failure
if (bail) {
const requestFailure = result?.error;

View File

@ -74,17 +74,17 @@ const interpolateVars = (request, envVars = {}, runtimeVariables = {}, processEn
} else if (contentType === 'application/x-www-form-urlencoded') {
if (typeof request.data === 'object') {
try {
let parsed = JSON.stringify(request.data);
parsed = _interpolate(parsed);
request.data = JSON.parse(parsed);
forOwn(request?.data, (value, key) => {
request.data[key] = _interpolate(value);
});
} catch (err) {}
}
} else if (contentType === 'multipart/form-data') {
if (typeof request.data === 'object' && !(request?.data instanceof FormData)) {
try {
let parsed = JSON.stringify(request.data);
parsed = _interpolate(parsed);
request.data = JSON.parse(parsed);
forOwn(request?.data, (value, key) => {
request.data[key] = _interpolate(value);
});
} catch (err) {}
}
} else {

View File

@ -76,17 +76,17 @@ const prepareRequest = (request, collectionRoot) => {
const password = get(request, 'auth.wsse.password', '');
const ts = new Date().toISOString();
const nonce = crypto.randomBytes(16).toString('base64');
const nonce = crypto.randomBytes(16).toString('hex');
// Create the password digest using SHA-256
const hash = crypto.createHash('sha256');
// Create the password digest using SHA-1 as required for WSSE
const hash = crypto.createHash('sha1');
hash.update(nonce + ts + password);
const digest = hash.digest('base64');
const digest = Buffer.from(hash.digest('hex').toString('utf8')).toString('base64');
// Construct the WSSE header
axiosRequest.headers[
'X-WSSE'
] = `UsernameToken Username="${username}", PasswordDigest="${digest}", Created="${ts}", Nonce="${nonce}"`;
] = `UsernameToken Username="${username}", PasswordDigest="${digest}", Nonce="${nonce}", Created="${ts}"`;
}
}

View File

@ -20,6 +20,7 @@ const { addAwsV4Interceptor, resolveAwsV4Credentials } = require('./awsv4auth-he
const { shouldUseProxy, PatchedHttpsProxyAgent } = require('../utils/proxy-util');
const path = require('path');
const { createFormData } = require('../utils/common');
const { getCookieStringForUrl, saveCookies, shouldUseCookies } = require('../utils/cookies');
const protocolRegex = /^([-+\w]{1,25})(:?\/\/|:)/;
const onConsoleLog = (type, args) => {
@ -178,6 +179,14 @@ const runSingleRequest = async function (
});
}
//set cookies if enabled
if (!options.disableCookies) {
const cookieString = getCookieStringForUrl(request.url);
if (cookieString && typeof cookieString === 'string' && cookieString.length) {
request.headers['cookie'] = cookieString;
}
}
// stringify the request url encoded params
if (request.headers['content-type'] === 'application/x-www-form-urlencoded') {
request.data = qs.stringify(request.data);
@ -220,6 +229,11 @@ const runSingleRequest = async function (
// Prevents the duration on leaking to the actual result
responseTime = response.headers.get('request-duration');
response.headers.delete('request-duration');
//save cookies if enabled
if (!options.disableCookies) {
saveCookies(request.url, response.headers);
}
} catch (err) {
if (err?.response) {
response = err.response;
@ -246,7 +260,7 @@ const runSingleRequest = async function (
data: null,
responseTime: 0
},
error: err.message,
error: err?.message || err?.errors?.map(e => e?.message)?.at(0) || err?.code || 'Request Failed!',
assertionResults: [],
testResults: [],
nextRequestName: nextRequestName

View File

@ -0,0 +1,100 @@
const { Cookie, CookieJar } = require('tough-cookie');
const each = require('lodash/each');
const cookieJar = new CookieJar();
const addCookieToJar = (setCookieHeader, requestUrl) => {
const cookie = Cookie.parse(setCookieHeader, { loose: true });
cookieJar.setCookieSync(cookie, requestUrl, {
ignoreError: true // silently ignore things like parse errors and invalid domains
});
};
const getCookiesForUrl = (url) => {
return cookieJar.getCookiesSync(url);
};
const getCookieStringForUrl = (url) => {
const cookies = getCookiesForUrl(url);
if (!Array.isArray(cookies) || !cookies.length) {
return '';
}
const validCookies = cookies.filter((cookie) => !cookie.expires || cookie.expires > Date.now());
return validCookies.map((cookie) => cookie.cookieString()).join('; ');
};
const getDomainsWithCookies = () => {
return new Promise((resolve, reject) => {
const domainCookieMap = {};
cookieJar.store.getAllCookies((err, cookies) => {
if (err) {
return reject(err);
}
cookies.forEach((cookie) => {
if (!domainCookieMap[cookie.domain]) {
domainCookieMap[cookie.domain] = [cookie];
} else {
domainCookieMap[cookie.domain].push(cookie);
}
});
const domains = Object.keys(domainCookieMap);
const domainsWithCookies = [];
each(domains, (domain) => {
const cookies = domainCookieMap[domain];
const validCookies = cookies.filter((cookie) => !cookie.expires || cookie.expires > Date.now());
if (validCookies.length) {
domainsWithCookies.push({
domain,
cookies: validCookies,
cookieString: validCookies.map((cookie) => cookie.cookieString()).join('; ')
});
}
});
resolve(domainsWithCookies);
});
});
};
const deleteCookiesForDomain = (domain) => {
return new Promise((resolve, reject) => {
cookieJar.store.removeCookies(domain, null, (err) => {
if (err) {
return reject(err);
}
return resolve();
});
});
};
const saveCookies = (url, headers) => {
let setCookieHeaders = [];
if (headers['set-cookie']) {
setCookieHeaders = Array.isArray(headers['set-cookie'])
? headers['set-cookie']
: [headers['set-cookie']];
for (let setCookieHeader of setCookieHeaders) {
if (typeof setCookieHeader === 'string' && setCookieHeader.length) {
addCookieToJar(setCookieHeader, url);
}
}
}
}
module.exports = {
addCookieToJar,
getCookiesForUrl,
getCookieStringForUrl,
getDomainsWithCookies,
deleteCookiesForDomain,
saveCookies
};

View File

@ -22,13 +22,13 @@
"@rollup/plugin-commonjs": "^23.0.2",
"@rollup/plugin-node-resolve": "^15.0.1",
"@rollup/plugin-typescript": "^9.0.2",
"rollup":"3.29.4",
"rollup":"3.29.5",
"rollup-plugin-dts": "^5.0.0",
"rollup-plugin-peer-deps-external": "^2.2.4",
"rollup-plugin-terser": "^7.0.2",
"typescript": "^4.8.4"
},
"overrides": {
"rollup":"3.29.4"
"rollup":"3.29.5"
}
}

View File

@ -1,5 +1,5 @@
{
"version": "v1.31.0",
"version": "v1.34.2",
"name": "bruno",
"description": "Opensource API Client for Exploring and Testing APIs",
"homepage": "https://www.usebruno.com",
@ -22,7 +22,7 @@
"modulePaths": ["node_modules"]
},
"dependencies": {
"@aws-sdk/credential-providers": "3.525.0",
"@aws-sdk/credential-providers": "3.658.1",
"@usebruno/common": "0.1.0",
"@usebruno/js": "0.12.0",
"@usebruno/lang": "0.12.0",
@ -30,7 +30,7 @@
"@usebruno/schema": "0.7.0",
"about-window": "^1.15.2",
"aws4-axios": "^3.3.0",
"axios": "^1.5.1",
"axios": "1.7.5",
"chai": "^4.3.7",
"chokidar": "^3.5.3",
"content-disposition": "^0.5.4",
@ -48,7 +48,6 @@
"iconv-lite": "^0.6.3",
"is-valid-path": "^0.1.1",
"js-yaml": "^4.1.0",
"json-bigint": "^1.0.0",
"lodash": "^4.17.21",
"mime-types": "^2.1.35",
"nanoid": "3.3.4",
@ -56,7 +55,7 @@
"socks-proxy-agent": "^8.0.2",
"tough-cookie": "^4.1.3",
"uuid": "^9.0.0",
"vm2": "^3.9.13",
"@usebruno/vm2": "^3.9.13",
"yup": "^0.32.11"
},
"optionalDependencies": {
@ -64,6 +63,6 @@
},
"devDependencies": {
"electron": "31.2.1",
"electron-builder": "23.0.2"
"electron-builder": "25.1.8"
}
}

View File

@ -2,7 +2,7 @@ const _ = require('lodash');
const fs = require('fs');
const path = require('path');
const chokidar = require('chokidar');
const { hasBruExtension } = require('../utils/filesystem');
const { hasBruExtension, isWSLPath, normalizeAndResolvePath, normalizeWslPath } = require('../utils/filesystem');
const { bruToEnvJson, bruToJson, collectionBruToJson } = require('../bru');
const { dotenvToJson } = require('@usebruno/lang');
@ -445,11 +445,11 @@ class Watcher {
ignoreInitial: false,
usePolling: watchPath.startsWith('\\\\') || forcePolling ? true : false,
ignored: (filepath) => {
const normalizedPath = filepath.replace(/\\/g, '/');
const normalizedPath = isWSLPath(filepath) ? normalizeWslPath(filepath) : normalizeAndResolvePath(filepath);
const relativePath = path.relative(watchPath, normalizedPath);
return ignores.some((ignorePattern) => {
const normalizedIgnorePattern = ignorePattern.replace(/\\/g, '/');
const normalizedIgnorePattern = isWSLPath(ignorePattern) ? normalizeWslPath(ignorePattern) : ignorePattern.replace(/\\/g, '/');
return relativePath === normalizedIgnorePattern || relativePath.startsWith(normalizedIgnorePattern);
});
},

View File

@ -23,6 +23,7 @@ const registerPreferencesIpc = require('./ipc/preferences');
const Watcher = require('./app/watcher');
const { loadWindowState, saveBounds, saveMaximized } = require('./utils/window');
const registerNotificationsIpc = require('./ipc/notifications');
const registerGlobalEnvironmentsIpc = require('./ipc/global-environments');
const lastOpenedCollections = new LastOpenedCollections();
@ -143,6 +144,7 @@ app.on('ready', async () => {
// register all ipc handlers
registerNetworkIpc(mainWindow);
registerGlobalEnvironmentsIpc(mainWindow);
registerCollectionsIpc(mainWindow, watcher, lastOpenedCollections);
registerPreferencesIpc(mainWindow, watcher, lastOpenedCollections);
registerNotificationsIpc(mainWindow, watcher);

View File

@ -20,7 +20,8 @@ const {
normalizeWslPath,
normalizeAndResolvePath,
safeToRename,
isWindowsOS
isWindowsOS,
isValidFilename
} = require('../utils/filesystem');
const { openCollectionDialog } = require('../app/collections');
const { generateUidBasedOnHash, stringifyJson, safeParseJSON, safeStringifyJSON } = require('../utils/common');
@ -28,11 +29,11 @@ const { moveRequestUid, deleteRequestUid } = require('../cache/requestUids');
const { deleteCookiesForDomain, getDomainsWithCookies } = require('../utils/cookies');
const EnvironmentSecretsStore = require('../store/env-secrets');
const CollectionSecurityStore = require('../store/collection-security');
const UiStateSnapshot = require('../store/ui-state-snapshot');
const UiStateSnapshotStore = require('../store/ui-state-snapshot');
const environmentSecretsStore = new EnvironmentSecretsStore();
const collectionSecurityStore = new CollectionSecurityStore();
const UiStateSnapshotStore = new UiStateSnapshot();
const uiStateSnapshotStore = new UiStateSnapshotStore();
const envHasSecrets = (environment = {}) => {
const secrets = _.filter(environment.variables, (v) => v.secret);
@ -68,14 +69,20 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
try {
const dirPath = path.join(collectionLocation, collectionFolderName);
if (fs.existsSync(dirPath)) {
throw new Error(`collection: ${dirPath} already exists`);
const files = fs.readdirSync(dirPath);
if (files.length > 0) {
throw new Error(`collection: ${dirPath} already exists and is not empty`);
}
}
if (!isValidPathname(dirPath)) {
throw new Error(`collection: invalid pathname - ${dir}`);
}
await createDirectory(dirPath);
if (!fs.existsSync(dirPath)) {
await createDirectory(dirPath);
}
const uid = generateUidBasedOnHash(dirPath);
const brunoConfig = {
@ -198,7 +205,9 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
if (fs.existsSync(pathname)) {
throw new Error(`path: ${pathname} already exists`);
}
if (!isValidFilename(request.name)) {
throw new Error(`path: ${request.name}.bru is not a valid filename`);
}
const content = jsonToBru(request);
await writeFile(pathname, content);
} catch (error) {
@ -372,6 +381,10 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
throw new Error(`path: ${oldPath} is not a bru file`);
}
if (!isValidFilename(newName)) {
throw new Error(`path: ${newName} is not a valid filename`);
}
// update name in file and save new copy, then delete old copy
const data = await fs.promises.readFile(oldPath, 'utf8'); // Use async read
const jsonData = bruToJson(data);
@ -711,7 +724,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
ipcMain.handle('renderer:update-ui-state-snapshot', (event, { type, data }) => {
try {
UiStateSnapshotStore.update({ type, data });
uiStateSnapshotStore.update({ type, data });
} catch (error) {
throw new Error(error.message);
}

View File

@ -0,0 +1,50 @@
require('dotenv').config();
const { ipcMain } = require('electron');
const { globalEnvironmentsStore } = require('../store/global-environments');
const registerGlobalEnvironmentsIpc = (mainWindow) => {
// GLOBAL ENVIRONMENTS
ipcMain.handle('renderer:create-global-environment', async (event, { uid, name, variables }) => {
try {
globalEnvironmentsStore.addGlobalEnvironment({ uid, name, variables });
} catch (error) {
return Promise.reject(error);
}
});
ipcMain.handle('renderer:save-global-environment', async (event, { environmentUid, variables }) => {
try {
globalEnvironmentsStore.saveGlobalEnvironment({ environmentUid, variables })
} catch (error) {
return Promise.reject(error);
}
});
ipcMain.handle('renderer:rename-global-environment', async (event, { environmentUid, name }) => {
try {
globalEnvironmentsStore.renameGlobalEnvironment({ environmentUid, name });
} catch (error) {
return Promise.reject(error);
}
});
ipcMain.handle('renderer:delete-global-environment', async (event, { environmentUid }) => {
try {
globalEnvironmentsStore.deleteGlobalEnvironment({ environmentUid });
} catch (error) {
return Promise.reject(error);
}
});
ipcMain.handle('renderer:select-global-environment', async (event, { environmentUid }) => {
try {
globalEnvironmentsStore.selectGlobalEnvironment({ environmentUid });
} catch (error) {
return Promise.reject(error);
}
});
};
module.exports = registerGlobalEnvironmentsIpc;

View File

@ -233,6 +233,10 @@ const configureRequest = async (
);
request.httpAgent = new HttpProxyAgent(proxyUri);
}
} else {
request.httpsAgent = new https.Agent({
...httpsAgentRequestFields
});
}
} else if (proxyMode === 'system') {
const { http_proxy, https_proxy, no_proxy } = preferencesUtil.getSystemProxyEnvVariables();
@ -257,6 +261,10 @@ const configureRequest = async (
} catch (error) {
throw new Error('Invalid system https_proxy');
}
} else {
request.httpsAgent = new https.Agent({
...httpsAgentRequestFields
});
}
} else if (Object.keys(httpsAgentRequestFields).length > 0) {
request.httpsAgent = new https.Agent({
@ -409,6 +417,10 @@ const registerNetworkIpc = (mainWindow) => {
requestUid,
collectionUid
});
mainWindow.webContents.send('main:global-environment-variables-update', {
globalEnvironmentVariables: scriptResult.globalEnvironmentVariables
});
}
// interpolate variables inside request
@ -469,6 +481,10 @@ const registerNetworkIpc = (mainWindow) => {
requestUid,
collectionUid
});
mainWindow.webContents.send('main:global-environment-variables-update', {
globalEnvironmentVariables: result.globalEnvironmentVariables
});
}
if (result?.error) {
@ -504,6 +520,10 @@ const registerNetworkIpc = (mainWindow) => {
requestUid,
collectionUid
});
mainWindow.webContents.send('main:global-environment-variables-update', {
globalEnvironmentVariables: scriptResult.globalEnvironmentVariables
});
}
return scriptResult;
};
@ -691,6 +711,10 @@ const registerNetworkIpc = (mainWindow) => {
requestUid,
collectionUid
});
mainWindow.webContents.send('main:global-environment-variables-update', {
globalEnvironmentVariables: testResults.globalEnvironmentVariables
});
}
return {
@ -717,7 +741,7 @@ const registerNetworkIpc = (mainWindow) => {
const collectionRoot = get(collection, 'root', {});
const _request = collectionRoot?.request;
const request = prepareCollectionRequest(_request, collectionRoot, collectionPath);
const request = prepareCollectionRequest(_request, collection, collectionPath);
request.__bruno__executionMode = 'standalone';
const envVars = getEnvVars(environment);
const processEnvVars = getProcessEnvVars(collectionUid);
@ -1160,6 +1184,10 @@ const registerNetworkIpc = (mainWindow) => {
runtimeVariables: testResults.runtimeVariables,
collectionUid
});
mainWindow.webContents.send('main:global-environment-variables-update', {
globalEnvironmentVariables: testResults.globalEnvironmentVariables
});
}
} catch (error) {
mainWindow.webContents.send('main:run-folder-event', {

View File

@ -14,6 +14,7 @@ const getContentType = (headers = {}) => {
};
const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, processEnvVars = {}) => {
const globalEnvironmentVariables = request?.globalEnvironmentVariables || {};
const collectionVariables = request?.collectionVariables || {};
const folderVariables = request?.folderVariables || {};
const requestVariables = request?.requestVariables || {};
@ -39,6 +40,7 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc
// runtimeVariables take precedence over envVars
const combinedVars = {
...globalEnvironmentVariables,
...collectionVariables,
...envVariables,
...folderVariables,
@ -78,17 +80,17 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc
} else if (contentType === 'application/x-www-form-urlencoded') {
if (typeof request.data === 'object') {
try {
let parsed = JSON.stringify(request.data);
parsed = _interpolate(parsed);
request.data = JSON.parse(parsed);
forOwn(request?.data, (value, key) => {
request.data[key] = _interpolate(value);
});
} catch (err) {}
}
} else if (contentType === 'multipart/form-data') {
if (typeof request.data === 'object' && !(request.data instanceof FormData)) {
try {
let parsed = JSON.stringify(request.data);
parsed = _interpolate(parsed);
request.data = JSON.parse(parsed);
forOwn(request?.data, (value, key) => {
request.data[key] = _interpolate(value);
});
} catch (err) {}
}
} else {

View File

@ -1,7 +1,8 @@
const { get, each } = require('lodash');
const { setAuthHeaders } = require('./prepare-request');
const prepareCollectionRequest = (request, collectionRoot) => {
const prepareCollectionRequest = (request, collection) => {
const collectionRoot = get(collection, 'root', {});
const headers = {};
let contentTypeDefined = false;
let url = request.url;
@ -34,6 +35,8 @@ const prepareCollectionRequest = (request, collectionRoot) => {
};
axiosRequest = setAuthHeaders(axiosRequest, request, collectionRoot);
axiosRequest.globalEnvironmentVariables = collection?.globalEnvironmentVariables;
if (request.script) {
axiosRequest.script = request.script;

Some files were not shown because too many files have changed in this diff Show More