Compare commits

..

89 Commits

Author SHA1 Message Date
Anoop M D
de603ecbfb chore: deps update - axios, jsonpath-plus, @aws-sdk/credential-providers, express 2025-03-13 01:17:28 +05:30
Pragadesh-45
6a85635c49 Fix: Inconsistent JSON parsing and formatting in res.body and Res-preview (#4103)
* Fix: Revert selective JSON parsing where string response is not parsed

- Revert "Merge pull request #3706 from Pragadesh-45/fix/response-format-updates"
  - e897dc1eb0
- Revert "Merge pull request #3676 from pooja-bruno/fix/string-json-response"
  - 1f2bee1f90

* Fix: Revert interpreting Assert RHS-value wrapped in quotes literally

- Revert "Merge pull request #3806 from Pragadesh-45/fix/handle-assert-results"
  - 63d3cb380d
- Revert "Merge pull request #3805 from Pragadesh-45/fix/handle-assert-results"
  - 6abd063749

* Fix: Inconsistent JSON formatting in preview when encoded value is a string

* Fix: Prettify JSON for Res-preview without parsing to avoid JS specific roundings

* Fix(testbench): req.body is always Buffer after the binary req body related changes

* Added `/api/echo/custom` where response can be configured using request itself

* Added tests for validating Assert and Response-preview

Co-authored-by: Pragadesh-45 <temporaryg7904@gmail.com>

* Handle char-encoding in Response-preview and added more tests

* Updated API endpoint in tests to use httpfaker api

* QuickJS (Safe Mode) exec logic to handle template literals similar to Developer Mode

* Safe Mode bru.runRequest to return statusText similar to Developer Mode

---------

Co-authored-by: ramki-bruno <ramki@usebruno.com>
Co-authored-by: Anoop M D <anoop.md1421@gmail.com>
2025-03-13 00:49:57 +05:30
pooja-bruno
0fbbe8a996 feat: show response errors while keeping response preview intact (#4082)
Co-authored-by: Anoop M D <anoop.md1421@gmail.com>
2025-03-10 19:51:48 +05:30
naman-bruno
4ff4e3b732 Added new postman v2 schema urls 2025-03-10 17:08:29 +05:30
Anoop M D
7c65317b07 chore: improved cookie styling ux 2025-03-08 23:09:46 +05:30
ramki-bruno
b57c996564 Added appropriate auto-focus on inputs for add-new/add/updating cookie 2025-03-08 23:09:46 +05:30
ramki-bruno
5de75892a2 Fix: Show validation errors in raw-edit mode in manage-cookie UI 2025-03-08 23:09:46 +05:30
ramki-bruno
d5e828aef2 Refactoring and styling fixes in Manage-Cookie UI 2025-03-08 23:09:46 +05:30
sanish-bruno
51c86bc0e9 Added UI to manage cookies 2025-03-08 23:09:46 +05:30
Anoop M D
253cb8b315 chore: updated share collection color theme 2025-03-08 20:11:24 +05:30
lohxt1
0876ad0dab Revert "revert changes from another pr"
This reverts commit 94dfaf45cd.
2025-03-08 17:05:31 +05:30
lohxt1
a1c133b303 revert changes from another pr 2025-03-08 17:05:31 +05:30
lohxt1
38cf206075 revert import colleciton modal ui changes 2025-03-08 17:05:31 +05:30
lohxt1
9d598db55e share collection and import collection ui updates
~ added share collection option in the collection overview tab
2025-03-08 17:05:31 +05:30
therealrinku
233c57e625 feat: auto select body tab if it exists when params isnt active 2025-03-04 15:27:33 +05:30
Jens
b399576dab Update readme.md
Updated link to Roadmap.
2025-03-04 01:46:15 +05:30
tlaloc911
655eec09c1 fix windows build (#4043) 2025-02-26 15:31:22 +05:30
Tim Nikischin
51eda3f08c Implement correct Runner title (#3854)
Implement correct Runner title fixes: #3763
Use title instead of filepath in runner.
2025-02-26 12:57:33 +05:30
sanish-bruno
a438c06b97 fix: remove duplicate search components 2025-02-26 12:50:52 +05:30
Ryan
4977dbeb11 Update BugReport.yaml
Added context around cause of bug, better placeholders for OS versions
2025-02-26 12:12:00 +05:30
tlaloc911
7cacc255b4 fix h1 and h2 style 2025-02-20 21:39:49 +05:30
Sanjai Kumar
b28b60d4a7 chore: update BugReport template for clarity and additional information 2025-02-18 21:59:23 +05:30
Sanjai Kumar
2fc45de430 chore: update BugReport template to include version and OS information 2025-02-18 21:59:23 +05:30
Ryan
c58604716e Update FeatureRequest.yaml (#3974)
updated submittal questions
2025-02-18 21:56:41 +05:30
lohit
31409c6206 feat: reuse worker threads for bru file parsing (#4054) 2025-02-18 19:58:37 +05:30
Anoop M D
dfb0b1b966 fix: allow popus in notification iframes
This is to allow is to allow users to open pages (like bruno downloads) from the app
2025-02-14 21:25:28 +05:30
Anoop M D
f8b4a0b85b feat: notification visibility rules based on semver 2025-02-14 18:16:34 +05:30
naman-bruno
200732bac5 fix: space on collection docs 2025-02-14 17:54:20 +05:30
ramki-bruno
c997924c42 Strengthen CSP 2025-02-14 16:01:25 +05:30
ramki-bruno
711eb24c01 Refactoring InfoTip to support just text and children instead of HTML 2025-02-14 16:00:59 +05:30
ramki-bruno
3b8a613914 Refactor ToolHint to use text prop as text instead of HTML
Currently there is no usage of ToolHint where we are passing HTML
content, so this should not break anything.
2025-02-14 16:00:59 +05:30
naman-bruno
81930f6fe6 Fix: Import collection failed for postman custom method 2025-02-14 02:43:31 +05:30
sreelakshmi-bruno
e0750148e6 Styling improvements in failed-load-request summary (#3956)
Co-authored-by: Sreelakshmi Jayarajan <sreelakshmi@Sreelakshmis-MacBook-Air.local>
2025-02-14 02:42:14 +05:30
Anoop M D
528f822294 feat: notifications are displayed using iframe 2025-02-14 02:37:07 +05:30
lohit
31c11830a6 fix: should be able to save the request after reverting the changes (#3999) 2025-02-11 20:38:04 +05:30
lohxt1
eff3b29bfb fix: should be able to save request after reverting all the changes 2025-02-11 20:27:05 +05:30
lohxt1
4656958f2f fix: should be able to save request after reverting all the changes 2025-02-11 20:27:05 +05:30
ramki-bruno
aa4575b0ea Fix: CLI-Tests Workflow lacks contents:read permission 2025-02-11 18:08:01 +05:30
Sanjai Kumar
14d798eea7 Revert "fix: improve handling of Buffer responses and set default charset to utf-8"
This reverts commit d8a2e6f405.
2025-02-11 18:06:30 +05:30
Sanjai Kumar
8af285d41e Revert "refactor: enhance JSON parsing logic for Buffer responses"
This reverts commit 14e9227e07.
2025-02-11 18:06:30 +05:30
naman-bruno
6a0eb1ccde fix: nonReplaceableTabTypes getting duplicate 2025-02-11 15:35:52 +05:30
naman-bruno
4a613ed1b7 Fix: duplicate collection tabs 2025-02-11 15:35:52 +05:30
sanjai0py
7566d658d4 Fix: Prevent interpolation of buffer values in JSON requests 2025-02-11 15:32:44 +05:30
ramki-bruno
70ee819bae Fix: Postman-import-translation not adding comments for unsupported APIs
There was an error in one of the WIP feature which is breaking the
translation, for now commenting it out.
2025-02-10 21:47:43 +05:30
lohxt1
8810b9e291 fix graphql variables editor and tests editor height 2025-02-10 21:42:53 +05:30
Sanjai Kumar
89a4cd62bc Feat: Move-Collection with Drag-and-Drop (#3755)
---------

Co-authored-by: sanjai0py <sanjailucifer666@gmail.com>
Co-authored-by: ramki-bruno <ramki@usebruno.com>
2025-02-10 20:46:42 +05:30
Anoop M D
4c1765e9f9 feat(#2896): add support for cheerio and xml2json as inbuilt library 2025-02-10 18:55:20 +05:30
Sreelakshmi Jayarajan
8351589994 Fixing typo in pm translation of util function 2025-02-10 18:55:11 +05:30
Sreelakshmi Jayarajan
4d063b2d0b Revert "Fixing typo in pm translation of util functions"
This reverts commit fceff56872.
2025-02-10 18:55:11 +05:30
Sreelakshmi Jayarajan
288628003f Fixing typo in pm translation of util functions 2025-02-10 18:55:11 +05:30
Pooja Belaramani
ced6ddfab5 add: translation for skipRequest and stopExecution 2025-02-10 11:45:23 +05:30
Pragadesh-45
5291bbaef7 Fix: Import failing for collections with special characters in Windows (#3969)
* fix: correct variable used in collection name update
* fix: sanitize collection names by removing invalid filesystem characters
* refactor: refactor collection name sanitization to use `sanitizeDirectoryName`
2025-02-10 11:37:50 +05:30
ramki-bruno
667b15386c Fix: Unresponsive click-area in folder-collection-item between chevron-icon and name 2025-02-06 23:34:58 +05:30
ramki-bruno
086e943c04 Fix: Collectio-items not loading when clicking on chevron icon the first-time 2025-02-06 23:34:58 +05:30
ramki-bruno
901c4e1ca2 Fix: Request-send action should make the Tab permanent/non-preview 2025-02-06 23:34:58 +05:30
sanjai0py
14e9227e07 refactor: enhance JSON parsing logic for Buffer responses 2025-02-06 21:24:55 +05:30
sanjai0py
d8a2e6f405 fix: improve handling of Buffer responses and set default charset to utf-8 2025-02-06 21:24:55 +05:30
lohxt1
5c096cff7b fix worker lane logic 2025-02-06 21:20:23 +05:30
Sanjai Kumar
0913e668e0 refactor: update browseFiles action to remove default properties and improve file dialog handling (#3957)
---------
Co-authored-by: sanjai0py <sanjailucifer666@gmail.com>
2025-02-06 20:26:49 +05:30
Sahil Khan
f0c3125227 fix:handle render svg response (#3833) 2025-02-06 20:23:27 +05:30
naman-bruno
a5d23599a1 Fixed: user cookie header getting overwritten by stored cookie in cli 2025-02-06 20:03:42 +05:30
naman-bruno
28ca0494e0 Fixed: user cookie header getting overwritten by stored cookie 2025-02-06 20:03:42 +05:30
naman-bruno
722d9788ca Feature: Improve tab UX (#3831)
---------
Co-authored-by: ramki-bruno <ramki@usebruno.com>
2025-02-06 19:34:10 +05:30
lohit
038f2d1f0b temp. revert tests for file body (#3941)
* temporarily revert tests for file body
2025-02-04 22:12:59 +05:30
lohit
8f604efc7e fixes tests for the file body pr (#3940)
fixes tests for bruno-app and bruno-electron
2025-02-04 22:12:59 +05:30
Sanjai Kumar
af182a9c00 refactor: rename binaryFile to file and update related references 2025-02-04 22:12:59 +05:30
Sanjai Kumar
dd23962958 improvements (#3930)
Co-authored-by: Sanjai Kumar <sanjai@usebruno.com>
2025-02-04 22:12:59 +05:30
Marcos Adriano
324b7cec51 feat: raw binary files handling in request body (#3734)
--------------
Co-authored-by: lohit <lohit@usebruno.com>
Co-authored-by: Anoop M D <anoop.md1421@gmail.com>
2025-02-04 22:12:59 +05:30
lohxt1
8abf8ff9c8 skipped request should not be considered as errors in junit reports 2025-02-04 21:48:10 +05:30
Pragadesh-45
ec5a5c9b56 fix: correct variable used in collection name update 2025-02-04 18:49:02 +05:30
Pooja Belaramani
4598acd068 fix 2025-02-03 13:49:35 +05:30
Pooja Belaramani
062ab00a66 fix: ensure API key values are converted to strings in Postman collection importer. 2025-02-03 13:49:35 +05:30
Pooja Belaramani
650cb47a8b fix: resolve function recusion 2025-02-03 13:01:24 +05:30
Pooja Belaramani
08139a8f3e fix 2025-01-30 09:58:38 +05:30
Pooja Belaramani
51e087efba add: hint word for runRequest, sendNextRequest, skipRequest, getTestResults 2025-01-30 09:58:38 +05:30
lohxt1
ff5683f19f add runRequest and runner utils functions to cli
~ add bru.runRequest support for cli
~ add bru.runner.skipRequest, bru.runner.stopExecution support for cli
2025-01-29 11:53:02 +05:30
lohxt1
d13e4b3b54 ensure variables set in scripts/tests during bru.runRequest reflect in original request scripts/tests 2025-01-29 11:53:02 +05:30
Jarod Gowgiel
2d08567d8d Add an npm script to run the Electron app for debugging (#3616)
This allows for developers to attach Dev Tools, e.g. the Chrome
"dedicated DevTools for node", to the main Electron process
for debugging operations that occur on the main process.
2025-01-29 11:25:41 +05:30
ramki-bruno
b51f8109a2 Review changes and minor-refactoring in Reveal in Finder feature 2025-01-29 11:18:57 +05:30
Naman
8f241a32ae Feature: Reveal in Finder (#3698)
* feat: Reveal in Finder

* added support for linux
2025-01-29 11:18:57 +05:30
Sanjai Kumar
b7fda331dc Fix: Comment toggling for JSON modes in CodeEditor 2025-01-29 11:16:15 +05:30
lohxt1
10e0fde2a8 use lowercase header keys while making requests 2025-01-29 08:34:01 +05:30
Anoop M D
9f5f975f70 feat: async parser workers (#3834) (#3887)
feat: async parser workers (#3834)
Co-authored-by: lohit <lohit.jiddimani@gmail.com>
2025-01-29 02:53:53 +05:30
Pragadesh-45
b5bd259a1b fix: handle Windows paths in cloneItem and getDirectoryName functions (fixes: #3401) (#3646)
* fix: handle Windows paths in cloneItem and getDirectoryName functions

* chore: removed commented lines

---------

Co-authored-by: Anoop M D <anoop.md1421@gmail.com>
2025-01-28 21:32:51 +05:30
lohit
0633d45a10 upgraded axios libarary (#3899) 2025-01-28 18:55:59 +05:30
Pragadesh-45
16e27d2ca4 feat: make BrunoResponse callable to access body data using expressions fixes (#481) (#3710)
* feat: make `BrunoResponse` callable to access body data using expressions
2025-01-28 14:57:00 +05:30
Alex
956d5a38e9 fix: bump axios version to handle ivp6 (#3878)
Co-authored-by: alex.chua <alex.chua@bytedance.com>
2025-01-28 14:36:29 +05:30
Sanjai Kumar
bfc1101371 refactor: update GitHub Actions workflow to add permissions for checks and pull requests for the cli-tests job (#3844) 2025-01-28 14:13:14 +05:30
sanish chirayath
05be59f00c style: update StyledWrapper to use flex layout and adjust dialog positioning (#3888) 2025-01-27 18:49:05 +05:30
158 changed files with 7861 additions and 861 deletions

View File

@@ -6,26 +6,58 @@ body:
attributes:
value: |
Thanks for taking the time to fill out this bug report!
Before submitting, please make sure you've searched existing issues:
👉 [Search existing issues](https://github.com/usebruno/bruno/issues?q=is%3Aissue)
- type: checkboxes
attributes:
label: 'I have checked the following:'
options:
- label: I use the newest version of bruno.
required: true
- label: I've searched existing issues and found nothing related to my issue.
- label: "I have searched existing issues and found nothing related to my issue."
required: true
- type: checkboxes
attributes:
label: 'This bug is:'
options:
- label: making Bruno unusable for me
required: false
- label: slowing me down but I'm able to continue working
required: false
- label: annoying
required: false
- type: input
attributes:
label: Bruno version
description: Please specify the version of Bruno you are using in which the issue occurs.
placeholder: 1.38.1
validations:
required: true
- type: input
attributes:
label: Operating System
description: Information about the operating system the issue occurs on.
placeholder: Windows 11 26100.3037 / macOS 15.1 (24B83) / Linux 6.13.1
validations:
required: true
- type: textarea
attributes:
label: Describe the bug
description: A clear and concise description of the bug.
description: A clear and concise description of the bug and how it's effecting your work along with steps to reproduce.
validations:
required: true
- type: textarea
attributes:
label: .bru file to reproduce the bug
description: Attach your .bru file here that can reqroduce the problem.
description: Attach your .bru file here that can reproduce the problem.
validations:
required: false
- type: textarea
attributes:
label: Screenshots/Live demo link

View File

@@ -8,13 +8,23 @@ body:
options:
- label: I've searched existing issues and found nothing related to my issue.
required: true
- type: checkboxes
attributes:
label: 'This feature'
options:
- label: blocks me from using Bruno
required: false
- label: would improve my quality of life in Bruno
required: false
- label: is something I've never seen an API client do before
required: false
- type: markdown
attributes:
value: |
Suggest an idea for this project.
- type: textarea
attributes:
label: Describe the feature you want to add
label: Describe the feature you want to add, and how it would change your usage of Bruno
description: A clear and concise description of the feature you want to be added.
validations:
required: true
@@ -23,4 +33,4 @@ body:
label: Mockups or Images of the feature
description: Add some images to support your feature.
validations:
required: true
required: false

View File

@@ -5,14 +5,13 @@ on:
pull_request:
branches: [main]
permissions:
contents: read
jobs:
unit-test:
name: Unit Tests
timeout-minutes: 60
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
@@ -52,6 +51,10 @@ jobs:
cli-test:
name: CLI Tests
runs-on: ubuntu-latest
permissions:
checks: write
pull-requests: write
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4

3695
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -36,6 +36,7 @@
"build:web": "npm run build --workspace=packages/bruno-app",
"prettier:web": "npm run prettier --workspace=packages/bruno-app",
"dev:electron": "npm run dev --workspace=packages/bruno-electron",
"dev:electron:debug": "npm run debug --workspace=packages/bruno-electron",
"build:bruno-common": "npm run build --workspace=packages/bruno-common",
"build:bruno-query": "npm run build --workspace=packages/bruno-query",
"build:graphql-docs": "npm run build --workspace=packages/bruno-graphql-docs",

View File

@@ -1,6 +1,6 @@
{
"name": "@usebruno/app",
"version": "0.3.0",
"version": "1.39.0",
"private": true,
"scripts": {
"dev": "rsbuild dev",
@@ -20,11 +20,11 @@
"@usebruno/common": "0.1.0",
"@usebruno/graphql-docs": "0.1.0",
"@usebruno/schema": "0.7.0",
"axios": "1.7.5",
"classnames": "^2.3.1",
"codemirror": "5.65.2",
"codemirror-graphql": "2.1.1",
"cookie": "0.7.1",
"dompurify": "^3.2.4",
"escape-html": "^1.0.3",
"file": "^0.2.2",
"file-dialog": "^0.0.8",
@@ -34,19 +34,22 @@
"graphiql": "3.7.1",
"graphql": "^16.6.0",
"graphql-request": "^3.7.0",
"httpsnippet": "^3.0.6",
"httpsnippet": "^3.0.9",
"i18next": "24.1.2",
"iconv-lite": "^0.6.3",
"idb": "^7.0.0",
"immer": "^9.0.15",
"jsesc": "^3.0.2",
"jshint": "^2.13.6",
"json5": "^2.2.3",
"jsonc-parser": "^3.2.1",
"jsonpath-plus": "10.2.0",
"jsonpath-plus": "^10.3.0",
"know-your-http-well": "^0.5.0",
"lodash": "^4.17.21",
"markdown-it": "^13.0.2",
"markdown-it-replace-link": "^1.2.0",
"moment": "^2.30.1",
"moment-timezone": "^0.5.47",
"mousetrap": "^1.6.5",
"nanoid": "3.3.8",
"path": "^0.12.7",
@@ -69,6 +72,7 @@
"react-redux": "^7.2.9",
"react-tooltip": "^5.5.2",
"sass": "^1.46.0",
"semver": "^7.7.1",
"strip-json-comments": "^5.0.1",
"styled-components": "^5.3.3",
"system": "^2.0.1",

View File

@@ -0,0 +1,62 @@
import React, { createContext, useContext, useState } from 'react';
import { IconChevronDown } from '@tabler/icons';
import { AccordionItem, AccordionHeader, AccordionContent } from './styledWrapper';
const AccordionContext = createContext();
const Accordion = ({ children, defaultIndex }) => {
const [openIndex, setOpenIndex] = useState(defaultIndex);
const toggleItem = (index) => {
setOpenIndex(openIndex === index ? null : index);
};
return (
<AccordionContext.Provider value={{ openIndex, toggleItem }}>
<div>{children}</div>
</AccordionContext.Provider>
);
};
const Item = ({ index, children, ...props }) => {
return (
<AccordionItem {...props}>
{React.Children.map(children, (child) => React.cloneElement(child, { index }))}
</AccordionItem>
);
};
export const Header = ({ index, children, ...props }) => {
const { openIndex, toggleItem } = useContext(AccordionContext);
const isOpen = openIndex === index;
return (
<AccordionHeader onClick={() => toggleItem(index)} {...props} className={isOpen ? 'open' : ''}>
<div className="w-full">{children}</div>
<IconChevronDown
className="w-5 h-5 ml-auto"
style={{
transform: `rotate(${isOpen ? '180deg' : '0deg'})`,
transition: 'transform 0.3s ease-in-out'
}}
/>
</AccordionHeader>
);
};
const Content = ({ index, children, ...props }) => {
const { openIndex } = useContext(AccordionContext);
const isOpen = openIndex === index;
return (
<AccordionContent isOpen={isOpen} {...props}>
{children}
</AccordionContent>
);
};
Accordion.Item = Item;
Accordion.Header = Header;
Accordion.Content = Content;
export default Accordion;

View File

@@ -0,0 +1,28 @@
import styled from 'styled-components';
const AccordionItem = styled.div`
border: 1px solid ${(props) => props.theme.input.border};
border-radius: 4px;
overflow: hidden;
margin-bottom: 1rem;
`;
const AccordionHeader = styled.button`
width: 100%;
display: flex;
padding: 0.75rem 1rem;
background: transparent;
cursor: pointer;
font-weight: 500;
&.open, &:hover {
background-color: ${(props) => props.theme.plainGrid.hoverBg};
}
`;
const AccordionContent = styled.div`
padding: ${(props) => (props.isOpen ? '1rem' : '0')};
max-height: ${(props) => (props.isOpen ? 'auto' : '0')};
`;
export { AccordionItem, AccordionHeader, AccordionContent };

View File

@@ -8,6 +8,8 @@ const StyledWrapper = styled.div`
font-size: ${(props) => (props.fontSize ? `${props.fontSize}px` : 'inherit')};
line-break: anywhere;
flex: 1 1 0;
display: flex;
flex-direction: column-reverse;
}
/* Removes the glow outline around the folded json */
@@ -26,6 +28,10 @@ const StyledWrapper = styled.div`
.CodeMirror-dialog {
overflow: visible;
position: relative;
top: unset;
left: unset;
input {
background: transparent;
border: 1px solid #d3d6db;

View File

@@ -74,6 +74,9 @@ if (!SERVER_RENDERED) {
'bru.setNextRequest(requestName)',
'req.disableParsingResponseJson()',
'bru.getRequestVar(key)',
'bru.runRequest(requestPathName)',
'bru.getAssertionResults()',
'bru.getTestResults()',
'bru.sleep(ms)',
'bru.getGlobalEnvVar(key)',
'bru.setGlobalEnvVar(key, value)',
@@ -171,11 +174,21 @@ export default class CodeEditor extends React.Component {
}
},
'Cmd-F': (cm) => {
if (this._isSearchOpen()) {
// replace the older search component with the new one
const search = document.querySelector('.CodeMirror-dialog.CodeMirror-dialog-top');
search && search.remove();
}
cm.execCommand('findPersistent');
this._bindSearchHandler();
this._appendSearchResultsCount();
},
'Ctrl-F': (cm) => {
if (this._isSearchOpen()) {
// replace the older search component with the new one
const search = document.querySelector('.CodeMirror-dialog.CodeMirror-dialog-top');
search && search.remove();
}
cm.execCommand('findPersistent');
this._bindSearchHandler();
this._appendSearchResultsCount();
@@ -194,8 +207,20 @@ export default class CodeEditor extends React.Component {
'Cmd-Y': 'foldAll',
'Ctrl-I': 'unfoldAll',
'Cmd-I': 'unfoldAll',
'Ctrl-/': 'toggleComment',
'Cmd-/': 'toggleComment'
'Ctrl-/': () => {
if (['application/ld+json', 'application/json'].includes(this.props.mode)) {
this.editor.toggleComment({ lineComment: '//', blockComment: '/*' });
} else {
this.editor.toggleComment();
}
},
'Cmd-/': () => {
if (['application/ld+json', 'application/json'].includes(this.props.mode)) {
this.editor.toggleComment({ lineComment: '//', blockComment: '/*' });
} else {
this.editor.toggleComment();
}
}
},
foldOptions: {
widget: (from, to) => {
@@ -350,6 +375,10 @@ export default class CodeEditor extends React.Component {
}
};
_isSearchOpen = () => {
return document.querySelector('.CodeMirror-dialog.CodeMirror-dialog-top');
};
/**
* Bind handler to search input to count number of search results
*/

View File

@@ -1,11 +1,7 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
div.CodeMirror {
.CodeMirror-scroll {
padding-bottom: 0px;
}
}
.editing-mode {
cursor: pointer;
}

View File

@@ -1,10 +1,21 @@
import React from 'react';
import React from "react";
import { getTotalRequestCountInCollection } from 'utils/collections/';
import { IconFolder, IconFileOff, IconWorld, IconApi } from '@tabler/icons';
import { IconFolder, IconWorld, IconApi, IconShare } from '@tabler/icons';
import { areItemsLoading, getItemsLoadStats } from "utils/collections/index";
import { useState } from "react";
import ShareCollection from "components/ShareCollection/index";
const Info = ({ collection }) => {
const totalRequestsInCollection = getTotalRequestCountInCollection(collection);
const isCollectionLoading = areItemsLoading(collection);
const { loading: itemsLoadingCount, total: totalItems } = getItemsLoadStats(collection);
const [showShareCollectionModal, toggleShowShareCollectionModal] = useState(false);
const handleToggleShowShareCollectionModal = (value) => (e) => {
toggleShowShareCollectionModal(value);
}
return (
<div className="w-full flex flex-col h-fit">
<div className="rounded-lg py-6">
@@ -42,15 +53,30 @@ const Info = ({ collection }) => {
</div>
<div className="ml-4">
<div className="font-semibold text-sm">Requests</div>
<div className="mt-1 text-sm text-muted">
{totalRequestsInCollection} request{totalRequestsInCollection !== 1 ? 's' : ''} in collection
<div className="mt-1 text-sm text-muted font-mono">
{
isCollectionLoading? `${totalItems - itemsLoadingCount} out of ${totalItems} requests in the collection loaded` : `${totalRequestsInCollection} request${totalRequestsInCollection !== 1 ? 's' : ''} in collection`
}
</div>
</div>
</div>
<div className="flex items-start group cursor-pointer" onClick={handleToggleShowShareCollectionModal(true)}>
<div className="flex-shrink-0 p-3 bg-indigo-50 dark:bg-indigo-900/20 rounded-lg">
<IconShare className="w-5 h-5 text-indigo-500" stroke={1.5} />
</div>
<div className="ml-4 h-full flex flex-col justify-start">
<div className="font-semibold text-sm h-fit my-auto">Share</div>
<div className="mt-1 text-sm group-hover:underline text-link">
Share Collection
</div>
</div>
</div>
{showShareCollectionModal && <ShareCollection collection={collection} onClose={handleToggleShowShareCollectionModal(false)} />}
</div>
</div>
</div>
);
};
export default Info;
export default Info;

View File

@@ -2,8 +2,15 @@ import React from 'react';
import { flattenItems } from "utils/collections";
import { IconAlertTriangle } from '@tabler/icons';
import StyledWrapper from "./StyledWrapper";
import { useDispatch, useSelector } from 'react-redux';
import { isItemARequest, itemIsOpenedInTabs } from 'utils/tabs/index';
import { getDefaultRequestPaneTab } from 'utils/collections/index';
import { addTab, focusTab } from 'providers/ReduxStore/slices/tabs';
import { hideHomePage } from 'providers/ReduxStore/slices/app';
const RequestsNotLoaded = ({ collection }) => {
const dispatch = useDispatch();
const tabs = useSelector((state) => state.tabs.tabs);
const flattenedItems = flattenItems(collection.items);
const itemsFailedLoading = flattenedItems?.filter(item => item?.partial && !item?.loading);
@@ -11,6 +18,29 @@ const RequestsNotLoaded = ({ collection }) => {
return null;
}
const handleRequestClick = (item) => e => {
e.preventDefault();
if (isItemARequest(item)) {
dispatch(hideHomePage());
if (itemIsOpenedInTabs(item, tabs)) {
dispatch(
focusTab({
uid: item.uid
})
);
return;
}
dispatch(
addTab({
uid: item.uid,
collectionUid: collection.uid,
requestPaneTab: getDefaultRequestPaneTab(item)
})
);
return;
}
}
return (
<StyledWrapper className="w-full card my-2">
<div className="flex items-center gap-2 px-3 py-2 title bg-yellow-50 dark:bg-yellow-900/20">
@@ -31,7 +61,7 @@ const RequestsNotLoaded = ({ collection }) => {
<tbody>
{flattenedItems?.map((item, index) => (
item?.partial && !item?.loading ? (
<tr key={index}>
<tr key={index} className='cursor-pointer' onClick={handleRequestClick(item)}>
<td className="py-1.5 px-3">
{item?.pathname?.split(`${collection?.pathname}/`)?.[1]}
</td>

View File

@@ -104,18 +104,15 @@ const ProxySettings = ({ proxyConfig, onUpdate }) => {
<div className="mb-3 flex items-center">
<label className="settings-label flex items-center" htmlFor="enabled">
Config
<InfoTip
text={`
<InfoTip infotipId="request-var">
<div>
<ul>
<li><span style="width: 50px;display:inline-block;">global</span> - use global proxy config</li>
<li><span style="width: 50px;display:inline-block;">enabled</span> - use collection proxy config</li>
<li><span style="width: 50px;display:inline-block;">disable</span> - disable proxy</li>
<li><span style={{width: "50px", display: "inline-block"}}>global</span> - use global proxy config</li>
<li><span style={{width: "50px", display: "inline-block"}}>enabled</span> - use collection proxy config</li>
<li><span style={{width: "50px", display: "inline-block"}}>disable</span> - disable proxy</li>
</ul>
</div>
`}
infotipId="request-var"
/>
</InfoTip>
</label>
<div className="flex items-center">
<label className="flex items-center">

View File

@@ -89,7 +89,7 @@ const VarsTable = ({ collection, vars, varType }) => {
<td>
<div className="flex items-center">
<span>Expr</span>
<InfoTip text="You can write any valid JS Template Literal here" infotipId="request-var" />
<InfoTip content="You can write any valid JS Template Literal here" infotipId="request-var" />
</div>
</td>
)}

View File

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

View File

@@ -0,0 +1,371 @@
import React, { useState, useRef, useEffect } from 'react';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import Modal from 'components/Modal/index';
import { modifyCookie, addCookie, getParsedCookie, createCookieString } from 'providers/ReduxStore/slices/app';
import { useDispatch } from 'react-redux';
import toast from 'react-hot-toast';
import ToggleSwitch from 'components/ToggleSwitch/index';
import { IconInfoCircle } from '@tabler/icons';
import moment from 'moment';
import 'moment-timezone';
import { Tooltip } from 'react-tooltip';
import { isEmpty } from 'lodash';
const removeEmptyValues = (obj) => {
return Object.fromEntries(Object.entries(obj).filter(([_, value]) => value !== null && value !== undefined));
};
const ModifyCookieModal = ({ onClose, domain, cookie }) => {
const dispatch = useDispatch();
const [isRawMode, setIsRawMode] = useState(false);
const [cookieString, setCookieString] = useState('');
const initialParseRef = useRef(false);
const formik = useFormik({
enableReinitialize: true,
initialValues: {
...(cookie ? cookie : {}),
key: cookie?.key || '',
value: cookie?.value || '',
path: cookie?.path || '/',
domain: cookie?.domain || domain || '',
expires: cookie?.expires ? moment(cookie.expires).format(moment.HTML5_FMT.DATETIME_LOCAL) : '',
secure: cookie?.secure || false,
httpOnly: cookie?.httpOnly || false
},
validationSchema: Yup.object({
key: Yup.string().required('Key is required'),
value: Yup.string().required('Value is required'),
domain: Yup.string().required('Domain is required'),
secure: Yup.boolean(),
httpOnly: Yup.boolean(),
expires: Yup.mixed()
.nullable()
.transform((value) => {
if (!value || value === '') return null;
return moment(value).isValid() ? moment(value).toDate() : null;
})
.test('future-date', 'Expiration date must be in the future', (value) => {
if (!value) return true;
return moment(value).isAfter(moment());
})
}),
onSubmit: (values) => {
const modValues = removeEmptyValues({
...(cookie ? cookie : {}),
...values,
expires: values.expires
? moment(values.expires).isValid()
? moment(values.expires).toDate()
: Infinity
: Infinity
});
handleCookieDispatch(cookie, domain, modValues, onClose);
}
});
const title = cookie ? 'Modify Cookie' : 'Add Cookie';
const handleCookieDispatch = (cookie, domain, modValues, onClose) => {
if (cookie) {
dispatch(modifyCookie(domain, cookie, modValues))
.then(() => {
toast.success('Cookie modified successfully');
onClose();
})
.catch((err) => {
toast.error('An error occurred while modifying cookie');
console.error(err);
});
} else {
dispatch(addCookie(domain, modValues))
.then(() => {
toast.success('Cookie added successfully');
onClose();
})
.catch((err) => {
toast.error('An error occurred while adding cookie');
console.error(err);
});
}
};
const onSubmit = async () => {
try {
if (isRawMode) {
const cookieObj = await dispatch(getParsedCookie(cookieString));
const modifiedCookie = removeEmptyValues({
...formik.values,
...cookieObj,
expires: cookieObj?.expires
? moment(cookieObj.expires).isValid()
? moment(cookieObj.expires).toDate()
: Infinity
: Infinity
});
if (!cookieObj) {
toast.error('Please enter a valid cookie string');
return;
}
const validationErrors = await formik.setValues(
(values) => ({
...values,
...modifiedCookie,
expires:
modifiedCookie?.expires && moment(modifiedCookie.expires).isValid()
? moment(new Date(modifiedCookie.expires)).format(moment.HTML5_FMT.DATETIME_LOCAL)
: ''
}),
true
);
if (!isEmpty(validationErrors)) {
toast.error(Object.values(validationErrors).join("\n"));
return;
}
handleCookieDispatch(cookie, domain, modifiedCookie, onClose);
} else {
formik.handleSubmit();
}
} catch (error) {
const errMsg = error.message || 'An error occurred while parsing cookie string';
toast.error(errMsg);
}
};
useEffect(() => {
if (!isRawMode) return;
const loadCookieString = async () => {
if (cookie) {
const str = await dispatch(createCookieString(cookie));
setCookieString(str);
}
return '';
};
loadCookieString();
}, [cookie, isRawMode]);
// create the cookieString when raw mode is enabled
useEffect(() => {
if (isRawMode) {
const createCookieStr = async () => {
const str = await dispatch(createCookieString(formik.values));
setCookieString(str);
};
createCookieStr();
}
}, [isRawMode, formik.values]);
useEffect(() => {
// Reset the ref when raw mode changes
if (isRawMode) {
initialParseRef.current = false;
return;
}
const setParsedCookie = async () => {
if (!isRawMode && cookieString && !initialParseRef.current) {
initialParseRef.current = true;
try {
const cookieObj = await dispatch(getParsedCookie(cookieString));
if (!cookieObj) return;
formik.setValues(
(values) => ({
...values,
...removeEmptyValues(cookieObj),
expires:
cookieObj?.expires && moment(cookieObj.expires).isValid()
? moment(new Date(cookieObj.expires)).format(moment.HTML5_FMT.DATETIME_LOCAL)
: ''
}),
true
);
} catch (error) {
const errMsg = error.message || 'An error occurred while parsing cookie string';
toast.error(errMsg);
}
}
};
setParsedCookie();
}, [isRawMode, cookieString, dispatch, formik]);
return (
<Modal
size="lg"
title={title}
onClose={onClose}
handleCancel={onClose}
handleConfirm={onSubmit}
customHeader={
<div className="flex items-center justify-between w-full">
<h2 className="text-sm font-bold">{title}</h2>
<div className="ml-auto flex items-center ">
<ToggleSwitch
className="mr-2"
isOn={isRawMode}
size="2xs"
handleToggle={(e) => {
setIsRawMode(e.target.checked);
}}
/>
<label className="text-sm font-normal mr-4 normal-case">Edit Raw</label>
</div>
</div>
}
>
<form onSubmit={(e) => e.preventDefault()} className="px-2">
{isRawMode ? (
<div>
<div className="flex items-center gap-2 mb-1">
<label className="block text-sm">Set-Cookie String</label>
<IconInfoCircle id="cookie-raw-info" size={16} strokeWidth={1.5} className="text-gray-400" />
<Tooltip
anchorId="cookie-raw-info"
className="tooltip-mod"
html="Key, Path, and Domain are immutable properties and cannot be modified for existing cookies"
/>
</div>
<textarea
value={cookieString}
onChange={(e) => setCookieString(e.target.value)}
className="block textbox w-full h-24"
placeholder="key=value; key2=value2"
/>
</div>
) : (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm mb-1">
Domain<span className="text-red-600">*</span>{' '}
</label>
<input
type="text"
name="domain"
// Auto-focus if its add-new i.e. when domain prop is empty
autoFocus={!domain && !formik.values.domain}
value={formik.values.domain}
onChange={formik.handleChange}
className="block textbox non-passphrase-input w-full disabled:opacity-50"
disabled={!!cookie}
/>
{formik.touched.domain && formik.errors.domain && (
<div className="text-red-500 text-sm mt-1">{formik.errors.domain}</div>
)}
</div>
<div>
<label className="block text-sm mb-1">Path</label>
<input
type="text"
name="path"
value={formik.values.path}
onChange={formik.handleChange}
className="block textbox non-passphrase-input w-full disabled:opacity-50"
disabled={!!cookie}
/>
{formik.touched.path && formik.errors.path && (
<div className="text-red-500 text-sm mt-1">{formik.errors.path}</div>
)}
</div>
<div>
<label className="block text-sm mb-1">
Key<span className="text-red-600">*</span>{' '}
</label>
<input
type="text"
name="key"
// Auto focus when add-for-domain i.e. if domain is already prefilled
autoFocus={!!domain && !formik.values.key}
value={formik.values.key}
onChange={formik.handleChange}
className="block textbox non-passphrase-input w-full disabled:opacity-50"
disabled={!!cookie}
/>
{formik.touched.key && formik.errors.key && (
<div className="text-red-500 text-sm mt-1">{formik.errors.key}</div>
)}
</div>
<div>
<label className="block text-sm mb-1">
Value<span className="text-red-600">*</span>{' '}
</label>
<input
type="text"
name="value"
// Auto-focus when its in edit mode i.e. cookie prop is present
autoFocus={!!cookie}
value={formik.values.value}
onChange={formik.handleChange}
className="block textbox non-passphrase-input w-full"
/>
{formik.touched.value && formik.errors.value && (
<div className="text-red-500 text-sm mt-1">{formik.errors.value}</div>
)}
</div>
</div>
{/* Date Picker */}
<div className="w-full flex items-end">
<div>
<label className="block text-sm mb-1">Expiration ({moment.tz.guess()})</label>
<input
type="datetime-local"
name="expires"
value={formik.values.expires}
onChange={(e) => {
formik.handleChange(e);
}}
className="block textbox non-passphrase-input w-full"
min={moment().format(moment.HTML5_FMT.DATETIME_LOCAL)}
/>
{formik.touched.expires && formik.errors.expires && (
<div className="text-red-500 text-sm mt-1">{formik.errors.expires}</div>
)}
</div>
{/* Checkboxes */}
<div className="flex space-x-4 ml-auto">
<label className="flex items-center">
<input
type="checkbox"
name="secure"
checked={formik.values.secure}
onChange={formik.handleChange}
className="mr-2"
/>
<span className="text-sm">Secure</span>
</label>
<label className="flex items-center">
<input
type="checkbox"
name="httpOnly"
checked={formik.values.httpOnly}
onChange={formik.handleChange}
className="mr-2"
/>
<span className="text-sm">HTTP Only</span>
</label>
</div>
</div>
</div>
)}
</form>
</Modal>
);
};
export default ModifyCookieModal;

View File

@@ -11,6 +11,65 @@ const Wrapper = styled.div`
user-select: none;
}
}
&.header {
input {
padding: 0.3rem 0.5rem;
}
}
.textbox {
line-height: 1.42857143;
border: 1px solid #ccc;
padding: 0.45rem;
box-shadow: none;
border-radius: 0px;
outline: none;
box-shadow: none;
transition: border-color ease-in-out 0.1s;
border-radius: 3px;
background-color: ${(props) => props.theme.modal.input.bg};
border: 1px solid ${(props) => props.theme.modal.input.border};
&:focus {
border: solid 1px ${(props) => props.theme.modal.input.focusBorder} !important;
outline: none !important;
}
}
.scroll-box {
max-height: 500px;
overflow-y: auto;
background:
/* Shadow Cover TOP */
linear-gradient(
${(props) => props.theme.modal.body.bg} 20%,
rgba(255, 255, 255, 0)
) center top,
/* Shadow Cover BOTTOM */
linear-gradient(
rgba(255, 255, 255, 0),
${(props) => props.theme.modal.body.bg} 80%
) center bottom,
/* Shadow TOP */
linear-gradient(
rgba(0, 0, 0, 0.1) 0%,
rgba(0, 0, 0, 0) 100%
) center top,
/* Shadow BOTTOM */
linear-gradient(
rgba(0, 0, 0, 0) 0%,
rgba(0, 0, 0, 0.1) 100%
) center bottom;
background-repeat: no-repeat;
background-size: 100% 30px, 100% 30px, 100% 10px, 100% 10px;
background-attachment: local, local, scroll, scroll;
}
`;
export default Wrapper;

View File

@@ -1,53 +1,331 @@
import React from 'react';
import React, { useState, useRef, useEffect, useMemo } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import Modal from 'components/Modal';
import { IconTrash } from '@tabler/icons';
import { deleteCookiesForDomain } from 'providers/ReduxStore/slices/app';
import Accordion from 'components/Accordion/index';
import { IconTrash, IconEdit, IconCirclePlus, IconCookieOff, IconAlertTriangle, IconSearch } from '@tabler/icons';
import { deleteCookiesForDomain, deleteCookie } from 'providers/ReduxStore/slices/app';
import toast from 'react-hot-toast';
import ModifyCookieModal from 'components/Cookies/ModifyCookieModal/index';
import StyledWrapper from './StyledWrapper';
import moment from 'moment';
import { Tooltip } from 'react-tooltip';
const ClearDomainCookiesModal = ({ onClose, domain, onClear }) => (
<Modal onClose={onClose} handleCancel={onClose} title="Clear Domain Cookies" 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">
Are you sure you want to clear all cookies for the domain {domain}?
</div>
<div className="flex justify-between mt-6">
<div>
<button className="btn btn-sm btn-close" onClick={onClose}>
Close
</button>
</div>
<div>
<button className="btn btn-sm btn-danger" onClick={onClear}>
Clear All
</button>
</div>
</div>
</Modal>
);
const DeleteCookieModal = ({ onClose, cookieName, onDelete }) => (
<Modal onClose={onClose} handleCancel={onClose} title="Delete Cookie" 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">
Are you sure you want to delete the cookie {cookieName}?
</div>
<div className="flex justify-between mt-6">
<div>
<button className="btn btn-sm btn-close" onClick={onClose}>
Close
</button>
</div>
<div>
<button className="btn btn-sm btn-danger" onClick={onDelete}>
Delete
</button>
</div>
</div>
</Modal>
);
const CollectionProperties = ({ onClose }) => {
const dispatch = useDispatch();
const cookies = useSelector((state) => state.app.cookies) || [];
const [isModifyCookieModalOpen, setIsModifyCookieModalOpen] = useState(false);
const [currentDomain, setCurrentDomain] = useState(null);
const [cookieToEdit, setCookieToEdit] = useState(null);
const handleDeleteDomain = (domain) => {
dispatch(deleteCookiesForDomain(domain))
.then(() => {
toast.success('Domain deleted successfully');
})
.catch((err) => console.log(err) && toast.error('Failed to delete domain'));
const [domainToClear, setDomainToClear] = useState(null);
const [cookieToDelete, setCookieToDelete] = useState(null);
const [searchText, setSearchText] = useState(null);
const handleAddCookie = (domain) => {
if(domain) setCurrentDomain(domain);
setIsModifyCookieModalOpen(true);
};
const handleEditCookie = (domain, cookie) => {
setCurrentDomain(domain);
setCookieToEdit(cookie);
setIsModifyCookieModalOpen(true);
};
const handleClearDomainCookies = (domain) => {
setDomainToClear(domain);
};
const clearDomainCookiesAction = () => {
dispatch(deleteCookiesForDomain(domainToClear))
.then(() => {
toast.success('Domain cookies cleared successfully');
})
.catch((err) => console.log(err) && toast.error('Failed to clear domain cookies'));
setDomainToClear(null);
};
const handleDeleteCookie = (domain, path, key) => {
setCookieToDelete({ key, domain, path });
};
const deleteCookieAction = () => {
if (cookieToDelete) {
const { domain, path, key } = cookieToDelete;
dispatch(deleteCookie(domain, path, key))
.then(() => {
toast.success('Cookie deleted successfully');
})
.catch((err) => console.log(err) && toast.error('Failed to delete cookie'));
}
setCookieToDelete(null);
};
const filteredCookies = useMemo(() => {
if (!searchText) return cookies;
return cookies.filter((cookie) =>
cookie.domain.toLowerCase().includes(searchText.toLowerCase())
);
}, [cookies, searchText]);
const shouldShowHeader = cookies && cookies.length > 0;
return (
<Modal size="md" title="Cookies" hideFooter={true} handleCancel={onClose}>
<StyledWrapper>
<table className="w-full border-collapse" style={{ marginTop: '-1rem' }}>
<thead>
<tr>
<th className="py-2 px-2 text-left">Domain</th>
<th className="py-2 px-2 text-left">Cookie</th>
<th className="py-2 px-2 text-center" style={{ width: 80 }}>
Actions
</th>
</tr>
</thead>
<tbody>
{cookies.map((cookie) => (
<tr key={cookie.domain}>
<td className="py-2 px-2">{cookie.domain}</td>
<td className="py-2 px-2 break-all">{cookie.cookieString}</td>
<td className="text-center">
<button tabIndex="-1" onClick={() => handleDeleteDomain(cookie.domain)}>
<IconTrash strokeWidth={1.5} size={20} />
</button>
</td>
</tr>
))}
</tbody>
</table>
</StyledWrapper>
</Modal>
<>
<Modal
size="xl"
title="Cookies"
hideFooter={true}
handleCancel={onClose}
customHeader={shouldShowHeader ? (
<StyledWrapper className="header flex items-center justify-between w-full">
<h2 className="text-xs font-medium">Cookies</h2>
<input
type="search"
placeholder="Search by domain"
value={searchText || ''}
onChange={(e) => setSearchText(e.target.value)}
className="block textbox non-passphrase-input ml-auto font-normal"
/>
<button
type="submit"
className="submit btn btn-sm btn-secondary flex items-center gap-1 mx-4 font-medium"
onClick={(e) => {
e.stopPropagation();
handleAddCookie();
}}
>
<IconCirclePlus strokeWidth={1.5} size={16} />
<span>Add Cookie</span>
</button>
</StyledWrapper>
) : null}
>
<StyledWrapper>
{!cookies || !cookies.length ? (
// No cookies found
<div className="flex items-center justify-center flex-col">
<IconCookieOff size={48} strokeWidth={1.5} className="text-gray-500" />
<h2 className="text-lg font-semibold mt-4">No cookies found</h2>
<p className="text-gray-500 mt-2">Add cookies to get started</p>
<button
type="submit"
className="submit btn btn-sm btn-secondary flex items-center gap-1 mt-8"
onClick={(e) => {
e.stopPropagation();
handleAddCookie();
}}
>
<IconCirclePlus strokeWidth={1.5} size={16} />
<span>Add Cookie</span>
</button>
</div>
) : cookies.length && !filteredCookies.length ? (
// No search results
<div className="flex items-center justify-center flex-col">
<IconSearch size={48} />
<h2 className="text-lg font-semibold mt-4">No search results</h2>
<p className="text-gray-500 mt-2">Try a different search term</p>
</div>
) : (
// Show cookies list
<div className="scroll-box">
<Accordion defaultIndex={0}>
{filteredCookies.map((domainWithCookies, i) => (
<Accordion.Item key={i} index={i}>
<Accordion.Header index={i} className="flex items-center">
<div className="flex items-center">
<span>{domainWithCookies.domain}</span>
<span className="ml-2 text-xs dark:text-gray-300 text-gray-500">
({domainWithCookies.cookies.length}{' '}
{domainWithCookies.cookies.length === 1 ? 'cookie' : 'cookies'})
</span>
<div className="ml-auto flex items-center gap-2">
<button
type="submit"
className="flex items-center gap-1 text-gray-500 hover:text-gray-950 dark:text-white dark:hover:text-gray-300"
onClick={(e) => {
e.stopPropagation();
handleAddCookie(domainWithCookies.domain);
}}
>
<IconCirclePlus strokeWidth={1.5} size={16} />
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleClearDomainCookies(domainWithCookies.domain);
}}
className="text-gray-950 dark:text-white dark:hover:hover:text-red-600 hover:text-red-600 mr-2"
>
<IconTrash strokeWidth={1.5} size={16} />
</button>
</div>
</div>
</Accordion.Header>
<Accordion.Content index={i}>
<div className="flex items-center justify-between">
<table className="w-full">
<thead>
<tr className="text-left border-b border-gray-200 dark:border-neutral-600 text-gray-700 dark:text-gray-300">
<th className="py-2 px-4 font-semibold w-32">Name</th>
<th className="py-2 px-4 font-semibold w-52">Value</th>
<th className="py-2 px-4 font-semibold">Path</th>
<th className="py-2 px-4 font-semibold">Expires</th>
<th className="py-2 px-4 font-semibold text-center">Secure</th>
<th className="py-2 px-4 font-semibold text-center">HTTP Only</th>
<th className="py-2 px-4 font-semibold text-right w-24">Actions</th>
</tr>
</thead>
<tbody>
{domainWithCookies.cookies.map((cookie) => (
<tr key={cookie.key} className="border-b border-gray-200 dark:border-neutral-600 last:border-none">
<td className="py-2 px-4 truncate">
<span id={`cookie-key-${cookie.key}`}>{cookie.key}</span>
<Tooltip
anchorId={`cookie-key-${cookie.key}`}
className="tooltip-mod"
html={cookie.key}
/>
</td>
<td className="py-2 px-4 truncate">
<span id={`cookie-value-${cookie.key}`}>{cookie.value}</span>
<Tooltip
anchorId={`cookie-value-${cookie.key}`}
className="tooltip-mod"
html={cookie.value}
/>
</td>
<td className="py-2 px-4 truncate">{cookie.path || '/'}</td>
<td className="py-2 px-4 truncate">
<span id={`cookie-expires-${cookie.key}`}>
{cookie.expires && moment(cookie.expires).isValid()
? new Date(cookie.expires).toLocaleString()
: 'Session'}
</span>
{cookie.expires && moment(cookie.expires).isValid() && (
<Tooltip
anchorId={`cookie-expires-${cookie.key}`}
className="tooltip-mod"
html={new Date(cookie.expires).toLocaleString()}
/>
)}
</td>
<td className="py-2 px-4 text-center">{cookie.secure ? '✓' : ''}</td>
<td className="py-2 px-4 text-center">{cookie.httpOnly ? '✓' : ''}</td>
<td className="py-2 px-4">
<div className="flex items-center justify-end gap-2">
<button
onClick={(e) => {
e.stopPropagation();
handleEditCookie(domainWithCookies.domain, cookie);
}}
className="text-gray-700 hover:text-gray-950
dark:text-white dark:hover:text-gray-300"
>
<IconEdit strokeWidth={1.5} size={16} />
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteCookie(domainWithCookies.domain, cookie.path, cookie.key);
}}
className="text-gray-950 dark:text-white dark:hover:hover:text-red-600 hover:text-red-600"
>
<IconTrash strokeWidth={1.5} size={16} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Accordion.Content>
</Accordion.Item>
))}
</Accordion>
</div>
)}
</StyledWrapper>
</Modal>
{isModifyCookieModalOpen && (
<ModifyCookieModal
onClose={() => {
setCookieToEdit(null);
setCurrentDomain(null);
setIsModifyCookieModalOpen(false);
}}
domain={currentDomain}
cookie={cookieToEdit}
/>
)}
{domainToClear ? (
<ClearDomainCookiesModal
onClose={() => setDomainToClear(null)}
domain={domainToClear}
onClear={clearDomainCookiesAction}
/>
) : null}
{cookieToDelete ? (
<DeleteCookieModal
onClose={() => setCookieToDelete(null)}
cookieName={cookieToDelete.key}
onDelete={deleteCookieAction}
/>
) : null}
</>
);
};

View File

@@ -6,10 +6,9 @@ import { IconX } from '@tabler/icons';
import { isWindowsOS } from 'utils/common/platform';
import slash from 'utils/common/slash';
const FilePickerEditor = ({ value, onChange, collection }) => {
value = value || [];
const FilePickerEditor = ({ value, onChange, collection, isSingleFilePicker = false }) => {
const dispatch = useDispatch();
const filenames = value
const filenames = (isSingleFilePicker ? [value] : value || [])
.filter((v) => v != null && v != '')
.map((v) => {
const separator = isWindowsOS() ? '\\' : '/';
@@ -20,7 +19,7 @@ const FilePickerEditor = ({ value, onChange, collection }) => {
const title = filenames.map((v) => `- ${v}`).join('\n');
const browse = () => {
dispatch(browseFiles())
dispatch(browseFiles([], [!isSingleFilePicker ? "multiSelections": ""]))
.then((filePaths) => {
// If file is in the collection's directory, then we use relative path
// Otherwise, we use the absolute path
@@ -34,7 +33,7 @@ const FilePickerEditor = ({ value, onChange, collection }) => {
return filePath;
});
onChange(filePaths);
onChange(isSingleFilePicker ? filePaths[0] : filePaths);
})
.catch((error) => {
console.error(error);
@@ -42,14 +41,14 @@ const FilePickerEditor = ({ value, onChange, collection }) => {
};
const clear = () => {
onChange([]);
onChange(isSingleFilePicker ? '' : []);
};
const renderButtonText = (filenames) => {
if (filenames.length == 1) {
return filenames[0];
}
return filenames.length + ' files selected';
return filenames.length + ' file(s) selected';
};
return filenames.length > 0 ? (
@@ -66,9 +65,9 @@ const FilePickerEditor = ({ value, onChange, collection }) => {
</div>
) : (
<button className="btn btn-secondary px-1" style={{ width: '100%' }} onClick={browse}>
Select Files
{isSingleFilePicker ? 'Select File' : 'Select Files'}
</button>
);
};
export default FilePickerEditor;
export default FilePickerEditor;

View File

@@ -88,7 +88,7 @@ const VarsTable = ({ folder, collection, vars, varType }) => {
<td>
<div className="flex items-center">
<span>Expr</span>
<InfoTip text="You can write any valid JS expression here" infotipId="response-var" />
<InfoTip content="You can write any valid JS expression here" infotipId="response-var" />
</div>
</td>
)}

View File

@@ -0,0 +1,104 @@
const OpenApiLogo = () => {
return (
<svg width="28" height="28" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg">
<path
style={{
fill: "#91d400",
fillOpacity: 1,
fillRule: "nonzero",
stroke: "none"
}} d="M43.125 51.148H20.781l.012.325c.012.21.027.418.039.625.004.09.008.18.016.27a41.442 41.442 0 0 0 .164 1.687c0 .027.004.05.008.078.035.285.07.574.113.86 0 .003 0 .007.004.01a36.98 36.98 0 0 0 1.152 5.255c.004.008.008.012.008.02a27.978 27.978 0 0 0 .265.859c.004.015.012.031.016.047.078.242.164.484.246.73.024.059.043.121.067.184.074.207.148.418.226.629.04.093.074.187.11.285.07.172.136.343.203.52.054.128.11.257.164.39.054.133.113.27.168.406.074.164.148.328.218.492.047.102.09.2.133.297.09.195.184.395.278.59l.093.191c.11.227.223.45.336.672.02.035.035.07.051.102a36.344 36.344 0 0 0 .41.773c.028.051.059.102.086.149L44.45 56.156l.07-.043a15.031 15.031 0 0 1-1.394-4.965Zm0 0" transform="translate(-26.793 -.606) scale(1.44332)"
/>
<path
style={{
fill: "#91d400",
fillOpacity: 1,
fillRule: "nonzero",
stroke: "none"
}} d="m22.563 61.137-.727.207.008.02zm0 0" transform="translate(-26.793 -.606) scale(1.44332)"
/>
<path
style={{
fill: "#4c5930",
fillOpacity: 1,
fillRule: "nonzero",
stroke: "none"
}} d="m48.613 61.355-.05.055-15.739 15.664c.082.074.16.149.242.223.149.133.297.266.446.394.078.067.152.137.23.204.18.152.36.3.54.449.046.043.097.082.144.12a21.669 21.669 0 0 0 .691.556c.227.175.45.343.676.515.012.008.02.012.027.02.95.707 1.93 1.367 2.946 1.98.035.024.07.043.105.067l.578.34c.117.066.239.132.356.203.113.062.222.125.336.187.207.11.41.223.617.328a35.567 35.567 0 0 0 1.824.887l.559-1.348 7.918-19.133.027-.07a15.337 15.337 0 0 1-2.473-1.64Zm0 0" transform="translate(-26.793 -.606) scale(1.44332)"
/>
<path
style={{
fill: "#68a338",
fillOpacity: 1,
fillRule: "nonzero",
stroke: "none"
}} d="M46.977 59.797a16.778 16.778 0 0 1-.899-1.102c-.152-.203-.3-.406-.437-.617a15.93 15.93 0 0 1-.41-.633L26.124 68.902c.297.485.602.957.914 1.422.012.016.02.035.031.051l.012.016c.008.015.02.03.027.046.004.004.004.004.004.008.028.035.051.07.078.11 0 .004 0 .004.004.007v.004a35.121 35.121 0 0 0 1.051 1.465c.008.012.016.02.02.031.156.2.308.403.464.602.024.027.043.05.063.078.164.203.328.406.496.61.04.046.078.093.117.144.153.18.305.36.457.535.067.078.137.153.203.227.13.148.262.297.395.445.074.082.152.16.226.242.032.035.067.075.102.11l.293.316.121.121c.176.184.352.363.531.54l15.758-15.684a22.18 22.18 0 0 1-.515-.551Zm0 0" transform="translate(-26.793 -.606) scale(1.44332)"
/>
<path
style={{
fill: "#4c5930",
fillOpacity: 1,
fillRule: "nonzero",
stroke: "none"
}} d="M67.867 61.348c-.176.136-.347.273-.527.406l.039.066L78.87 80.805a38.54 38.54 0 0 0 1.57-1.078 38.099 38.099 0 0 0 3.227-2.653L67.93 61.41Zm0 0" transform="translate(-26.793 -.606) scale(1.44332)"
/>
<path
style={{
fill: "#91d400",
fillOpacity: 1,
fillRule: "nonzero",
stroke: "none"
}} d="m77.418 81.707.023-.012-.023.012zm.023-.012c.051-.027.102-.054.153-.086l-.004-.004c-.05.032-.098.06-.149.09zm-.023.012-.008.004zm-.039-.039.027.047zm.039.039.023-.012-.023.012zm-.016.012.004-.004zm.008-.009-.004.005c.004-.004.008-.004.012-.008-.004.004-.004.004-.008.004zm0 0" transform="translate(-26.793 -.606) scale(1.44332)"
/>
<path
style={{
fill: "#91d400",
fillOpacity: 1,
fillRule: "nonzero",
stroke: "none"
}} d="M77.441 81.695c.051-.03.102-.054.153-.086-.051.032-.102.059-.153.086zm.149-.09.004.004zm-.2.118.005-.004zm-.19-.763-.391-.644-10.727-17.722c-.215.133-.437.25-.66.367-.223.121-.45.23-.676.34a15.303 15.303 0 0 1-6.523 1.469c-1.465 0-2.93-.211-4.344-.63-.238-.074-.473-.167-.711-.25-.238-.085-.48-.156-.715-.253l-7.91 19.117-.309.75-.265.644h-.004l.062.024c.024.012.043.016.067.027h.004c.004 0 .007.004.011.004.188.078.375.145.563.215.238.094.473.184.707.27.121.042.238.097.36.136a38.13 38.13 0 0 0 7.648 1.824c.105.012.207.028.308.04l.32.035c.2.023.4.047.602.066l.149.012c.25.023.496.043.742.062.082.004.168.008.25.016.219.016.433.027.648.039.133.004.266.008.399.016.172.004.343.011.515.015.246.008.496.008.746.012h.176c2.082 0 4.164-.172 6.223-.516l.105-.015c.215-.04.434-.078.653-.117.125-.024.246-.047.37-.075.126-.023.255-.05.384-.078.21-.043.421-.09.632-.14l.118-.024a37.8 37.8 0 0 0 8.992-3.34c.187-.097.367-.207.554-.308.22-.121.438-.246.657-.371.152-.086.308-.164.457-.254l.004-.004h.004l.003-.004c.004 0 .004 0 .004-.004l-.027-.047.027.047h.004c.004-.004.008-.004.008-.004.008-.008.016-.012.023-.016.051-.03.102-.058.149-.09zM48.625 37.938c.172-.141.348-.274.523-.407l-.039-.066L37.621 18.48c-.535.348-1.062.704-1.578 1.082a37.213 37.213 0 0 0-3.219 2.649l15.739 15.664Zm0 0" transform="translate(-26.793 -.606) scale(1.44332)"
/>
<path
style={{
fill: "#4c5930",
fillOpacity: 1,
fillRule: "nonzero",
stroke: "none"
}} d="M31.73 23.254c-.18.18-.347.363-.523.543-.172.18-.352.36-.523.543a38.163 38.163 0 0 0-3.32 4.125c-.106.156-.216.312-.321.469-.11.164-.219.328-.324.496-.04.058-.078.12-.117.18a37.099 37.099 0 0 0-5.82 18.527c-.009.25-.016.504-.02.754s-.012.5-.012.75h22.29c0-.25.023-.5.034-.75.012-.254.016-.504.043-.754a15.002 15.002 0 0 1 3.36-8.078c.16-.192.34-.375.507-.559.172-.188.329-.379.508-.559zm45.993-5.508a46.43 46.43 0 0 0-.684-.402c-.117-.067-.23-.133-.348-.196-.117-.066-.23-.128-.347-.195-.2-.11-.403-.219-.606-.324-.031-.016-.062-.031-.093-.05a38.014 38.014 0 0 0-4.02-1.798c-.035-.015-.074-.027-.11-.039a37.527 37.527 0 0 0-8.41-2.102c-.101-.015-.207-.027-.312-.042l-.313-.036a31.754 31.754 0 0 0-.777-.078c-.238-.023-.48-.043-.719-.062-.093-.004-.187-.012-.28-.016-.204-.015-.415-.027-.618-.039l-.328-.012v22.239c1.144.117 2.281.363 3.383.734l16.445-16.367a41.117 41.117 0 0 0-1.863-1.215zm0 0" transform="translate(-26.793 -.606) scale(1.44332)"
/>
<path
style={{
fill: "#68a338",
fillOpacity: 1,
fillRule: "nonzero",
stroke: "none"
}} d="m38.898 17.68.391.644zm18.59-5.34c-.25.004-.504.004-.754.015a38.117 38.117 0 0 0-4.71.48c-.036.009-.07.013-.106.02-.219.036-.434.079-.652.118l-.371.07c-.13.027-.258.05-.383.082a36.99 36.99 0 0 0-.75.164 37.925 37.925 0 0 0-8.996 3.336c-.184.098-.368.21-.551.312-.219.118-.438.243-.66.368-.16.093-.325.18-.489.277h-.003a.162.162 0 0 1-.036.02c-.043.027-.086.046-.129.074l.004.004.387.644 11.117 18.367c.215-.132.438-.25.66-.367a15.15 15.15 0 0 1 5.668-1.73c.25-.028.5-.047.754-.063.25-.011.504-.023.758-.023V12.324c-.254 0-.504.008-.758.016zm0 0" transform="translate(-26.793 -.606) scale(1.44332)"
/>
<path
style={{
fill: "#4c5930",
fillOpacity: 1,
fillRule: "nonzero",
stroke: "none"
}} d="m95.695 47.809-.035-.598c-.008-.102-.012-.2-.02-.3l-.058-.704c-.008-.059-.012-.121-.016-.18a53.501 53.501 0 0 0-.086-.785c-.003-.023-.003-.043-.007-.062 0-.012 0-.02-.004-.032-.035-.28-.07-.562-.114-.843 0-.012 0-.02-.003-.028a36.772 36.772 0 0 0-1.153-5.246 47.929 47.929 0 0 0-.258-.832c-.011-.035-.023-.07-.03-.105-.083-.242-.161-.48-.247-.719-.023-.063-.043-.129-.066-.195a39.302 39.302 0 0 0-.34-.91c-.067-.172-.133-.344-.2-.512-.054-.137-.109-.27-.163-.403-.055-.132-.114-.261-.168-.394l-.223-.504-.129-.285c-.09-.2-.188-.402-.281-.602-.032-.058-.059-.12-.086-.18-.113-.23-.227-.456-.34-.683-.016-.031-.031-.062-.05-.094-.13-.25-.259-.5-.395-.746l-.012-.023a36.958 36.958 0 0 0-2.133-3.446L72.628 44.77c.376 1.097.618 2.23.735 3.37h22.344l-.012-.331zm0 0" transform="translate(-26.793 -.606) scale(1.44332)"
/>
<path
style={{
fill: "#68a338",
fillOpacity: 1,
fillRule: "nonzero",
stroke: "none"
}} d="M73.45 49.64c0 .255-.024.505-.036.755-.012.25-.016.503-.043.753a15.002 15.002 0 0 1-3.36 8.079c-.16.191-.335.375-.507.558-.168.188-.328.38-.508.559L84.758 76.03c.18-.18.347-.363.523-.543.172-.183.352-.363.52-.547a36.965 36.965 0 0 0 3.195-3.937c.04-.055.074-.11.11-.16.117-.168.234-.34.347-.508.102-.152.203-.3.3-.453.048-.074.095-.153.142-.223a37.055 37.055 0 0 0 5.808-18.515c.012-.25.016-.5.024-.75.003-.25.011-.5.011-.754zm0 0" transform="translate(-26.793 -.606) scale(1.44332)"
/>
<path
style={{
fill: "#3f3f42",
fillOpacity: 1,
fillRule: "nonzero",
stroke: "none"
}} d="M102.36 5.734c-4.079-4.058-10.688-4.058-14.766 0-3.254 3.235-3.903 8.075-1.965 11.961l-22.746 22.64c-3.906-1.925-8.77-1.28-12.024 1.958-4.078 4.059-4.074 10.64 0 14.7 4.082 4.058 10.692 4.054 14.77 0a10.356 10.356 0 0 0 1.965-11.966l22.746-22.64c3.906 1.93 8.765 1.281 12.02-1.957a10.355 10.355 0 0 0 0-14.696zm0 0" transform="translate(-26.793 -.606) scale(1.44332)"
/>
</svg>
);
};
export default OpenApiLogo;

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { Tooltip as ReactInfoTip } from 'react-tooltip';
const InfoTip = ({ text, infotipId }) => {
const InfoTip = ({ html: _ignored, infotipId, ...props }) => {
return (
<>
<svg
@@ -17,7 +17,7 @@ const InfoTip = ({ text, infotipId }) => {
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z" />
<path d="M5.255 5.786a.237.237 0 0 0 .241.247h.825c.138 0 .248-.113.266-.25.09-.656.54-1.134 1.342-1.134.686 0 1.314.343 1.314 1.168 0 .635-.374.927-.965 1.371-.673.489-1.206 1.06-1.168 1.987l.003.217a.25.25 0 0 0 .25.246h.811a.25.25 0 0 0 .25-.25v-.105c0-.718.273-.927 1.01-1.486.609-.463 1.244-.977 1.244-2.056 0-1.511-1.276-2.241-2.673-2.241-1.267 0-2.655.59-2.75 2.286zm1.557 5.763c0 .533.425.927 1.01.927.609 0 1.028-.394 1.028-.927 0-.552-.42-.94-1.029-.94-.584 0-1.009.388-1.009.94z" />
</svg>
<ReactInfoTip anchorId={infotipId} html={text} />
<ReactInfoTip anchorId={infotipId} {...props} />
</>
);
};

View File

@@ -15,14 +15,14 @@ const StyledMarkdownBodyWrapper = styled.div`
margin: 0.67em 0;
font-weight: var(--base-text-weight-semibold, 600);
padding-bottom: 0.3em;
font-size: 1.3;
font-size: 1.3em;
border-bottom: 1px solid var(--color-border-muted);
}
h2 {
font-weight: var(--base-text-weight-semibold, 600);
padding-bottom: 0.3em;
font-size: 1.2;
font-size: 1.2em;
border-bottom: 1px solid var(--color-border-muted);
}

View File

@@ -3,6 +3,7 @@ import { useState } from 'react';
import StyledWrapper from './StyleWrapper';
import Modal from 'components/Modal/index';
import { useEffect } from 'react';
import { useApp } from 'providers/App';
import {
fetchNotifications,
markAllNotificationsAsRead,
@@ -11,18 +12,18 @@ import {
import { useDispatch, useSelector } from 'react-redux';
import { humanizeDate, relativeDate } from 'utils/common';
import ToolHint from 'components/ToolHint';
import { useTheme } from 'providers/Theme';
import DOMPurify from 'dompurify';
const PAGE_SIZE = 5;
const Notifications = () => {
const dispatch = useDispatch();
const { version } = useApp();
const notifications = useSelector((state) => state.notifications.notifications);
const [showNotificationsModal, setShowNotificationsModal] = useState(false);
const [selectedNotification, setSelectedNotification] = useState(null);
const [pageNumber, setPageNumber] = useState(1);
const { storedTheme } = useTheme();
const notificationsStartIndex = (pageNumber - 1) * PAGE_SIZE;
const notificationsEndIndex = pageNumber * PAGE_SIZE;
@@ -30,7 +31,9 @@ const Notifications = () => {
const unreadNotifications = notifications.filter((notification) => !notification.read);
useEffect(() => {
dispatch(fetchNotifications());
dispatch(fetchNotifications({
currentVersion: version
}));
}, []);
useEffect(() => {
@@ -66,6 +69,13 @@ const Notifications = () => {
dispatch(markNotificationAsRead({ notificationId: notification?.id }));
};
const getSanitizedDescription = (description) => {
return DOMPurify.sanitize(encodeURIComponent(description), {
ALLOWED_TAGS: ['a', 'ul', 'img', 'li', 'div', 'span', 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'],
ALLOWED_ATTR: ['href', 'style', 'target', 'src', 'alt']
});
};
const modalCustomHeader = (
<div className="flex flex-row gap-8">
<div>NOTIFICATIONS</div>
@@ -90,7 +100,9 @@ const Notifications = () => {
<a
className="relative cursor-pointer"
onClick={() => {
dispatch(fetchNotifications());
dispatch(fetchNotifications({
currentVersion: version
}));
setShowNotificationsModal(true);
}}
aria-label="Check all Notifications"
@@ -179,10 +191,11 @@ const Notifications = () => {
<div className="w-full notification-date text-xs mb-4">
{humanizeDate(selectedNotification?.date)}
</div>
<div
className="flex w-full flex-col flex-wrap h-fit"
dangerouslySetInnerHTML={{ __html: selectedNotification?.description }}
></div>
<iframe
src={`data:text/html,${getSanitizedDescription(selectedNotification?.description)}`}
sandbox="allow-popups"
style={{ width: '100%', height: '100%' }}
></iframe>
</div>
</div>
) : (

View File

@@ -0,0 +1,65 @@
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.table.border};
}
thead {
color: ${(props) => props.theme.table.thead.color};
font-size: 0.8125rem;
user-select: none;
}
td {
padding: 6px 10px;
&:nth-child(1) {
width: 30%;
}
&:nth-child(2) {
width: 45%;
}
&:nth-child(3) {
width: 25%;
}
&:nth-child(4) {
width: 70px;
}
}
}
.btn-add-param {
font-size: 0.8125rem;
}
input[type='text'] {
width: 100%;
border: solid 1px transparent;
outline: none !important;
color: ${(props) => props.theme.table.input.color};
background: transparent;
&:focus {
outline: none !important;
border: solid 1px transparent;
}
}
input[type='radio'] {
cursor: pointer;
position: relative;
top: 1px;
}
`;
export default Wrapper;

View File

@@ -0,0 +1,164 @@
import React, { useState, useEffect } from 'react';
import { get, cloneDeep, isArray } from 'lodash';
import { IconTrash } from '@tabler/icons';
import { useDispatch } from 'react-redux';
import { useTheme } from 'providers/Theme';
import { addFile as _addFile, updateFile, deleteFile } from 'providers/ReduxStore/slices/collections/index';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
import FilePickerEditor from 'components/FilePickerEditor/index';
import SingleLineEditor from 'components/SingleLineEditor/index';
const FileBody = ({ item, collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const params = item.draft ? get(item, 'draft.request.body.file') : get(item, 'request.body.file');
const [enabledFileUid, setEnableFileUid] = useState(params && params.length ? params[0].uid : '');
const addFile = () => {
dispatch(
_addFile({
itemUid: item.uid,
collectionUid: collection.uid,
})
);
};
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
const handleRun = () => dispatch(sendRequest(item, collection.uid));
const handleParamChange = (e, _param, type) => {
const param = cloneDeep(_param);
switch (type) {
case 'filePath': {
param.filePath = e.target.filePath;
param.contentType = "";
break;
}
case 'contentType': {
param.contentType = e.target.contentType;
break;
}
case 'selected': {
param.selected = e.target.selected;
setEnableFileUid(param.uid)
break;
}
}
dispatch(
updateFile({
param: param,
itemUid: item.uid,
collectionUid: collection.uid
})
);
};
const handleRemoveParams = (param) => {
dispatch(
deleteFile({
paramUid: param.uid,
itemUid: item.uid,
collectionUid: collection.uid
})
);
};
return (
<StyledWrapper className="w-full">
<table>
<thead>
<tr>
<td>
<div className="flex items-center justify-center">File</div>
</td>
<td>
<div className="flex items-center justify-center">Content-Type</div>
</td>
<td>
<div className="flex items-center justify-center">Selected</div>
</td>
<td></td>
</tr>
</thead>
<tbody>
{params && params.length
? params.map((param, index) => {
return (
<tr key={param.uid}>
<td>
<FilePickerEditor
isSingleFilePicker={true}
value={param.filePath}
onChange={(path) =>
handleParamChange(
{
target: {
filePath: path
}
},
param,
'filePath'
)
}
collection={collection}
/>
</td>
<td>
<SingleLineEditor
className="flex items-center justify-center"
onSave={onSave}
theme={storedTheme}
placeholder="Auto"
value={param.contentType}
onChange={(newValue) =>
handleParamChange(
{
target: {
contentType: newValue
}
},
param,
'contentType'
)
}
onRun={handleRun}
collection={collection}
/>
</td>
<td>
<div className="flex items-center justify-center">
<input
key={param.uid}
type="radio"
name="selected"
checked={enabledFileUid === param.uid || param.selected}
tabIndex="-1"
className="mr-1 mousetrap"
onChange={(e) => handleParamChange(e, param, 'selected')}
/>
</div>
</td>
<td>
<div className="flex items-center justify-center">
<button tabIndex="-1" onClick={() => handleRemoveParams(param)}>
<IconTrash strokeWidth={1.5} size={20} />
</button>
</div>
</td>
</tr>
);
})
: null}
</tbody>
</table>
<div>
<button className="btn-add-param text-link pr-2 pt-3 select-none" onClick={addFile}>
+ Add File
</button>
</div>
</StyledWrapper>
);
};
export default FileBody;

View File

@@ -154,7 +154,7 @@ const GraphQLRequestPane = ({ item, collection, leftPaneWidth, onSchemaLoad, tog
</div>
<GraphQLSchemaActions item={item} collection={collection} onSchemaLoad={setSchema} toggleDocs={toggleDocs} />
</div>
<section className="flex w-full mt-5 flex-1">{getTabPanel(focusedTab.requestPaneTab)}</section>
<section className="flex w-full mt-5 flex-1 relative">{getTabPanel(focusedTab.requestPaneTab)}</section>
</StyledWrapper>
);
};

View File

@@ -49,7 +49,7 @@ const GraphQLVariables = ({ variables, item, collection }) => {
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
return (
<StyledWrapper className="w-full relative">
<>
<button
className="btn-add-param text-link px-4 py-4 select-none absolute top-0 right-0 z-10"
onClick={onPrettify}
@@ -68,7 +68,7 @@ const GraphQLVariables = ({ variables, item, collection }) => {
onRun={onRun}
onSave={onSave}
/>
</StyledWrapper>
</>
);
};

View File

@@ -15,6 +15,7 @@ import Tests from 'components/RequestPane/Tests';
import StyledWrapper from './StyledWrapper';
import { find, get } from 'lodash';
import Documentation from 'components/Documentation/index';
import { useEffect } from 'react';
const ContentIndicator = () => {
return (
@@ -24,6 +25,14 @@ const ContentIndicator = () => {
);
};
const ErrorIndicator = () => {
return (
<sup className="ml-[.125rem] opacity-80 font-medium text-red-500">
<DotIcon width="10" ></DotIcon>
</sup>
);
};
const HttpRequestPane = ({ item, collection, leftPaneWidth }) => {
const dispatch = useDispatch();
const tabs = useSelector((state) => state.tabs.tabs);
@@ -111,6 +120,12 @@ const HttpRequestPane = ({ item, collection, leftPaneWidth }) => {
requestVars.filter((request) => request.enabled).length +
responseVars.filter((response) => response.enabled).length;
useEffect(() => {
if (activeParamsLength === 0 && body.mode !== 'none') {
selectTab('body');
}
}, []);
return (
<StyledWrapper className="flex flex-col h-full relative">
<div className="flex flex-wrap items-center tabs" role="tablist">
@@ -136,7 +151,11 @@ const HttpRequestPane = ({ item, collection, leftPaneWidth }) => {
</div>
<div className={getTabClassname('script')} role="tab" onClick={() => selectTab('script')}>
Script
{(script.req || script.res) && <ContentIndicator />}
{(script.req || script.res) && (
item.preScriptResponseErrorMessage || item.postResponseScriptErrorMessage ?
<ErrorIndicator /> :
<ContentIndicator />
)}
</div>
<div className={getTabClassname('assert')} role="tab" onClick={() => selectTab('assert')}>
Assert

View File

@@ -176,8 +176,7 @@ const QueryParams = ({ item, collection }) => {
</button>
<div className="mb-2 title text-xs flex items-stretch">
<span>Path</span>
<InfoTip
text={`
<InfoTip infotipId="path-param-InfoTip">
<div>
Path variables are automatically added whenever the
<code className="font-mono mx-2">:name</code>
@@ -186,9 +185,7 @@ const QueryParams = ({ item, collection }) => {
https://example.com/v1/users/<span>:id</span>
</code>
</div>
`}
infotipId="path-param-InfoTip"
/>
</InfoTip>
</div>
<table>
<thead>

View File

@@ -128,6 +128,15 @@ const RequestBodyMode = ({ item, collection }) => {
SPARQL
</div>
<div className="label-item font-medium">Other</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('file');
}}
>
File / Binary
</div>
<div
className="dropdown-item"
onClick={() => {

View File

@@ -8,6 +8,7 @@ import { useTheme } from 'providers/Theme';
import { updateRequestBody } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
import FileBody from '../FileBody/index';
const RequestBody = ({ item, collection }) => {
const dispatch = useDispatch();
@@ -62,6 +63,10 @@ const RequestBody = ({ item, collection }) => {
);
}
if (bodyMode === 'file') {
return <FileBody item={item} collection={collection}/>
}
if (bodyMode === 'formUrlEncoded') {
return <FormUrlEncodedParams item={item} collection={collection} />;
}
@@ -72,4 +77,4 @@ const RequestBody = ({ item, collection }) => {
return <StyledWrapper className="w-full">No Body</StyledWrapper>;
};
export default RequestBody;
export default RequestBody;

View File

@@ -1,10 +0,0 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
div.CodeMirror {
/* todo: find a better way */
height: calc(100vh - 220px);
}
`;
export default StyledWrapper;

View File

@@ -5,7 +5,6 @@ import CodeEditor from 'components/CodeEditor';
import { updateRequestTests } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { useTheme } from 'providers/Theme';
import StyledWrapper from './StyledWrapper';
const Tests = ({ item, collection }) => {
const dispatch = useDispatch();
@@ -28,19 +27,17 @@ const Tests = ({ item, collection }) => {
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
return (
<StyledWrapper className="w-full">
<CodeEditor
collection={collection}
value={tests || ''}
theme={displayedTheme}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
onEdit={onEdit}
mode="javascript"
onRun={onRun}
onSave={onSave}
/>
</StyledWrapper>
<CodeEditor
collection={collection}
value={tests || ''}
theme={displayedTheme}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
onEdit={onEdit}
mode="javascript"
onRun={onRun}
onSave={onSave}
/>
);
};

View File

@@ -98,7 +98,7 @@ const VarsTable = ({ item, collection, vars, varType }) => {
) : (
<div className="flex items-center">
<span>Expr</span>
<InfoTip text="You can write any valid JS expression here" infotipId="response-var" />
<InfoTip content="You can write any valid JS expression here" infotipId="response-var" />
</div>
), accessor: 'value', width: '46%' },
{ name: '', accessor: '', width: '14%' }

View File

@@ -1,4 +1,4 @@
import { IconLoader2, IconFile } from '@tabler/icons';
import { IconLoader2, IconFile, IconAlertTriangle } from '@tabler/icons';
import { loadRequest, loadRequestViaWorker } from 'providers/ReduxStore/slices/collections/actions';
import { useDispatch } from 'react-redux';
import StyledWrapper from './StyledWrapper';
@@ -15,65 +15,59 @@ const RequestNotLoaded = ({ collection, item }) => {
return <StyledWrapper>
<div className='flex flex-col p-4'>
<div className='card shadow-sm rounded-md p-4 w-[600px]'>
<div className='card shadow-sm rounded-md p-4 w-[685px]'>
<div>
<div className='font-medium flex items-center gap-2 pb-4'>
<IconFile size={16} strokeWidth={1.5} className="text-gray-400" />
File Info
</div>
<div className='hr'/>
<div className='hr' />
<div className='flex items-center mt-2'>
<span className='w-12 mr-2 text-muted'>Name:</span>
<div>{item?.name}</div>
</div>
<div className='flex items-center mt-1'>
<span className='w-12 mr-2 text-muted'>Path:</span>
<div className='break-all'>{item?.pathname}</div>
</div>
<div className='flex items-center mt-1 pb-4'>
<span className='w-12 mr-2 text-muted'>Size:</span>
<div>{item?.size?.toFixed?.(2)} MB</div>
</div>
{!item?.error && (
<>
<div className='hr'/>
<div className='text-muted text-xs mt-4 mb-2'>
Due to its large size, this request wasn't loaded automatically.
<div className='flex flex-col'>
<div className='flex items-center gap-2 px-3 py-2 title bg-yellow-50 dark:bg-yellow-900/20'>
<IconAlertTriangle size={16} className="text-yellow-500" />
<span>The request wasn't loaded due to its large size. Please try again with the following options:</span>
</div>
<div className='flex flex-col gap-6 mt-4'>
<div className='flex flex-col'>
<button
className={`submit btn btn-sm btn-secondary w-fit h-fit flex flex-row gap-2 ${item?.loading? 'opacity-50 cursor-blocked': ''}`}
onClick={handleLoadRequest}
>
Load Request
</button>
<small className='text-muted mt-1'>
May cause the app to freeze temporarily while it runs.
</small>
</div>
<div className='flex flex-col'>
<div className='flex flex-row mt-6 gap-2 items-center w-full'>
<button
className={`submit btn btn-sm btn-secondary w-fit h-fit flex flex-row gap-2 ${item?.loading? 'opacity-50 cursor-blocked': ''}`}
onClick={handleLoadRequestViaWorker}
>
Load Request in Background
Load in background
</button>
<small className='text-muted mt-1'>
Runs in background.
</small>
</div>
<p>(Runs in background)</p>
</div>
</>
<div className='flex flex-row mt-6 items-center gap-2 w-full'>
<button
className={`submit btn btn-sm btn-secondary w-fit h-fit flex flex-row gap-2 ${item?.loading? 'opacity-50 cursor-blocked': ''}`}
onClick={handleLoadRequest}
>
Force load
</button>
<p>(May cause the app to freeze temporarily while it runs)</p>
</div>
</div>
)}
{item?.loading && (
<>
<div className='hr mt-4'/>
<div className='hr mt-4' />
<div className='flex items-center gap-2 mt-4'>
<IconLoader2 className="animate-spin" size={16} strokeWidth={2} />
<span>Loading...</span>

View File

@@ -35,7 +35,7 @@ const CollectionToolBar = ({ collection }) => {
const viewCollectionSettings = () => {
dispatch(
addTab({
uid: uuid(),
uid: collection.uid,
collectionUid: collection.uid,
type: 'collection-settings'
})

View File

@@ -2,15 +2,15 @@ import React from 'react';
import CloseTabIcon from './CloseTabIcon';
import { IconVariable, IconSettings, IconRun, IconFolder, IconShieldLock } from '@tabler/icons';
const SpecialTab = ({ handleCloseClick, type, tabName }) => {
const SpecialTab = ({ handleCloseClick, type, tabName, handleDoubleClick }) => {
const getTabInfo = (type, tabName) => {
switch (type) {
case 'collection-settings': {
return (
<>
<div onDoubleClick={handleDoubleClick} className="flex items-center flex-nowrap overflow-hidden">
<IconSettings size={18} strokeWidth={1.5} className="text-yellow-600" />
<span className="ml-1 leading-6">Collection</span>
</>
</div>
);
}
case 'collection-overview': {
@@ -31,7 +31,7 @@ const SpecialTab = ({ handleCloseClick, type, tabName }) => {
}
case 'folder-settings': {
return (
<div className="flex items-center flex-nowrap overflow-hidden">
<div onDoubleClick={handleDoubleClick} className="flex items-center flex-nowrap overflow-hidden">
<IconFolder size={18} strokeWidth={1.5} className="text-yellow-600 min-w-[18px]" />
<span className="ml-1 leading-6 truncate">{tabName || 'Folder'}</span>
</div>

View File

@@ -1,6 +1,6 @@
import React, { useState, useRef, Fragment } from 'react';
import get from 'lodash/get';
import { closeTabs } from 'providers/ReduxStore/slices/tabs';
import { closeTabs, makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { deleteRequestDraft } from 'providers/ReduxStore/slices/collections';
import { useTheme } from 'providers/Theme';
@@ -73,13 +73,13 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
if (['collection-settings', 'collection-overview', 'folder-settings', 'variables', 'collection-runner', 'security-settings'].includes(tab.type)) {
return (
<StyledWrapper
className="flex items-center justify-between tab-container px-1"
className={`flex items-center justify-between tab-container px-1 ${tab.preview ? "italic" : ""}`}
onMouseUp={handleMouseUp} // Add middle-click behavior here
>
{tab.type === 'folder-settings' ? (
<SpecialTab handleCloseClick={handleCloseClick} type={tab.type} tabName={folder?.name} />
<SpecialTab handleCloseClick={handleCloseClick} handleDoubleClick={() => dispatch(makeTabPermanent({ uid: tab.uid }))} type={tab.type} tabName={folder?.name} />
) : (
<SpecialTab handleCloseClick={handleCloseClick} type={tab.type} />
<SpecialTab handleCloseClick={handleCloseClick} handleDoubleClick={() => dispatch(makeTabPermanent({ uid: tab.uid }))} type={tab.type} />
)}
</StyledWrapper>
);
@@ -144,8 +144,9 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
/>
)}
<div
className="flex items-baseline tab-label pl-2"
className={`flex items-baseline tab-label pl-2 ${tab.preview ? "italic" : ""}`}
onContextMenu={handleRightClick}
onDoubleClick={() => dispatch(makeTabPermanent({ uid: tab.uid }))}
onMouseUp={(e) => {
if (!item.draft) return handleMouseUp(e);

View File

@@ -3,48 +3,49 @@ import QueryResultFilter from './QueryResultFilter';
import { JSONPath } from 'jsonpath-plus';
import React from 'react';
import classnames from 'classnames';
import iconv from 'iconv-lite';
import { getContentType, safeStringifyJSON, safeParseXML } from 'utils/common';
import { getCodeMirrorModeBasedOnContentType } from 'utils/common/codemirror';
import QueryResultPreview from './QueryResultPreview';
import StyledWrapper from './StyledWrapper';
import { useState } from 'react';
import { useMemo } from 'react';
import { useEffect } from 'react';
import { useState, useMemo, useEffect } from 'react';
import { useTheme } from 'providers/Theme/index';
import { uuid } from 'utils/common/index';
import { getEncoding, prettifyJson, uuid } from 'utils/common/index';
const formatResponse = (data, mode, filter) => {
if (data === undefined) {
const formatResponse = (data, dataBuffer, encoding, mode, filter) => {
if (data === undefined || !dataBuffer) {
return '';
}
if (data === null) {
return 'null';
}
// TODO: We need a better way to get the raw response-data here instead
// of using this dataBuffer param.
// Also, we only need the raw response-data and content-type to show the preview.
const rawData = iconv.decode(
Buffer.from(dataBuffer, "base64"),
iconv.encodingExists(encoding) ? encoding : "utf-8"
);
if (mode.includes('json')) {
let isValidJSON = false;
try {
isValidJSON = typeof JSON.parse(JSON.stringify(data)) === 'object'
JSON.parse(rawData);
} catch (error) {
console.log('Error parsing JSON: ', error.message);
}
if (!isValidJSON && typeof data === 'string') {
return data;
// If the response content-type is JSON and it fails parsing, its an invalid JSON.
// In that case, just show the response as it is in the preview.
return rawData;
}
if (filter) {
try {
data = JSONPath({ path: filter, json: data });
return prettifyJson(JSON.stringify(data));
} catch (e) {
console.warn('Could not apply JSONPath filter:', e.message);
}
}
return safeStringifyJSON(data, true);
// Prettify the JSON string directly instead of parse->stringify to avoid
// issues like rounding numbers bigger than Number.MAX_SAFE_INTEGER etc.
return prettifyJson(rawData);
}
if (mode.includes('xml')) {
@@ -59,14 +60,27 @@ const formatResponse = (data, mode, filter) => {
return data;
}
return safeStringifyJSON(data, true);
return prettifyJson(rawData);
};
const formatErrorMessage = (error) => {
if (!error) return 'Something went wrong';
const remoteMethodError = "Error invoking remote method 'send-http-request':";
if (error.includes(remoteMethodError)) {
const parts = error.split(remoteMethodError);
return parts[1]?.trim() || error;
}
return error;
};
const QueryResult = ({ item, collection, data, dataBuffer, width, disableRunEventListener, headers, error }) => {
const contentType = getContentType(headers);
const mode = getCodeMirrorModeBasedOnContentType(contentType, data);
const [filter, setFilter] = useState(null);
const formattedData = formatResponse(data, mode, filter);
const formattedData = formatResponse(data, dataBuffer, getEncoding(headers), mode, filter);
const { displayedTheme } = useTheme();
const debouncedResultFilterOnChange = debounce((e) => {
@@ -121,6 +135,7 @@ const QueryResult = ({ item, collection, data, dataBuffer, width, disableRunEven
}, [allowedPreviewModes, previewTab]);
const queryFilterEnabled = useMemo(() => mode.includes('json'), [mode]);
const hasScriptError = item.preRequestScriptErrorMessage || item.postResponseScriptErrorMessage;
return (
<StyledWrapper
@@ -133,7 +148,7 @@ const QueryResult = ({ item, collection, data, dataBuffer, width, disableRunEven
</div>
{error ? (
<div>
<div className="text-red-500">{error}</div>
{hasScriptError ? null : <div className="text-red-500">{formatErrorMessage(error)}</div>}
{error && typeof error === 'string' && error.toLowerCase().includes('self signed certificate') ? (
<div className="mt-6 muted text-xs">
@@ -143,24 +158,26 @@ const QueryResult = ({ item, collection, data, dataBuffer, width, disableRunEven
) : null}
</div>
) : (
<>
<QueryResultPreview
previewTab={previewTab}
data={data}
dataBuffer={dataBuffer}
formattedData={formattedData}
item={item}
contentType={contentType}
mode={mode}
collection={collection}
allowedPreviewModes={allowedPreviewModes}
disableRunEventListener={disableRunEventListener}
displayedTheme={displayedTheme}
/>
{queryFilterEnabled && (
<QueryResultFilter filter={filter} onChange={debouncedResultFilterOnChange} mode={mode} />
)}
</>
<div className="h-full flex flex-col">
<div className="flex-1 relative">
<QueryResultPreview
previewTab={previewTab}
data={data}
dataBuffer={dataBuffer}
formattedData={formattedData}
item={item}
contentType={contentType}
mode={mode}
collection={collection}
allowedPreviewModes={allowedPreviewModes}
disableRunEventListener={disableRunEventListener}
displayedTheme={displayedTheme}
/>
{queryFilterEnabled && (
<QueryResultFilter filter={filter} onChange={debouncedResultFilterOnChange} mode={mode} />
)}
</div>
</div>
)}
</StyledWrapper>
);

View File

@@ -0,0 +1,55 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
border-left: 4px solid ${(props) => props.theme.colors.text.danger};
border-top: 1px solid transparent;
border-right: 1px solid transparent;
border-bottom: 1px solid transparent;
border-radius: 0.375rem;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
max-height: 200px;
min-height: 70px;
overflow-y: auto;
background-color: ${(props) => props.theme.bg === '#1e1e1e' ? 'rgba(40, 40, 40, 0.5)' : 'rgba(250, 250, 250, 0.9)'};
.error-icon-container {
margin-top: 0.125rem;
padding: 0.375rem;
border-radius: 9999px;
background-color: ${(props) => props.theme.bg === '#1e1e1e' ? 'rgba(40, 40, 40, 0.8)' : 'rgba(240, 240, 240, 0.8)'};
svg {
color: ${(props) => props.theme.colors.text.danger};
}
}
.close-button {
opacity: 0.7;
transition: opacity 0.2s;
&:hover {
opacity: 1;
}
svg {
color: ${(props) => props.theme.text};
}
}
.error-title {
font-weight: 600;
margin-bottom: 0.375rem;
color: ${(props) => props.theme.colors.text.danger};
}
.error-message {
font-family: monospace;
font-size: 0.6875rem;
line-height: 1.25rem;
white-space: pre-wrap;
word-break: break-all;
color: ${(props) => props.theme.text};
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,37 @@
import React from 'react';
import { IconX } from '@tabler/icons';
import StyledWrapper from './StyledWrapper';
const ScriptError = ({ item, onClose }) => {
const preRequestError = item?.preRequestScriptErrorMessage;
const postResponseError = item?.postResponseScriptErrorMessage;
if (!preRequestError && !postResponseError) return null;
const errorMessage = preRequestError || postResponseError;
const errorTitle = preRequestError ? 'Pre-Request Script Error' : 'Post-Response Script Error';
return (
<StyledWrapper className="mt-4 mb-2">
<div className="flex items-start gap-3 px-4 py-3">
<div className="flex-1 min-w-0">
<div className="error-title">
{errorTitle}
</div>
<div className="error-message">
{errorMessage}
</div>
</div>
<div
className="close-button flex-shrink-0 cursor-pointer"
onClick={onClose}
>
<IconX size={16} strokeWidth={1.5} />
</div>
</div>
</StyledWrapper>
);
};
export default ScriptError;

View File

@@ -0,0 +1,28 @@
import React from 'react';
import { IconAlertCircle } from '@tabler/icons';
import ToolHint from 'components/ToolHint';
const ScriptErrorIcon = ({ itemUid, onClick }) => {
const toolhintId = `script-error-icon-${itemUid}`;
return (
<>
<div
id={toolhintId}
className="cursor-pointer ml-2"
onClick={onClick}
>
<div className="flex items-center text-red-400">
<IconAlertCircle size={16} strokeWidth={1.5} className="stroke-current" />
</div>
</div>
<ToolHint
toolhintId={toolhintId}
text="Script execution error occurred"
place="bottom"
/>
</>
);
};
export default ScriptErrorIcon;

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useState, useEffect } from 'react';
import find from 'lodash/find';
import classnames from 'classnames';
import { useDispatch, useSelector } from 'react-redux';
@@ -13,6 +13,8 @@ import ResponseSize from './ResponseSize';
import Timeline from './Timeline';
import TestResults from './TestResults';
import TestResultsLabel from './TestResultsLabel';
import ScriptError from './ScriptError';
import ScriptErrorIcon from './ScriptErrorIcon';
import StyledWrapper from './StyledWrapper';
import ResponseSave from 'src/components/ResponsePane/ResponseSave';
import ResponseClear from 'src/components/ResponsePane/ResponseClear';
@@ -22,6 +24,13 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const isLoading = ['queued', 'sending'].includes(item.requestState);
const [showScriptErrorCard, setShowScriptErrorCard] = useState(false);
useEffect(() => {
if (item?.preRequestScriptErrorMessage || item?.postResponseScriptErrorMessage) {
setShowScriptErrorCard(true);
}
}, [item?.preRequestScriptErrorMessage, item?.postResponseScriptErrorMessage]);
const selectTab = (tab) => {
dispatch(
@@ -98,6 +107,8 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
};
const responseHeadersCount = typeof response.headers === 'object' ? Object.entries(response.headers).length : 0;
const hasScriptError = item?.preRequestScriptErrorMessage || item?.postResponseScriptErrorMessage;
return (
<StyledWrapper className="flex flex-col h-full relative">
@@ -117,6 +128,12 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
</div>
{!isLoading ? (
<div className="flex flex-grow justify-end items-center">
{hasScriptError && !showScriptErrorCard && (
<ScriptErrorIcon
itemUid={item.uid}
onClick={() => setShowScriptErrorCard(true)}
/>
)}
<ResponseClear item={item} collection={collection} />
<ResponseSave item={item} />
<StatusCode status={response.status} />
@@ -126,9 +143,15 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
) : null}
</div>
<section
className={`flex flex-grow relative pl-3 pr-4 ${focusedTab.responsePaneTab === 'response' ? '' : 'mt-4'}`}
className={`flex flex-col flex-grow relative pl-3 pr-4 ${focusedTab.responsePaneTab === 'response' ? '' : 'mt-4'}`}
>
{isLoading ? <Overlay item={item} collection={collection} /> : null}
{hasScriptError && showScriptErrorCard && (
<ScriptError
item={item}
onClose={() => setShowScriptErrorCard(false)}
/>
)}
{getTabPanel(focusedTab.responsePaneTab)}
</section>
</StyledWrapper>

View File

@@ -11,14 +11,14 @@ import ResponsePane from './ResponsePane';
import StyledWrapper from './StyledWrapper';
import { areItemsLoading } from 'utils/collections';
const getRelativePath = (fullPath, pathname) => {
const getDisplayName = (fullPath, pathname, name) => {
// convert to unix style path
fullPath = slash(fullPath);
pathname = slash(pathname);
let relativePath = path.relative(fullPath, pathname);
const { dir, name } = path.parse(relativePath);
return path.join(dir, name);
const { dir } = path.parse(relativePath);
return [dir, name].filter(i => i).join('/');
};
export default function RunnerResults({ collection }) {
@@ -58,7 +58,7 @@ export default function RunnerResults({ collection }) {
type: info.type,
filename: info.filename,
pathname: info.pathname,
relativePath: getRelativePath(collection.pathname, info.pathname)
displayName: getDisplayName(collection.pathname, info.pathname, info.name)
};
if (newItem.status !== 'error' && newItem.status !== 'skipped') {
if (newItem.testResults) {
@@ -186,7 +186,7 @@ export default function RunnerResults({ collection }) {
<span
className={`mr-1 ml-2 ${item.status == 'error' || item.status == 'skipped' || item.testStatus == 'fail' ? 'danger' : ''}`}
>
{item.relativePath}
{item.displayName}
</span>
{item.status !== 'error' && item.status !== 'skipped' && item.status !== 'completed' ? (
<IconRefresh className="animate-spin ml-1" size={18} strokeWidth={1.5} />
@@ -266,7 +266,7 @@ export default function RunnerResults({ collection }) {
<div className="flex flex-1 w-[50%]">
<div className="flex flex-col w-full overflow-auto">
<div className="flex items-center px-3 mb-4 font-medium">
<span className="mr-2">{selectedItem.relativePath}</span>
<span className="mr-2">{selectedItem.displayName}</span>
<span>
{selectedItem.testStatus === 'pass' ? (
<IconCircleCheck className="test-success" size={20} strokeWidth={1.5} />
@@ -275,7 +275,6 @@ export default function RunnerResults({ collection }) {
)}
</span>
</div>
{/* <div className='px-3 mb-4 font-medium'>{selectedItem.relativePath}</div> */}
<ResponsePane item={selectedItem} collection={collection} />
</div>
</div>

View File

@@ -0,0 +1,30 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.tabs {
.tab {
padding: 6px 0px;
border: none;
border-bottom: solid 2px transparent;
margin-right: 1.25rem;
color: var(--color-tab-inactive);
cursor: pointer;
&:focus,
&:active,
&:focus-within,
&:focus-visible,
&:target {
outline: none !important;
box-shadow: none !important;
}
&.active {
color: ${(props) => props.theme.tabs.active.color} !important;
border-bottom: solid 2px ${(props) => props.theme.tabs.active.border} !important;
}
}
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,60 @@
import React from 'react';
import Modal from 'components/Modal';
import { IconDownload } from '@tabler/icons';
import StyledWrapper from './StyledWrapper';
import Bruno from 'components/Bruno';
import exportBrunoCollection from 'utils/collections/export';
import exportPostmanCollection from 'utils/exporters/postman-collection';
import { cloneDeep } from 'lodash';
import { transformCollectionToSaveToExportAsFile } from 'utils/collections/index';
const ShareCollection = ({ onClose, collection }) => {
const handleExportBrunoCollection = () => {
const collectionCopy = cloneDeep(collection);
exportBrunoCollection(transformCollectionToSaveToExportAsFile(collectionCopy));
onClose();
};
const handleExportPostmanCollection = () => {
const collectionCopy = cloneDeep(collection);
exportPostmanCollection(collectionCopy);
onClose();
};
return (
<Modal
size="md"
title="Share Collection"
confirmText="Close"
handleConfirm={onClose}
handleCancel={onClose}
hideCancel
>
<StyledWrapper className="flex flex-col h-full w-[500px]">
<div className="space-y-2">
<div className="flex border border-gray-200 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-500/10 items-center p-3 rounded-lg transition-colors cursor-pointer" onClick={handleExportBrunoCollection}>
<div className="mr-3 p-1 rounded-full">
<Bruno width={28} />
</div>
<div className="flex-1">
<div className="font-medium">Bruno Collection</div>
<div className="text-xs">Export in Bruno format</div>
</div>
</div>
<div className="flex border border-gray-200 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-500/10 items-center p-3 rounded-lg transition-colors cursor-pointer" onClick={handleExportPostmanCollection}>
<div className="mr-3 p-1 rounded-full">
<IconDownload size={28} strokeWidth={1} className="" />
</div>
<div className="flex-1">
<div className="font-medium">Postman Collection</div>
<div className="text-xs">Export in Postman format</div>
</div>
</div>
</div>
</StyledWrapper>
</Modal>
);
};
export default ShareCollection;

View File

@@ -127,7 +127,7 @@ const CloneCollection = ({ onClose, collection }) => {
<label htmlFor="collection-folder-name" className="flex items-center mt-3">
<span className="font-semibold">Folder Name</span>
<InfoTip
text="This folder will be created under the selected location"
content="This folder will be created under the selected location"
infotipId="collection-folder-name-infotip"
/>
</label>

View File

@@ -5,8 +5,8 @@ import classnames from 'classnames';
import { useDrag, useDrop } from 'react-dnd';
import { IconChevronRight, IconDots } from '@tabler/icons';
import { useSelector, useDispatch } from 'react-redux';
import { addTab, focusTab } from 'providers/ReduxStore/slices/tabs';
import { moveItem, sendRequest } from 'providers/ReduxStore/slices/collections/actions';
import { addTab, focusTab, makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
import { moveItem, showInFolder, sendRequest } from 'providers/ReduxStore/slices/collections/actions';
import { collectionFolderClicked } from 'providers/ReduxStore/slices/collections';
import Dropdown from 'components/Dropdown';
import NewRequest from 'components/Sidebar/NewRequest';
@@ -23,13 +23,16 @@ import { hideHomePage } from 'providers/ReduxStore/slices/app';
import toast from 'react-hot-toast';
import StyledWrapper from './StyledWrapper';
import NetworkError from 'components/ResponsePane/NetworkError/index';
import CollectionItemIcon from './CollectionItemIcon/index';
import { findItemInCollection } from 'utils/collections';
import CollectionItemIcon from './CollectionItemIcon';
import { scrollToTheActiveTab } from 'utils/tabs';
const CollectionItem = ({ item, collection, searchText }) => {
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const isSidebarDragging = useSelector((state) => state.app.isDragging);
const dispatch = useDispatch();
const collectionItemRef = useRef(null);
const [renameItemModalOpen, setRenameItemModalOpen] = useState(false);
const [cloneItemModalOpen, setCloneItemModalOpen] = useState(false);
@@ -43,28 +46,31 @@ const CollectionItem = ({ item, collection, searchText }) => {
const itemIsCollapsed = hasSearchText ? false : item.collapsed;
const [{ isDragging }, drag] = useDrag({
type: `COLLECTION_ITEM_${collection.uid}`,
type: `collection-item-${collection.uid}`,
item: item,
collect: (monitor) => ({
isDragging: monitor.isDragging()
})
}),
options: {
dropEffect: "move"
}
});
const [{ isOver }, drop] = useDrop({
accept: `COLLECTION_ITEM_${collection.uid}`,
accept: `collection-item-${collection.uid}`,
drop: (draggedItem) => {
if (draggedItem.uid !== item.uid) {
dispatch(moveItem(collection.uid, draggedItem.uid, item.uid));
}
dispatch(moveItem(collection.uid, draggedItem.uid, item.uid));
},
canDrop: (draggedItem) => {
return draggedItem.uid !== item.uid;
},
collect: (monitor) => ({
isOver: monitor.isOver()
})
isOver: monitor.isOver(),
}),
});
drag(drop(collectionItemRef));
const dropdownTippyRef = useRef();
const MenuIcon = forwardRef((props, ref) => {
return (
@@ -83,13 +89,6 @@ const CollectionItem = ({ item, collection, searchText }) => {
'item-hovered': isOver
});
const scrollToTheActiveTab = () => {
const activeTab = document.querySelector('.request-tab.active');
if (activeTab) {
activeTab.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
};
const handleRun = async () => {
dispatch(sendRequest(item, collection.uid)).catch((err) =>
toast.custom((t) => <NetworkError onClose={() => toast.dismiss(t.id)} />, {
@@ -99,10 +98,13 @@ const CollectionItem = ({ item, collection, searchText }) => {
};
const handleClick = (event) => {
if (event.detail != 1) return;
//scroll to the active tab
setTimeout(scrollToTheActiveTab, 50);
if (isItemARequest(item)) {
const isRequest = isItemARequest(item);
if (isRequest) {
dispatch(hideHomePage());
if (itemIsOpenedInTabs(item, tabs)) {
dispatch(
@@ -112,20 +114,21 @@ const CollectionItem = ({ item, collection, searchText }) => {
);
return;
}
dispatch(
addTab({
uid: item.uid,
collectionUid: collection.uid,
requestPaneTab: getDefaultRequestPaneTab(item)
requestPaneTab: getDefaultRequestPaneTab(item),
type: 'request',
})
);
return;
}
} else {
dispatch(
addTab({
uid: item.uid,
collectionUid: collection.uid,
type: 'folder-settings'
type: 'folder-settings',
})
);
dispatch(
@@ -134,9 +137,12 @@ const CollectionItem = ({ item, collection, searchText }) => {
collectionUid: collection.uid
})
);
}
};
const handleFolderCollapse = () => {
const handleFolderCollapse = (e) => {
e.stopPropagation();
e.preventDefault();
dispatch(
collectionFolderClicked({
itemUid: item.uid,
@@ -156,10 +162,6 @@ const CollectionItem = ({ item, collection, searchText }) => {
}
};
const handleDoubleClick = (event) => {
setRenameItemModalOpen(true);
};
let indents = range(item.depth);
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const isFolder = isItemAFolder(item);
@@ -180,6 +182,10 @@ const CollectionItem = ({ item, collection, searchText }) => {
}
}
const handleDoubleClick = (event) => {
dispatch(makeTabPermanent({ uid: item.uid }))
};
// we need to sort request items by seq property
const sortRequestItems = (items = []) => {
return items.sort((a, b) => a.seq - b.seq);
@@ -220,6 +226,13 @@ const CollectionItem = ({ item, collection, searchText }) => {
}
};
const handleShowInFolder = () => {
dispatch(showInFolder(item.pathname)).catch((error) => {
console.error('Error opening the folder', error);
toast.error('Error opening the folder');
});
};
const requestItems = sortRequestItems(filter(item.items, (i) => isItemARequest(i)));
const folderItems = sortFolderItems(filter(item.items, (i) => isItemAFolder(i)));
@@ -246,7 +259,7 @@ const CollectionItem = ({ item, collection, searchText }) => {
{generateCodeItemModalOpen && (
<GenerateCodeItem collection={collection} item={item} onClose={() => setGenerateCodeItemModalOpen(false)} />
)}
<div className={itemRowClassName} ref={(node) => drag(drop(node))}>
<div className={itemRowClassName} ref={collectionItemRef}>
<div className="flex items-center h-full w-full">
{indents && indents.length
? indents.map((i) => {
@@ -273,6 +286,9 @@ const CollectionItem = ({ item, collection, searchText }) => {
style={{
paddingLeft: 8
}}
onClick={handleClick}
onContextMenu={handleRightClick}
onDoubleClick={handleDoubleClick}
>
<div style={{ width: 16, minWidth: 16 }}>
{isFolder ? (
@@ -288,9 +304,6 @@ const CollectionItem = ({ item, collection, searchText }) => {
<div
className="ml-1 flex w-full h-full items-center overflow-hidden"
onClick={handleClick}
onContextMenu={handleRightClick}
onDoubleClick={handleDoubleClick}
>
<CollectionItemIcon item={item} />
<span className="item-name" title={item.name}>
@@ -371,6 +384,15 @@ const CollectionItem = ({ item, collection, searchText }) => {
Generate Code
</div>
)}
<div
className="dropdown-item"
onClick={(e) => {
dropdownTippyRef.current.hide();
handleShowInFolder();
}}
>
Show in Folder
</div>
<div
className="dropdown-item delete-item"
onClick={(e) => {

View File

@@ -12,6 +12,17 @@ const Wrapper = styled.div`
transform: rotateZ(90deg);
}
&.item-hovered {
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
.collection-actions {
.dropdown {
div[aria-expanded='false'] {
visibility: visible;
}
}
}
}
.collection-actions {
.dropdown {
div[aria-expanded='true'] {

View File

@@ -2,35 +2,37 @@ import React, { useState, forwardRef, useRef, useEffect } from 'react';
import classnames from 'classnames';
import { uuid } from 'utils/common';
import filter from 'lodash/filter';
import { useDrop } from 'react-dnd';
import { useDrop, useDrag } from 'react-dnd';
import { IconChevronRight, IconDots, IconLoader2 } from '@tabler/icons';
import Dropdown from 'components/Dropdown';
import { collapseCollection } from 'providers/ReduxStore/slices/collections';
import { mountCollection, moveItemToRootOfCollection } from 'providers/ReduxStore/slices/collections/actions';
import { useDispatch } from 'react-redux';
import { addTab } from 'providers/ReduxStore/slices/tabs';
import { mountCollection, moveItemToRootOfCollection, moveCollectionAndPersist } from 'providers/ReduxStore/slices/collections/actions';
import { useDispatch, useSelector } from 'react-redux';
import { addTab, makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
import NewRequest from 'components/Sidebar/NewRequest';
import NewFolder from 'components/Sidebar/NewFolder';
import CollectionItem from './CollectionItem';
import RemoveCollection from './RemoveCollection';
import ExportCollection from './ExportCollection';
import { doesCollectionHaveItemsMatchingSearchText } from 'utils/collections/search';
import { isItemAFolder, isItemARequest } from 'utils/collections';
import RenameCollection from './RenameCollection';
import StyledWrapper from './StyledWrapper';
import CloneCollection from './CloneCollection';
import { areItemsLoading } from 'utils/collections';
import { areItemsLoading, findItemInCollection } from 'utils/collections';
import { scrollToTheActiveTab } from 'utils/tabs';
import ShareCollection from 'components/ShareCollection/index';
const Collection = ({ collection, searchText }) => {
const [showNewFolderModal, setShowNewFolderModal] = useState(false);
const [showNewRequestModal, setShowNewRequestModal] = useState(false);
const [showRenameCollectionModal, setShowRenameCollectionModal] = useState(false);
const [showCloneCollectionModalOpen, setShowCloneCollectionModalOpen] = useState(false);
const [showExportCollectionModal, setShowExportCollectionModal] = useState(false);
const [showShareCollectionModal, setShowShareCollectionModal] = useState(false);
const [showRemoveCollectionModal, setShowRemoveCollectionModal] = useState(false);
const dispatch = useDispatch();
const isLoading = areItemsLoading(collection);
const collectionRef = useRef(null);
const menuDropdownTippyRef = useRef();
const onMenuDropdownCreate = (ref) => (menuDropdownTippyRef.current = ref);
@@ -52,6 +54,16 @@ const Collection = ({ collection, searchText }) => {
);
};
const ensureCollectionIsMounted = () => {
if (collection.mountStatus === 'unmounted') {
dispatch(mountCollection({
collectionUid: collection.uid,
collectionPathname: collection.pathname,
brunoConfig: collection.brunoConfig
}));
}
}
const hasSearchText = searchText && searchText?.trim()?.length;
const collectionIsCollapsed = hasSearchText ? false : collection.collapsed;
@@ -60,30 +72,37 @@ const Collection = ({ collection, searchText }) => {
});
const handleClick = (event) => {
if (event.detail != 1) return;
// Check if the click came from the chevron icon
const isChevronClick = event.target.closest('svg')?.classList.contains('chevron-icon');
if (collection.mountStatus === 'unmounted') {
dispatch(mountCollection({
collectionUid: collection.uid,
collectionPathname: collection.pathname,
brunoConfig: collection.brunoConfig
}));
}
dispatch(collapseCollection(collection.uid));
setTimeout(scrollToTheActiveTab, 50);
// Only open collection settings if not clicking the chevron
ensureCollectionIsMounted();
dispatch(collapseCollection(collection.uid));
if(!isChevronClick) {
dispatch(
addTab({
uid: uuid(),
uid: collection.uid,
collectionUid: collection.uid,
type: 'collection-settings'
type: 'collection-settings',
})
);
}
};
const handleDoubleClick = (event) => {
dispatch(makeTabPermanent({ uid: collection.uid }))
};
const handleCollectionCollapse = (e) => {
e.stopPropagation();
e.preventDefault();
ensureCollectionIsMounted();
dispatch(collapseCollection(collection.uid));
}
const handleRightClick = (event) => {
const _menuDropdown = menuDropdownTippyRef.current;
if (_menuDropdown) {
@@ -98,33 +117,58 @@ const Collection = ({ collection, searchText }) => {
const viewCollectionSettings = () => {
dispatch(
addTab({
uid: uuid(),
uid: collection.uid,
collectionUid: collection.uid,
type: 'collection-settings'
})
);
};
const isCollectionItem = (itemType) => {
return itemType.startsWith('collection-item');
};
const [{ isDragging }, drag] = useDrag({
type: "collection",
item: collection,
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
options: {
dropEffect: "move"
}
});
const [{ isOver }, drop] = useDrop({
accept: `COLLECTION_ITEM_${collection.uid}`,
drop: (draggedItem) => {
dispatch(moveItemToRootOfCollection(collection.uid, draggedItem.uid));
accept: ["collection", `collection-item-${collection.uid}`],
drop: (draggedItem, monitor) => {
const itemType = monitor.getItemType();
if (isCollectionItem(itemType)) {
dispatch(moveItemToRootOfCollection(collection.uid, draggedItem.uid))
} else {
dispatch(moveCollectionAndPersist({draggedItem, targetItem: collection}));
}
},
canDrop: (draggedItem) => {
// todo need to make sure that draggedItem belongs to the collection
return true;
return draggedItem.uid !== collection.uid;
},
collect: (monitor) => ({
isOver: monitor.isOver()
})
isOver: monitor.isOver(),
}),
});
drag(drop(collectionRef));
if (searchText && searchText.length) {
if (!doesCollectionHaveItemsMatchingSearchText(collection, searchText)) {
return null;
}
}
const collectionRowClassName = classnames('flex py-1 collection-name items-center', {
'item-hovered': isOver
});
// we need to sort request items by seq property
const sortRequestItems = (items = []) => {
return items.sort((a, b) => a.seq - b.seq);
@@ -148,16 +192,19 @@ const Collection = ({ collection, searchText }) => {
{showRemoveCollectionModal && (
<RemoveCollection collection={collection} onClose={() => setShowRemoveCollectionModal(false)} />
)}
{showExportCollectionModal && (
<ExportCollection collection={collection} onClose={() => setShowExportCollectionModal(false)} />
{showShareCollectionModal && (
<ShareCollection collection={collection} onClose={() => setShowShareCollectionModal(false)} />
)}
{showCloneCollectionModalOpen && (
<CloneCollection collection={collection} onClose={() => setShowCloneCollectionModalOpen(false)} />
)}
<div className="flex py-1 collection-name items-center" ref={drop}>
<div className={collectionRowClassName}
ref={collectionRef}
>
<div
className="flex flex-grow items-center overflow-hidden"
onClick={handleClick}
onDoubleClick={handleDoubleClick}
onContextMenu={handleRightClick}
>
<IconChevronRight
@@ -165,6 +212,7 @@ const Collection = ({ collection, searchText }) => {
strokeWidth={2}
className={`chevron-icon ${iconClassName}`}
style={{ width: 16, minWidth: 16, color: 'rgb(160 160 160)' }}
onClick={handleCollectionCollapse}
/>
<div className="ml-1" id="sidebar-collection-name">
{collection.name}
@@ -222,10 +270,10 @@ const Collection = ({ collection, searchText }) => {
className="dropdown-item"
onClick={(e) => {
menuDropdownTippyRef.current.hide();
setShowExportCollectionModal(true);
setShowShareCollectionModal(true);
}}
>
Export
Share
</div>
<div
className="dropdown-item"

View File

@@ -8,12 +8,10 @@ import {
IconSortDescendingLetters,
IconX
} from '@tabler/icons';
import Collection from '../Collections/Collection';
import Collection from './Collection';
import CreateCollection from '../CreateCollection';
import StyledWrapper from './StyledWrapper';
import CreateOrOpenCollection from './CreateOrOpenCollection';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { sortCollections } from 'providers/ReduxStore/slices/collections/actions';
// todo: move this to a separate folder
@@ -119,9 +117,7 @@ const Collections = () => {
{collections && collections.length
? collections.map((c) => {
return (
<DndProvider backend={HTML5Backend} key={c.uid}>
<Collection searchText={searchText} collection={c} key={c.uid} />
</DndProvider>
<Collection searchText={searchText} collection={c} key={c.uid} />
);
})
: null}

View File

@@ -120,7 +120,7 @@ const CreateCollection = ({ onClose }) => {
<label htmlFor="collection-folder-name" className="flex items-center mt-3">
<span className="font-semibold">Folder Name</span>
<InfoTip
text="This folder will be created under the selected location"
content="This folder will be created under the selected location"
infotipId="collection-folder-name-infotip"
/>
</label>

View File

@@ -5,6 +5,7 @@ import Preferences from 'components/Preferences';
import Cookies from 'components/Cookies';
import ToolHint from 'components/ToolHint';
import GoldenEdition from './GoldenEdition';
import { useApp } from 'providers/App';
import { useState, useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
@@ -20,7 +21,7 @@ const Sidebar = () => {
const leftSidebarWidth = useSelector((state) => state.app.leftSidebarWidth);
const preferencesOpen = useSelector((state) => state.app.showPreferences);
const [goldenEditionOpen, setGoldenEditionOpen] = useState(false);
const { version } = useApp();
const [asideWidth, setAsideWidth] = useState(leftSidebarWidth);
const [cookiesOpen, setCookiesOpen] = useState(false);
@@ -184,7 +185,7 @@ const Sidebar = () => {
Star
</GitHubButton> */}
</div>
<div className="flex flex-grow items-center justify-end text-xs mr-2">v1.36.1</div>
<div className="flex flex-grow items-center justify-end text-xs mr-2">v{version}</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,91 @@
import styled from 'styled-components';
const switchSizes = {
'2xs': { width: 32, height: 16, buttonSize: 14 },
xs: { width: 40, height: 20, buttonSize: 18 },
s: { width: 44, height: 22, buttonSize: 20 },
m: { width: 50, height: 24, buttonSize: 22 }, // default size
l: { width: 56, height: 28, buttonSize: 26 },
xl: { width: 64, height: 32, buttonSize: 30 },
'2xl': { width: 72, height: 36, buttonSize: 34 }
};
const getSizeValues = (size = 'm') => switchSizes[size] || switchSizes.m;
export const Switch = styled.div`
position: relative;
display: inline-block;
width: ${(props) => getSizeValues(props.size).width}px;
height: ${(props) => getSizeValues(props.size).height}px;
border-radius: ${(props) => getSizeValues(props.size).height}px;
`;
export const Checkbox = styled.input`
opacity: 0;
width: 0;
height: 0;
&:checked + label div {
background-color: ${(props) => props.theme.textLink};
}
&:checked + label div:before {
transform: translateX(${(props) => getSizeValues(props.size).width - getSizeValues(props.size).buttonSize - 2}px);
}
`;
export const Label = styled.label`
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
cursor: pointer;
background-color: ${(props) => props.theme.input.bg};
border-radius: 24px;
div {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: ${(props) => props.theme.colors.text.muted};
border-radius: 24px;
transition: transform 0.2s;
}
`;
export const Inner = styled.div`
position: absolute;
top: 2px;
left: 2px;
right: 2px;
bottom: 2px;
background-color: #fafafa;
transition: 0.4s;
border-radius: ${(props) => getSizeValues(props.size).height - 2}px;
`;
export const SwitchButton = styled.div`
position: absolute;
height: ${(props) => getSizeValues(props.size).buttonSize}px;
width: ${(props) => getSizeValues(props.size).buttonSize}px;
left: 2px;
bottom: 2px;
background-color: white;
transition: 0.4s;
border-radius: 50%;
&:before {
content: '';
position: absolute;
height: ${(props) => getSizeValues(props.size).buttonSize - 2}px;
width: ${(props) => getSizeValues(props.size).buttonSize - 2}px;
background-color: white;
top: 2px;
left: 2px;
transition: 0.4s;
border-radius: 50%;
}
`;

View File

@@ -0,0 +1,15 @@
import { Checkbox, Inner, Label, Switch, SwitchButton } from './StyledWrapper';
const ToggleSwitch = ({ isOn, handleToggle, size = 'm', ...props }) => {
return (
<Switch size={size} {...props}>
<Checkbox checked={isOn} onChange={handleToggle} id="toggle-switch" type="checkbox" size={size} />
<Label htmlFor="toggle-switch">
<Inner size={size} />
<SwitchButton size={size} />
</Label>
</Switch>
);
};
export default ToggleSwitch;

View File

@@ -34,7 +34,7 @@ const ToolHint = ({
<StyledWrapper theme={appliedTheme}>
<ReactToolHint
anchorId={toolhintId}
html={text}
content={text}
className="toolhint"
offset={offset}
place={place}

View File

@@ -1,6 +1,8 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './pages/index';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
const rootElement = document.getElementById('root');
@@ -8,7 +10,9 @@ if (rootElement) {
const root = ReactDOM.createRoot(rootElement);
root.render(
<React.StrictMode>
<App />
<DndProvider backend={HTML5Backend}>
<App />
</DndProvider>
</React.StrictMode>
);
}

View File

@@ -6,11 +6,12 @@ import ConfirmAppClose from './ConfirmAppClose';
import useIpcEvents from './useIpcEvents';
import useTelemetry from './useTelemetry';
import StyledWrapper from './StyledWrapper';
import { version } from '../../../package.json';
export const AppContext = React.createContext();
export const AppProvider = (props) => {
useTelemetry();
useTelemetry({ version });
useIpcEvents();
const dispatch = useDispatch();
@@ -37,7 +38,7 @@ export const AppProvider = (props) => {
}, []);
return (
<AppContext.Provider {...props} value="appProvider">
<AppContext.Provider {...props} value={{ version }}>
<StyledWrapper>
<ConfirmAppClose />
{props.children}
@@ -46,4 +47,12 @@ export const AppProvider = (props) => {
);
};
export const useApp = () => {
const context = React.useContext(AppContext);
if (!context) {
throw new Error('useApp must be used within an AppProvider');
}
return context;
};
export default AppProvider;

View File

@@ -42,7 +42,7 @@ const getAnonymousTrackingId = () => {
return id;
};
const trackStart = () => {
const trackStart = (version) => {
if (isPlaywrightTestRunning()) {
return;
}
@@ -58,16 +58,18 @@ const trackStart = () => {
event: 'start',
properties: {
os: platformLib.os.family,
version: '1.38.1'
version: version
}
});
};
const useTelemetry = () => {
const useTelemetry = ({ version }) => {
useEffect(() => {
trackStart();
setInterval(trackStart, 24 * 60 * 60 * 1000);
}, []);
if (posthogApiKey && posthogApiKey.length) {
trackStart(version);
setInterval(trackStart, 24 * 60 * 60 * 1000);
}
}, [posthogApiKey]);
};
export default useTelemetry;

View File

@@ -6,12 +6,13 @@ import collectionsReducer from './slices/collections';
import tabsReducer from './slices/tabs';
import notificationsReducer from './slices/notifications';
import globalEnvironmentsReducer from './slices/global-environments';
import { draftDetectMiddleware } from './middlewares/draft/middleware';
const isDevEnv = () => {
return import.meta.env.MODE === 'development';
};
let middleware = [tasksMiddleware.middleware];
let middleware = [tasksMiddleware.middleware, draftDetectMiddleware];
if (isDevEnv()) {
middleware = [...middleware, debugMiddleware.middleware];
}

View File

@@ -0,0 +1,56 @@
import { handleMakeTabParmanent } from "./utils";
const actionsToIntercept = [
'collections/requestUrlChanged',
'collections/updateAuth',
'collections/addQueryParam',
'collections/moveQueryParam',
'collections/updateQueryParam',
'collections/deleteQueryParam',
'collections/updatePathParam',
'collections/addRequestHeader',
'collections/updateRequestHeader',
'collections/deleteRequestHeader',
'collections/moveRequestHeader',
'collections/addFormUrlEncodedParam',
'collections/updateFormUrlEncodedParam',
'collections/deleteFormUrlEncodedParam',
'collections/moveFormUrlEncodedParam',
'collections/addMultipartFormParam',
'collections/updateMultipartFormParam',
'collections/deleteMultipartFormParam',
'collections/moveMultipartFormParam',
'collections/updateRequestAuthMode',
'collections/updateRequestBodyMode',
'collections/updateRequestBody',
'collections/updateRequestGraphqlQuery',
'collections/updateRequestGraphqlVariables',
'collections/updateRequestScript',
'collections/updateResponseScript',
'collections/updateRequestTests',
'collections/updateRequestMethod',
'collections/addAssertion',
'collections/updateAssertion',
'collections/deleteAssertion',
'collections/moveAssertion',
'collections/addVar',
'collections/updateVar',
'collections/deleteVar',
'collections/moveVar',
'collections/addFolderHeader',
'collections/updateFolderHeader',
'collections/deleteFolderHeader',
'collections/addFolderVar',
'collections/updateFolderVar',
'collections/deleteFolderVar',
'collections/updateRequestDocs',
'collections/runRequestEvent', // TODO: This doesn't necessarily related to a draft state, need to rethink.
];
export const draftDetectMiddleware = ({ dispatch, getState }) => (next) => (action) => {
if (actionsToIntercept.includes(action.type)) {
const state = getState();
handleMakeTabParmanent(state, action, dispatch);
}
return next(action);
};

View File

@@ -0,0 +1,21 @@
import { makeTabPermanent } from "providers/ReduxStore/slices/tabs";
import { findCollectionByUid, findItemInCollection } from "utils/collections/index";
import find from 'lodash/find';
function handleMakeTabParmanent(state, action, dispatch) {
const tabs = state.tabs.tabs;
const activeTabUid = state.tabs.activeTabUid;
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
const itemUid = action.payload.itemUid || action.payload.folderUid
const collection = findCollectionByUid(state.collections.collections, action.payload.collectionUid);
if (collection) {
const item = findItemInCollection(collection, itemUid);
if (item && focusedTab.preview == true) {
dispatch(makeTabPermanent({ uid: itemUid }));
}
}
}
export {
handleMakeTabParmanent
}

View File

@@ -122,6 +122,44 @@ export const deleteCookiesForDomain = (domain) => (dispatch, getState) => {
});
};
export const deleteCookie = (domain, path, cookieKey) => (dispatch, getState) => {
return new Promise((resolve, reject) => {
const { ipcRenderer } = window;
ipcRenderer.invoke('renderer:delete-cookie', domain, path, cookieKey).then(resolve).catch(reject);
});
};
export const addCookie = (domain, cookie) => (dispatch, getState) => {
return new Promise((resolve, reject) => {
const { ipcRenderer } = window;
ipcRenderer.invoke('renderer:add-cookie', domain, cookie).then(resolve).catch(reject);
});
};
export const modifyCookie = (domain, oldCookie, cookie) => (dispatch, getState) => {
return new Promise((resolve, reject) => {
const { ipcRenderer } = window;
ipcRenderer.invoke('renderer:modify-cookie', domain, oldCookie, cookie).then(resolve).catch(reject);
});
};
export const getParsedCookie = (cookieStr) => () => {
return new Promise((resolve, reject) => {
const { ipcRenderer } = window;
ipcRenderer.invoke('renderer:get-parsed-cookie', cookieStr).then(resolve).catch(reject);
});
};
export const createCookieString = (cookieObj) => () => {
return new Promise((resolve, reject) => {
const { ipcRenderer } = window;
ipcRenderer.invoke('renderer:create-cookie-string', cookieObj).then(resolve).catch(reject);
});
};
export const completeQuitFlow = () => (dispatch, getState) => {
const { ipcRenderer } = window;
return ipcRenderer.invoke('main:complete-quit-flow');

View File

@@ -21,7 +21,7 @@ import {
transformRequestToSaveToFilesystem
} from 'utils/collections';
import { uuid, waitForNextTick } from 'utils/common';
import { PATH_SEPARATOR, getDirectoryName } from 'utils/common/platform';
import { PATH_SEPARATOR, getDirectoryName, isWindowsPath } from 'utils/common/platform';
import { cancelNetworkRequest, sendNetworkRequest } from 'utils/network';
import { callIpc } from 'utils/common/ipc';
@@ -32,6 +32,7 @@ import {
selectEnvironment as _selectEnvironment,
sortCollections as _sortCollections,
updateCollectionMountStatus,
moveCollection,
requestCancelled,
resetRunResults,
responseReceived,
@@ -493,7 +494,7 @@ export const cloneItem = (newName, itemUid, collectionUid) => (dispatch, getStat
);
if (!reqWithSameNameExists) {
const dirname = getDirectoryName(item.pathname);
const fullName = path.join(dirname, filename);
const fullName = isWindowsPath(item.pathname) ? path.win32.join(dirname, filename) : path.join(dirname, filename);
const { ipcRenderer } = window;
const requestItems = filter(parentItem.items, (i) => i.type !== 'folder');
itemToSave.seq = requestItems ? requestItems.length + 1 : 1;
@@ -758,7 +759,8 @@ export const newHttpRequest = (params) => (dispatch, getState) => {
xml: null,
sparql: null,
multipartForm: null,
formUrlEncoded: null
formUrlEncoded: null,
file: null
},
auth: auth ?? {
mode: 'none'
@@ -1038,14 +1040,17 @@ export const browseDirectory = () => (dispatch, getState) => {
};
export const browseFiles =
(filters = []) =>
(dispatch, getState) => {
(filters, properties) =>
(_dispatch, _getState) => {
const { ipcRenderer } = window;
return new Promise((resolve, reject) => {
ipcRenderer.invoke('renderer:browse-files', filters).then(resolve).catch(reject);
ipcRenderer
.invoke('renderer:browse-files', filters, properties)
.then(resolve)
.catch(reject);
});
};
};
export const updateBrunoConfig = (brunoConfig, collectionUid) => (dispatch, getState) => {
const state = getState();
@@ -1147,6 +1152,22 @@ export const importCollection = (collection, collectionLocation) => (dispatch, g
});
};
export const moveCollectionAndPersist = ({ draggedItem, targetItem }) => (dispatch, getState) => {
dispatch(moveCollection({ draggedItem, targetItem }));
return new Promise((resolve, reject) => {
const { ipcRenderer } = window;
const state = getState();
const collectionPaths = state.collections.collections.map((collection) => collection.pathname);
ipcRenderer
.invoke('renderer:update-collection-paths', collectionPaths)
.then(resolve)
.catch(reject);
});
};
export const saveCollectionSecurityConfig = (collectionUid, securityConfig) => (dispatch, getState) => {
return new Promise((resolve, reject) => {
const { ipcRenderer } = window;
@@ -1219,3 +1240,10 @@ export const mountCollection = ({ collectionUid, collectionPathname, brunoConfig
});
});
};
export const showInFolder = (collectionPath) => () => {
return new Promise((resolve, reject) => {
const { ipcRenderer } = window;
ipcRenderer.invoke('renderer:show-in-folder', collectionPath).then(resolve).catch(reject);
});
};

View File

@@ -1,5 +1,5 @@
import { uuid } from 'utils/common';
import { find, map, forOwn, concat, filter, each, cloneDeep, get, set } from 'lodash';
import { find, map, forOwn, concat, filter, each, cloneDeep, get, set, findIndex } from 'lodash';
import { createSlice } from '@reduxjs/toolkit';
import {
addDepth,
@@ -18,6 +18,8 @@ import {
import { parsePathParams, parseQueryParams, splitOnFirst, stringifyQueryParams } from 'utils/url';
import { getDirectoryName, getSubdirectoriesFromRoot, PATH_SEPARATOR } from 'utils/common/platform';
import toast from 'react-hot-toast';
import mime from 'mime-types';
import path from 'node:path';
const initialState = {
collections: [],
@@ -57,7 +59,9 @@ export const collectionsSlice = createSlice({
updateCollectionMountStatus: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
if (collection) {
collection.mountStatus = action.payload.mountStatus;
if (action.payload.mountStatus) {
collection.mountStatus = action.payload.mountStatus;
}
}
},
setCollectionSecurityConfig: (state, action) => {
@@ -98,6 +102,12 @@ export const collectionsSlice = createSlice({
break;
}
},
moveCollection: (state, action) => {
const { draggedItem, targetItem } = action.payload;
state.collections = state.collections.filter((i) => i.uid !== draggedItem.uid); // Remove dragged item
const targetItemIndex = state.collections.findIndex((i) => i.uid === targetItem.uid); // Find target item
state.collections.splice(targetItemIndex, 0, draggedItem); // Insert dragged-item above target-item
},
updateLastAction: (state, action) => {
const { collectionUid, lastAction } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
@@ -895,6 +905,76 @@ export const collectionsSlice = createSlice({
}
}
},
addFile: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
if (collection) {
const item = findItemInCollection(collection, action.payload.itemUid);
if (item && isItemARequest(item)) {
if (!item.draft) {
item.draft = cloneDeep(item);
}
item.draft.request.body.file = item.draft.request.body.file || [];
item.draft.request.body.file.push({
uid: uuid(),
filePath: '',
contentType: '',
selected: false
});
}
}
},
updateFile: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
if (collection) {
const item = findItemInCollection(collection, action.payload.itemUid);
if (item && isItemARequest(item)) {
if (!item.draft) {
item.draft = cloneDeep(item);
}
const param = find(item.draft.request.body.file, (p) => p.uid === action.payload.param.uid);
if (param) {
const contentType = mime.contentType(path.extname(action.payload.param.filePath));
param.filePath = action.payload.param.filePath;
param.contentType = action.payload.param.contentType || contentType || '';
param.selected = action.payload.param.selected;
item.draft.request.body.file = item.draft.request.body.file.map((p) => {
p.selected = p.uid === param.uid;
return p;
});
}
}
}
},
deleteFile: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
if (collection) {
const item = findItemInCollection(collection, action.payload.itemUid);
if (item && isItemARequest(item)) {
if (!item.draft) {
item.draft = cloneDeep(item);
}
item.draft.request.body.file = filter(
item.draft.request.body.file,
(p) => p.uid !== action.payload.paramUid
);
if (item.draft.request.body.file.length > 0) {
item.draft.request.body.file[0].selected = true;
}
}
}
},
updateRequestAuthMode: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
@@ -951,6 +1031,10 @@ export const collectionsSlice = createSlice({
item.draft.request.body.sparql = action.payload.content;
break;
}
case 'file': {
item.draft.request.body.file = action.payload.content;
break;
}
case 'formUrlEncoded': {
item.draft.request.body.formUrlEncoded = action.payload.content;
break;
@@ -1685,6 +1769,12 @@ export const collectionsSlice = createSlice({
// we don't want to lose the draft in this case
if (areItemsTheSameExceptSeqUpdate(item, file.data)) {
item.seq = file.data.seq;
if (item?.draft) {
item.draft.seq = file.data.seq;
}
if (item?.draft && areItemsTheSameExceptSeqUpdate(item?.draft, file.data)) {
item.draft = null;
}
} else {
item.name = file.data.name;
item.type = file.data.type;
@@ -1763,12 +1853,22 @@ export const collectionsSlice = createSlice({
}
},
runRequestEvent: (state, action) => {
const { itemUid, collectionUid, type, requestUid } = action.payload;
const { itemUid, collectionUid, type, requestUid, hasError } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
if (collection) {
const item = findItemInCollection(collection, itemUid);
if (item) {
if (type === 'pre-request-script-execution') {
item.requestUid = requestUid;
item.preRequestScriptErrorMessage = action.payload.errorMessage;
}
if(type === 'post-response-script-execution') {
item.requestUid = requestUid;
item.postResponseScriptErrorMessage = action.payload.errorMessage;
}
if (type === 'request-queued') {
const { cancelTokenUid } = action.payload;
item.requestUid = requestUid;
@@ -1952,6 +2052,9 @@ export const {
addMultipartFormParam,
updateMultipartFormParam,
deleteMultipartFormParam,
addFile,
updateFile,
deleteFile,
moveMultipartFormParam,
updateRequestAuthMode,
updateRequestBodyMode,
@@ -2003,7 +2106,8 @@ export const {
runFolderEvent,
resetCollectionRunner,
updateRequestDocs,
updateFolderDocs
updateFolderDocs,
moveCollection
} = collectionsSlice.actions;
export default collectionsSlice.reducer;

View File

@@ -1,7 +1,7 @@
import toast from 'react-hot-toast';
import { createSlice } from '@reduxjs/toolkit';
import { getAppInstallDate } from 'utils/common/platform';
import semver from 'semver';
const getReadNotificationIds = () => {
try {
let readNotificationIdsString = window.localStorage.getItem('bruno.notifications.read');
@@ -27,6 +27,26 @@ const initialState = {
readNotificationIds: getReadNotificationIds() || []
};
export const filterNotificationsByVersion = (notifications, currentVersion) => {
try {
if (!notifications) return [];
if (!currentVersion) return notifications;
return notifications.filter(notification => {
const { minVersion, maxVersion } = notification;
if (!minVersion && !maxVersion) return true;
if (!minVersion) return semver.lte(currentVersion, maxVersion);
if (!maxVersion) return semver.gte(currentVersion, minVersion);
return semver.gte(currentVersion, minVersion) && semver.lte(currentVersion, maxVersion);
});
} catch (error) {
console.error(error);
return [];
}
};
export const notificationSlice = createSlice({
name: 'notifications',
initialState,
@@ -86,13 +106,14 @@ export const notificationSlice = createSlice({
export const { setNotifications, setFetchingStatus, markNotificationAsRead, markAllNotificationsAsRead } =
notificationSlice.actions;
export const fetchNotifications = () => (dispatch, getState) => {
export const fetchNotifications = ({currentVersion}) => (dispatch, getState) => {
return new Promise((resolve) => {
const { ipcRenderer } = window;
dispatch(setFetchingStatus(true));
ipcRenderer
.invoke('renderer:fetch-notifications')
.then((notifications) => {
notifications = filterNotificationsByVersion(notifications, currentVersion);
dispatch(setNotifications({ notifications }));
dispatch(setFetchingStatus(false));
resolve(notifications);

View File

@@ -0,0 +1,133 @@
const { filterNotificationsByVersion } = require('./notifications');
describe('filterNotificationsByVersion - basic', () => {
it('should filter notifications by version', () => {
const notifications = [{ minVersion: '1.0.0', maxVersion: '1.1.0' }];
const currentVersion = '1.0.5';
const filteredNotifications = filterNotificationsByVersion(notifications, currentVersion);
expect(filteredNotifications).toEqual([{ minVersion: '1.0.0', maxVersion: '1.1.0' }]);
});
it('should gracefully handle no notifications', () => {
const notifications = [];
const currentVersion = '1.0.5';
const filteredNotifications = filterNotificationsByVersion(notifications, currentVersion);
expect(filteredNotifications).toEqual([]);
});
it('should gracefully handle notifications are undefined', () => {
const notifications = undefined;
const currentVersion = '1.0.5';
const filteredNotifications = filterNotificationsByVersion(notifications, currentVersion);
expect(filteredNotifications).toEqual([]);
});
it('should gracefully handle scenario when no current version is provided', () => {
const notifications = [{ minVersion: '1.0.0', maxVersion: '1.1.0' }];
const filteredNotifications = filterNotificationsByVersion(notifications);
expect(filteredNotifications).toEqual(notifications);
});
it('should gracefully handle scenario minVersion is undefined', () => {
const notifications = [{ minVersion: undefined, maxVersion: '1.1.0' }];
const currentVersion = '1.0.5';
const filteredNotifications = filterNotificationsByVersion(notifications, currentVersion);
expect(filteredNotifications).toEqual(notifications);
});
it('should gracefully handle scenario maxVersion is undefined', () => {
const notifications = [{ minVersion: '1.0.0', maxVersion: undefined }];
const currentVersion = '1.0.5';
const filteredNotifications = filterNotificationsByVersion(notifications, currentVersion);
expect(filteredNotifications).toEqual(notifications);
});
it('should gracefully handle scenario minVersion and maxVersion are undefined', () => {
const notifications = [{ minVersion: undefined, maxVersion: undefined }];
const currentVersion = '1.0.5';
const filteredNotifications = filterNotificationsByVersion(notifications, currentVersion);
expect(filteredNotifications).toEqual(notifications);
});
});
describe('filterNotificationsByVersion - semver', () => {
it('should filter out notifications outside version range', () => {
const notifications = [
{ minVersion: '1.0.0', maxVersion: '1.1.0' }, // should be included
{ minVersion: '2.0.0', maxVersion: '2.1.0' }, // should be filtered out
{ minVersion: '0.5.0', maxVersion: '0.9.0' } // should be filtered out
];
const currentVersion = '1.0.5';
const filteredNotifications = filterNotificationsByVersion(notifications, currentVersion);
expect(filteredNotifications).toEqual([
{ minVersion: '1.0.0', maxVersion: '1.1.0' }
]);
});
it('should handle mixed valid and invalid version ranges', () => {
const notifications = [
{ minVersion: '1.0.0', maxVersion: '2.0.0' }, // should be included
{ minVersion: '3.0.0', maxVersion: '4.0.0' }, // should be filtered out
{ minVersion: '1.5.0', maxVersion: '1.8.0' }, // should be included
{ minVersion: '0.1.0', maxVersion: '0.5.0' } // should be filtered out
];
const currentVersion = '1.6.0';
const filteredNotifications = filterNotificationsByVersion(notifications, currentVersion);
expect(filteredNotifications).toEqual([
{ minVersion: '1.0.0', maxVersion: '2.0.0' },
{ minVersion: '1.5.0', maxVersion: '1.8.0' }
]);
});
it('should handle edge cases of version ranges', () => {
const notifications = [
{ minVersion: '1.0.0', maxVersion: '1.0.0' }, // should be included
{ minVersion: '1.0.1', maxVersion: '2.0.0' }, // should be filtered out
{ minVersion: '0.9.9', maxVersion: '1.0.0' } // should be included
];
const currentVersion = '1.0.0';
const filteredNotifications = filterNotificationsByVersion(notifications, currentVersion);
expect(filteredNotifications).toEqual([
{ minVersion: '1.0.0', maxVersion: '1.0.0' },
{ minVersion: '0.9.9', maxVersion: '1.0.0' }
]);
});
});
describe('filterNotificationsByVersion - undefined version bounds', () => {
it('should include notifications when minVersion is undefined and current version is below maxVersion', () => {
const notifications = [
{ minVersion: undefined, maxVersion: '2.0.0' }
];
const currentVersion = '1.5.0';
const filteredNotifications = filterNotificationsByVersion(notifications, currentVersion);
expect(filteredNotifications).toEqual(notifications);
});
it('should exclude notifications when minVersion is undefined and current version is above maxVersion', () => {
const notifications = [
{ minVersion: undefined, maxVersion: '2.0.0' }
];
const currentVersion = '2.1.0';
const filteredNotifications = filterNotificationsByVersion(notifications, currentVersion);
expect(filteredNotifications).toEqual([]);
});
it('should include notifications when maxVersion is undefined and current version is above minVersion', () => {
const notifications = [
{ minVersion: '1.0.0', maxVersion: undefined }
];
const currentVersion = '2.0.0';
const filteredNotifications = filterNotificationsByVersion(notifications, currentVersion);
expect(filteredNotifications).toEqual(notifications);
});
it('should exclude notifications when maxVersion is undefined and current version is below minVersion', () => {
const notifications = [
{ minVersion: '1.0.0', maxVersion: undefined }
];
const currentVersion = '0.9.0';
const filteredNotifications = filterNotificationsByVersion(notifications, currentVersion);
expect(filteredNotifications).toEqual([]);
});
});

View File

@@ -1,4 +1,5 @@
import { createSlice } from '@reduxjs/toolkit';
import { findIndex } from 'lodash';
import filter from 'lodash/filter';
import find from 'lodash/find';
import last from 'lodash/last';
@@ -19,31 +20,59 @@ export const tabsSlice = createSlice({
initialState,
reducers: {
addTab: (state, action) => {
const alreadyExists = find(state.tabs, (tab) => tab.uid === action.payload.uid);
if (alreadyExists) {
const { uid, collectionUid, type, requestPaneTab, preview } = action.payload;
const nonReplaceableTabTypes = [
"variables",
"collection-runner",
"security-settings",
];
const existingTab = find(state.tabs, (tab) => tab.uid === uid);
if (existingTab) {
state.activeTabUid = existingTab.uid;
return;
}
if (
['variables', 'collection-settings', 'collection-overview', 'collection-runner', 'security-settings'].includes(action.payload.type)
) {
const tab = tabTypeAlreadyExists(state.tabs, action.payload.collectionUid, action.payload.type);
if (tab) {
state.activeTabUid = tab.uid;
if (nonReplaceableTabTypes.includes(type)) {
const existingTab = tabTypeAlreadyExists(state.tabs, collectionUid, type);
if (existingTab) {
state.activeTabUid = existingTab.uid;
return;
}
}
const lastTab = state.tabs[state.tabs.length - 1];
if (state.tabs.length > 0 && lastTab.preview) {
state.tabs[state.tabs.length - 1] = {
uid,
collectionUid,
requestPaneWidth: null,
requestPaneTab: requestPaneTab || 'params',
responsePaneTab: 'response',
type: type || 'request',
preview: preview !== undefined
? preview
: !nonReplaceableTabTypes.includes(type),
...(uid ? { folderUid: uid } : {})
}
state.activeTabUid = uid;
return
}
state.tabs.push({
uid: action.payload.uid,
collectionUid: action.payload.collectionUid,
uid,
collectionUid,
requestPaneWidth: null,
requestPaneTab: action.payload.requestPaneTab || 'params',
requestPaneTab: requestPaneTab || 'params',
responsePaneTab: 'response',
type: action.payload.type || 'request',
...(action.payload.uid ? { folderUid: action.payload.uid } : {})
type: type || 'request',
...(uid ? { folderUid: uid } : {}),
preview: preview !== undefined
? preview
: !nonReplaceableTabTypes.includes(type)
});
state.activeTabUid = action.payload.uid;
state.activeTabUid = uid;
},
focusTab: (state, action) => {
state.activeTabUid = action.payload.uid;
@@ -124,6 +153,15 @@ export const tabsSlice = createSlice({
const collectionUid = action.payload.collectionUid;
state.tabs = filter(state.tabs, (t) => t.collectionUid !== collectionUid);
state.activeTabUid = null;
},
makeTabPermanent: (state, action) => {
const { uid } = action.payload;
const tab = find(state.tabs, (t) => t.uid === uid);
if (tab) {
tab.preview = false;
} else{
console.error("Tab not found!")
}
}
}
});
@@ -136,7 +174,8 @@ export const {
updateRequestPaneTab,
updateResponsePaneTab,
closeTabs,
closeAllCollectionTabs
closeAllCollectionTabs,
makeTabPermanent
} = tabsSlice.actions;
export default tabsSlice.reducer;
export default tabsSlice.reducer;

View File

@@ -14,6 +14,8 @@ const createContentType = (mode) => {
return 'application/json';
case 'multipartForm':
return 'multipart/form-data';
case 'file':
return 'application/octet-stream';
default:
return '';
}
@@ -60,22 +62,51 @@ const createPostData = (body, type) => {
}
const contentType = createContentType(body.mode);
if (body.mode === 'formUrlEncoded' || body.mode === 'multipartForm') {
return {
mimeType: contentType,
params: body[body.mode]
.filter((param) => param.enabled)
.map((param) => ({
name: param.name,
value: param.value,
...(param.type === 'file' && { fileName: param.value })
}))
};
} else {
return {
mimeType: contentType,
text: body[body.mode]
};
switch (body.mode) {
case 'formUrlEncoded':
return {
mimeType: contentType,
text: new URLSearchParams(
body[body.mode]
.filter((param) => param.enabled)
.reduce((acc, param) => {
acc[param.name] = param.value;
return acc;
}, {})
).toString(),
params: body[body.mode]
.filter((param) => param.enabled)
.map((param) => ({
name: param.name,
value: param.value
}))
};
case 'multipartForm':
return {
mimeType: contentType,
params: body[body.mode]
.filter((param) => param.enabled)
.map((param) => ({
name: param.name,
value: param.value,
...(param.type === 'file' && { fileName: param.value })
}))
};
case 'file':
return {
mimeType: body[body.mode].filter((param) => param.enabled)[0].contentType,
params: body[body.mode]
.filter((param) => param.selected)
.map((param) => ({
value: param.filePath,
}))
};
default:
return {
mimeType: contentType,
text: body[body.mode]
};
}
};
@@ -89,6 +120,7 @@ export const buildHarRequest = ({ request, headers, type }) => {
queryString: createQuery(request.params),
postData: createPostData(request.body, type),
headersSize: 0,
bodySize: 0
bodySize: 0,
binary: true
};
};

View File

@@ -19,6 +19,27 @@ if (!SERVER_RENDERED) {
}
return [];
}
// Set default options for Bruno
const defaultOptions = {
esversion: 11,
expr: true,
asi: true,
undef: true,
browser: true,
devel: true,
predef: {
'bru': false,
'req': false,
'res': false,
'test': false,
'expect': false
}
};
// Merge provided options with defaults
options = Object.assign({}, defaultOptions, options);
if (!options.indent)
// JSHint error.character actually is a column index, this fixes underlining on lines using tabs for indentation
options.indent = 1; // JSHint default value is 4

View File

@@ -14,6 +14,7 @@ export const deleteUidsInItems = (items) => {
each(get(item, 'request.vars.assertions'), (a) => delete a.uid);
each(get(item, 'request.body.multipartForm'), (param) => delete param.uid);
each(get(item, 'request.body.formUrlEncoded'), (param) => delete param.uid);
each(get(item, 'request.body.file'), (param) => delete param.uid);
}
if (item.items && item.items.length) {

View File

@@ -1,13 +1,4 @@
import get from 'lodash/get';
import each from 'lodash/each';
import find from 'lodash/find';
import findIndex from 'lodash/findIndex';
import isString from 'lodash/isString';
import map from 'lodash/map';
import filter from 'lodash/filter';
import sortBy from 'lodash/sortBy';
import isEqual from 'lodash/isEqual';
import cloneDeep from 'lodash/cloneDeep';
import {cloneDeep, isEqual, sortBy, filter, map, isString, findIndex, find, each, get } from 'lodash';
import { uuid } from 'utils/common';
import path from 'path';
import slash from 'utils/common/slash';
@@ -146,6 +137,20 @@ export const areItemsLoading = (folder) => {
}, false);
}
export const getItemsLoadStats = (folder) => {
let loadingCount = 0;
let flattenedItems = flattenItems(folder.items);
flattenedItems?.forEach(i => {
if(i?.loading) {
loadingCount += 1;
}
});
return {
loading: loadingCount,
total: flattenedItems?.length
};
}
export const moveCollectionItem = (collection, draggedItem, targetItem) => {
let draggedItemParent = findParentItemInCollection(collection, draggedItem.uid);
@@ -281,6 +286,17 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
});
};
const copyFileParams = (params = []) => {
return map(params, (param) => {
return {
uid: param.uid,
filePath: param.filePath,
contentType: param.contentType,
selected: param.selected
}
});
}
const copyItems = (sourceItems, destItems) => {
each(sourceItems, (si) => {
if (!isItemAFolder(si) && !isItemARequest(si) && si.type !== 'js') {
@@ -308,7 +324,8 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
graphql: si.request.body.graphql,
sparql: si.request.body.sparql,
formUrlEncoded: copyFormUrlEncodedParams(si.request.body.formUrlEncoded),
multipartForm: copyMultipartFormParams(si.request.body.multipartForm)
multipartForm: copyMultipartFormParams(si.request.body.multipartForm),
file: copyFileParams(si.request.body.file)
},
script: si.request.script,
vars: si.request.vars,
@@ -661,6 +678,10 @@ export const humanizeRequestBodyMode = (mode) => {
label = 'SPARQL';
break;
}
case 'file': {
label = 'File / Binary';
break;
}
case 'formUrlEncoded': {
label = 'Form URL Encoded';
break;
@@ -761,6 +782,7 @@ export const refreshUidsInItem = (item) => {
each(get(item, 'request.params'), (param) => (param.uid = uuid()));
each(get(item, 'request.body.multipartForm'), (param) => (param.uid = uuid()));
each(get(item, 'request.body.formUrlEncoded'), (param) => (param.uid = uuid()));
each(get(item, 'request.body.file'), (param) => (param.uid = uuid()));
return item;
};
@@ -771,11 +793,13 @@ export const deleteUidsInItem = (item) => {
const headers = get(item, 'request.headers', []);
const bodyFormUrlEncoded = get(item, 'request.body.formUrlEncoded', []);
const bodyMultipartForm = get(item, 'request.body.multipartForm', []);
const file = get(item, 'request.body.file', []);
params.forEach((param) => delete param.uid);
headers.forEach((header) => delete header.uid);
bodyFormUrlEncoded.forEach((param) => delete param.uid);
bodyMultipartForm.forEach((param) => delete param.uid);
file.forEach((param) => delete param.uid);
return item;
};

View File

@@ -159,6 +159,8 @@ export const getCodeMirrorModeBasedOnContentType = (contentType, body) => {
if (contentType.includes('json')) {
return 'application/ld+json';
} else if (contentType.includes('image')) {
return 'application/image';
} else if (contentType.includes('xml')) {
return 'application/xml';
} else if (contentType.includes('html')) {
@@ -169,8 +171,6 @@ export const getCodeMirrorModeBasedOnContentType = (contentType, body) => {
return 'application/xml';
} else if (contentType.includes('yaml')) {
return 'application/yaml';
} else if (contentType.includes('image')) {
return 'application/image';
} else {
return 'application/text';
}

View File

@@ -1,5 +1,6 @@
import { customAlphabet } from 'nanoid';
import xmlFormat from 'xml-formatter';
import { format as jsoncFormat, applyEdits as jsoncApplyEdits } from 'jsonc-parser';
// a customized version of nanoid without using _ and -
export const uuid = () => {
@@ -26,6 +27,13 @@ export const waitForNextTick = () => {
});
};
export const prettifyJson = (doc) => {
return jsoncApplyEdits(
doc,
jsoncFormat(doc, null, {insertSpaces: true, tabSize: 2})
);
}
export const safeParseJSON = (str) => {
if (!str || !str.length || typeof str !== 'string') {
return str;
@@ -94,6 +102,8 @@ export const getContentType = (headers) => {
if (contentType && contentType.length) {
if (typeof contentType[0] == 'string' && /^[\w\-]+\/([\w\-]+\+)?json/.test(contentType[0])) {
return 'application/ld+json';
} else if (typeof contentType[0] === 'string' && /^image\/svg\+xml/i.test(contentType[0])) {
return 'image/svg+xml';
} else if (typeof contentType[0] == 'string' && /^[\w\-]+\/([\w\-]+\+)?xml/.test(contentType[0])) {
return 'application/xml';
}
@@ -174,3 +184,9 @@ export const generateUidBasedOnHash = (str) => {
};
export const stringifyIfNot = v => typeof v === 'string' ? v : String(v);
export const getEncoding = (headers) => {
// Parse the charset from content type: https://stackoverflow.com/a/33192813
const charsetMatch = /charset=([^()<>@,;:"/[\]?.=\s]*)/i.exec(headers['content-type'] || '');
return charsetMatch?.[1];
}

View File

@@ -24,11 +24,25 @@ export const getSubdirectoriesFromRoot = (rootPath, pathname) => {
return relativePath ? relativePath.split(path.sep) : [];
};
export const getDirectoryName = (pathname) => {
// convert to unix style path
pathname = slash(pathname);
return path.dirname(pathname);
export const isWindowsPath = (pathname) => {
if (!isWindowsOS()) {
return false;
}
// Check for Windows drive letter format (e.g., "C:\")
const hasDriveLetter = /^[a-zA-Z]:\\/.test(pathname);
// Check for UNC path format (e.g., "\\server\share") a.k.a. network path || WSL path
const isUNCPath = pathname.startsWith('\\\\');
return hasDriveLetter || isUNCPath;
};
export const getDirectoryName = (pathname) => {
return isWindowsPath(pathname) ? path.win32.dirname(pathname) : path.dirname(pathname);
};
export const isWindowsOS = () => {

View File

@@ -9,6 +9,7 @@
import parseCurlCommand from './parse-curl';
import * as querystring from 'query-string';
import * as jsesc from 'jsesc';
import * as path from 'path';
function getContentType(headers = {}) {
const contentType = Object.keys(headers).find((key) => key.toLowerCase() === 'content-type');
@@ -99,9 +100,30 @@ function getMultipleDataString(request, parsedQueryString) {
function getFilesString(request) {
const data = {};
data.files = {};
data.data = {};
if (request.isDataBinary) {
let filePath = '';
if (request.data.startsWith('@')) {
filePath = request.data.slice(1);
} else {
filePath = request.data;
}
data.data = [
{
filePath: repr(filePath),
contentType: request.headers['Content-Type'],
selected: true,
}
];
return data;
}
data.files = {};
for (const multipartKey in request.multipartUploads) {
const multipartValue = request.multipartUploads[multipartKey];
if (multipartValue.startsWith('@')) {
@@ -140,6 +162,7 @@ const curlToJson = (curlCommand) => {
requestJson.url = request.urlWithoutQuery;
requestJson.raw_url = request.url;
requestJson.method = request.method;
requestJson.isDataBinary = request.isDataBinary;
if (request.cookies) {
const cookies = {};
@@ -161,12 +184,10 @@ const curlToJson = (curlCommand) => {
if (request.query) {
requestJson.queries = getQueries(request);
}
if (typeof request.data === 'string' || typeof request.data === 'number') {
Object.assign(requestJson, getDataString(request));
} else if (request.multipartUploads) {
} else if (request.multipartUploads || request.isDataBinary) {
Object.assign(requestJson, getFilesString(request));
} else if (typeof request.data === 'string' || typeof request.data === 'number') {
Object.assign(requestJson, getDataString(request));
}
if (request.insecure) {

View File

@@ -86,4 +86,38 @@ describe('curlToJson', () => {
method: 'get'
});
});
it('should return a parse a curl with a post body with binary file type', () => {
const curlCommand = `curl 'https://www.usebruno.com'
-H 'Accept: application/json, text/plain, */*'
-H 'Accept-Language: en-US,en;q=0.9,hi;q=0.8'
-H 'Content-Type: application/json;charset=utf-8'
-H 'Origin: https://www.usebruno.com'
-H 'Referer: https://www.usebruno.com/'
--data-binary '@/path/to/file'
`;
const result = curlToJson(curlCommand);
expect(result).toEqual({
url: 'https://www.usebruno.com',
raw_url: 'https://www.usebruno.com',
method: 'post',
headers: {
Accept: 'application/json, text/plain, */*',
'Accept-Language': 'en-US,en;q=0.9,hi;q=0.8',
'Content-Type': 'application/json;charset=utf-8',
Origin: 'https://www.usebruno.com',
Referer: 'https://www.usebruno.com/'
},
isDataBinary: true,
data: [
{
filePath: '/path/to/file',
contentType: 'application/json;charset=utf-8',
selected: true
}
]
});
});
});

View File

@@ -50,14 +50,18 @@ export const getRequestFromCurlCommand = (curlCommand, requestType = 'http-reque
sparql: null,
multipartForm: null,
formUrlEncoded: null,
graphql: null
graphql: null,
file: null
};
if (parsedBody && contentType && typeof contentType === 'string') {
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')) {
} else if (requestType === 'http-request' && request.isDataBinary) {
body.mode = 'file';
body.file = parsedBody;
}else if (contentType.includes('application/json')) {
body.mode = 'json';
body.json = convertToCodeMirrorJson(parsedBody);
} else if (contentType.includes('xml')) {

View File

@@ -35,6 +35,7 @@ export const updateUidsInCollection = (_collection) => {
each(get(item, 'request.assertions'), (a) => (a.uid = uuid()));
each(get(item, 'request.body.multipartForm'), (param) => (param.uid = uuid()));
each(get(item, 'request.body.formUrlEncoded'), (param) => (param.uid = uuid()));
each(get(item, 'request.body.file'), (param) => (param.uid = uuid()));
if (item.items && item.items.length) {
updateItemUids(item.items);

View File

@@ -229,26 +229,27 @@ const transformOpenapiRequestItem = (request) => {
return brunoRequestItem;
};
const resolveRefs = (spec, components = spec?.components, visitedItems = new Set()) => {
const resolveRefs = (spec, components = spec?.components, cache = new Map()) => {
if (!spec || typeof spec !== 'object') {
return spec;
}
if (cache.has(spec)) {
return cache.get(spec);
}
if (Array.isArray(spec)) {
return spec.map((item) => resolveRefs(item, components, visitedItems));
return spec.map(item => resolveRefs(item, components, cache));
}
if ('$ref' in spec) {
const refPath = spec.$ref;
if (visitedItems.has(refPath)) {
return spec;
} else {
visitedItems.add(refPath);
if (cache.has(refPath)) {
return cache.get(refPath);
}
if (refPath.startsWith('#/components/')) {
// Local reference within components
const refKeys = refPath.replace('#/components/', '').split('/');
let ref = components;
@@ -256,25 +257,26 @@ const resolveRefs = (spec, components = spec?.components, visitedItems = new Set
if (ref && ref[key]) {
ref = ref[key];
} else {
// Handle invalid references gracefully?
return spec;
}
}
return resolveRefs(ref, components, visitedItems);
} else {
// Handle external references (not implemented here)
// You would need to fetch the external reference and resolve it.
// Example: Fetch and resolve an external reference from a URL.
cache.set(refPath, {});
const resolved = resolveRefs(ref, components, cache);
cache.set(refPath, resolved);
return resolved;
}
return spec;
}
// Recursively resolve references in nested objects
for (const prop in spec) {
spec[prop] = resolveRefs(spec[prop], components, new Set(visitedItems));
const resolved = {};
cache.set(spec, resolved);
for (const [key, value] of Object.entries(spec)) {
resolved[key] = resolveRefs(value, components, cache);
}
return spec;
return resolved;
};
const groupRequestsByTags = (requests) => {

View File

@@ -181,6 +181,7 @@ const importPostmanV2CollectionItem = (brunoParent, item, parentAuth, options) =
brunoParent.items = brunoParent.items || [];
const folderMap = {};
const requestMap = {};
const requestMethods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS', 'TRACE']
each(item, (i) => {
if (isItemAFolder(i)) {
@@ -230,6 +231,11 @@ const importPostmanV2CollectionItem = (brunoParent, item, parentAuth, options) =
} else {
if (i.request) {
if(!requestMethods.includes(i?.request?.method.toUpperCase())){
console.warn("Unexpected request.method")
return;
}
const baseRequestName = i.name;
let requestName = baseRequestName;
let count = 1;
@@ -422,7 +428,7 @@ const importPostmanV2CollectionItem = (brunoParent, item, parentAuth, options) =
brunoRequestItem.request.auth.mode = 'apikey';
brunoRequestItem.request.auth.apikey = {
key: authValues.key,
value: authValues.value,
value: authValues.value?.toString(), // Convert the value to a string as Postman's schema does not rigidly define the type of it,
placement: "header" //By default we are placing the apikey values in headers!
}
}
@@ -525,7 +531,9 @@ const parsePostmanCollection = (str, options) => {
let v2Schemas = [
'https://schema.getpostman.com/json/collection/v2.0.0/collection.json',
'https://schema.getpostman.com/json/collection/v2.1.0/collection.json'
'https://schema.getpostman.com/json/collection/v2.1.0/collection.json',
'https://schema.postman.com/json/collection/v2.0.0/collection.json',
'https://schema.postman.com/json/collection/v2.1.0/collection.json'
];
if (v2Schemas.includes(schema)) {

View File

@@ -24,6 +24,10 @@ const replacements = {
'postman\\.setEnvironmentVariable\\(': 'bru.setEnvVar(',
'postman\\.getEnvironmentVariable\\(': 'bru.getEnvVar(',
'postman\\.clearEnvironmentVariable\\(': 'bru.deleteEnvVar(',
'pm\\.execution\\.skipRequest\\(\\)': 'bru.runner.skipRequest()',
'pm\\.execution\\.skipRequest': 'bru.runner.skipRequest',
'pm\\.execution\\.setNextRequest\\(null\\)': 'bru.runner.stopExecution()',
'pm\\.execution\\.setNextRequest\\(\'null\'\\)': 'bru.runner.stopExecution()',
};
const extendedReplacements = Object.keys(replacements).reduce((acc, key) => {
@@ -50,7 +54,7 @@ export const postmanTranslation = (script, logCallback) => {
}
if (modifiedScript.includes('pm.') || modifiedScript.includes('postman.')) {
modifiedScript = modifiedScript.replace(/^(.*(pm\.|postman\.).*)$/gm, '// $1');
logCallback?.();
//logCallback?.();
}
return modifiedScript;
} catch (e) {

View File

@@ -11,3 +11,10 @@ export const isItemAFolder = (item) => {
export const itemIsOpenedInTabs = (item, tabs) => {
return find(tabs, (t) => t.uid === item.uid);
};
export const scrollToTheActiveTab = () => {
const activeTab = document.querySelector('.request-tab.active');
if (activeTab) {
activeTab.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
};

View File

@@ -46,13 +46,13 @@
"package.json"
],
"dependencies": {
"@aws-sdk/credential-providers": "3.658.1",
"@aws-sdk/credential-providers": "3.750.0",
"@usebruno/common": "0.1.0",
"@usebruno/js": "0.12.0",
"@usebruno/lang": "0.12.0",
"@usebruno/vm2": "^3.9.13",
"aws4-axios": "^3.3.0",
"axios": "1.7.5",
"axios": "^1.8.3",
"axios-ntlm": "^1.4.2",
"chai": "^4.3.7",
"chalk": "^3.0.0",
@@ -66,7 +66,6 @@
"qs": "^6.11.0",
"socks-proxy-agent": "^8.0.2",
"tough-cookie": "^4.1.3",
"@usebruno/vm2": "^3.9.13",
"xmlbuilder": "^15.1.1",
"yargs": "^17.6.2"
}

View File

@@ -11,6 +11,7 @@ const { rpad } = require('../utils/common');
const { bruToJson, getOptions, collectionBruToJson } = require('../utils/bru');
const { dotenvToJson } = require('@usebruno/lang');
const constants = require('../constants');
const { findItemInCollection } = require('../utils/collection');
const command = 'run [filename]';
const desc = 'Run a request';
@@ -18,6 +19,7 @@ const printRunSummary = (results) => {
let totalRequests = 0;
let passedRequests = 0;
let failedRequests = 0;
let skippedRequests = 0;
let totalAssertions = 0;
let passedAssertions = 0;
let failedAssertions = 0;
@@ -49,7 +51,10 @@ const printRunSummary = (results) => {
failedAssertions += 1;
}
}
if (!hasAnyTestsOrAssertions && result.error) {
if (!hasAnyTestsOrAssertions && result.skipped) {
skippedRequests += 1;
}
else if (!hasAnyTestsOrAssertions && result.error) {
failedRequests += 1;
} else {
passedRequests += 1;
@@ -62,6 +67,9 @@ const printRunSummary = (results) => {
if (failedRequests > 0) {
requestSummary += `, ${chalk.red(`${failedRequests} failed`)}`;
}
if (skippedRequests > 0) {
requestSummary += `, ${chalk.magenta(`${skippedRequests} skipped`)}`;
}
requestSummary += `, ${totalRequests} total`;
let assertSummary = `${rpad('Tests:', maxLength)} ${chalk.green(`${passedTests} passed`)}`;
@@ -84,6 +92,7 @@ const printRunSummary = (results) => {
totalRequests,
passedRequests,
failedRequests,
skippedRequests,
totalAssertions,
passedAssertions,
failedAssertions,
@@ -144,7 +153,7 @@ const createCollectionFromPath = (collectionPath) => {
});
}
}
return currentDirItems
return currentDirItems;
};
collection.items = traverse(collectionPath);
return collection;
@@ -634,6 +643,34 @@ const handler = async function (argv) {
}
const runtime = getJsSandboxRuntime(sandbox);
const runSingleRequestByPathname = async (relativeItemPathname) => {
return new Promise(async (resolve, reject) => {
let itemPathname = path.join(collectionPath, relativeItemPathname);
if (itemPathname && !itemPathname?.endsWith('.bru')) {
itemPathname = `${itemPathname}.bru`;
}
const bruJson = cloneDeep(findItemInCollection(collection, itemPathname));
if (bruJson) {
const res = await runSingleRequest(
itemPathname,
bruJson,
collectionPath,
runtimeVariables,
envVars,
processEnvVars,
brunoConfig,
collectionRoot,
runtime,
collection,
runSingleRequestByPathname
);
resolve(res?.response);
}
reject(`bru.runRequest: invalid request path - ${itemPathname}`);
});
}
let currentRequestIndex = 0;
let nJumps = 0; // count the number of jumps to avoid infinite loops
while (currentRequestIndex < bruJsons.length) {
@@ -651,7 +688,8 @@ const handler = async function (argv) {
brunoConfig,
collectionRoot,
runtime,
collection
collection,
runSingleRequestByPathname
);
results.push({
@@ -701,6 +739,11 @@ const handler = async function (argv) {
// determine next request
const nextRequestName = result?.nextRequestName;
if (result?.shouldStopRunnerExecution) {
break;
}
if (nextRequestName !== undefined) {
nJumps++;
if (nJumps > 10000) {

View File

@@ -62,7 +62,10 @@ const makeJUnitOutput = async (results, outputPath) => {
suite.testcase.push(testcase);
});
if (result.error) {
if (result?.skipped) {
suite['@skipped'] = 1;
}
else if (result.error) {
suite['@errors'] = 1;
suite['@tests'] = 1;
suite.testcase = [

View File

@@ -40,11 +40,13 @@ const runSingleRequest = async function (
brunoConfig,
collectionRoot,
runtime,
collection
collection,
runSingleRequestByPathname
) {
try {
let request;
let nextRequestName;
let shouldStopRunnerExecution = false;
let item = {
pathname: path.join(collectionPath, filename),
...bruJson
@@ -68,11 +70,41 @@ const runSingleRequest = async function (
collectionPath,
onConsoleLog,
processEnvVars,
scriptingConfig
scriptingConfig,
runSingleRequestByPathname
);
if (result?.nextRequestName !== undefined) {
nextRequestName = result.nextRequestName;
}
if (result?.stopExecution) {
shouldStopRunnerExecution = true;
}
if (result?.skipRequest) {
return {
test: {
filename: filename
},
request: {
method: request.method,
url: request.url,
headers: request.headers,
data: request.data
},
response: {
status: 'skipped',
statusText: 'request skipped via pre-request script',
data: null,
responseTime: 0
},
error: 'Request has been skipped from pre-request script',
skipped: true,
assertionResults: [],
testResults: [],
shouldStopRunnerExecution
};
}
}
// interpolate variables inside request
@@ -233,7 +265,30 @@ const runSingleRequest = async function (
if (!options.disableCookies) {
const cookieString = getCookieStringForUrl(request.url);
if (cookieString && typeof cookieString === 'string' && cookieString.length) {
request.headers['cookie'] = cookieString;
const existingCookieHeaderName = Object.keys(request.headers).find(
name => name.toLowerCase() === 'cookie'
);
const existingCookieString = existingCookieHeaderName ? request.headers[existingCookieHeaderName] : '';
// Helper function to parse cookies into an object
const parseCookies = (str) => str.split(';').reduce((cookies, cookie) => {
const [name, ...rest] = cookie.split('=');
if (name && name.trim()) {
cookies[name.trim()] = rest.join('=').trim();
}
return cookies;
}, {});
const mergedCookies = {
...parseCookies(existingCookieString),
...parseCookies(cookieString),
};
const combinedCookieString = Object.entries(mergedCookies)
.map(([name, value]) => `${name}=${value}`)
.join('; ');
request.headers[existingCookieHeaderName || 'Cookie'] = combinedCookieString;
}
}
@@ -323,7 +378,8 @@ const runSingleRequest = async function (
error: err?.message || err?.errors?.map(e => e?.message)?.at(0) || err?.code || 'Request Failed!',
assertionResults: [],
testResults: [],
nextRequestName: nextRequestName
nextRequestName: nextRequestName,
shouldStopRunnerExecution
};
}
}
@@ -363,11 +419,16 @@ const runSingleRequest = async function (
collectionPath,
null,
processEnvVars,
scriptingConfig
scriptingConfig,
runSingleRequestByPathname
);
if (result?.nextRequestName !== undefined) {
nextRequestName = result.nextRequestName;
}
if (result?.stopExecution) {
shouldStopRunnerExecution = true;
}
}
// run assertions
@@ -408,13 +469,18 @@ const runSingleRequest = async function (
collectionPath,
null,
processEnvVars,
scriptingConfig
scriptingConfig,
runSingleRequestByPathname
);
testResults = get(result, 'results', []);
if (result?.nextRequestName !== undefined) {
nextRequestName = result.nextRequestName;
}
if (result?.stopExecution) {
shouldStopRunnerExecution = true;
}
}
if (testResults?.length) {
@@ -447,7 +513,8 @@ const runSingleRequest = async function (
error: null,
assertionResults,
testResults,
nextRequestName: nextRequestName
nextRequestName: nextRequestName,
shouldStopRunnerExecution
};
} catch (err) {
console.log(chalk.red(stripExtension(filename)) + chalk.dim(` (${err.message})`));

View File

@@ -204,5 +204,6 @@ module.exports = {
mergeHeaders,
mergeVars,
mergeScripts,
findItemInCollection,
getTreePathFromCollectionToItem
}

View File

@@ -34,14 +34,10 @@ const parseDataFromResponse = (response, disableParsingResponseJson = false) =>
// Filter out ZWNBSP character
// https://gist.github.com/antic183/619f42b559b78028d1fe9e7ae8a1352d
data = data.replace(/^\uFEFF/, '');
// If the response is a string and starts and ends with double quotes, it's a stringified JSON and should not be parsed
if (!disableParsingResponseJson && !(typeof data === 'string' && data.startsWith('"') && data.endsWith('"'))) {
if (!disableParsingResponseJson) {
data = JSON.parse(data);
}
} catch {
}
} catch { }
return { data, dataBuffer };
};

View File

@@ -9,6 +9,7 @@
"scripts": {
"clean": "rimraf dist",
"dev": "electron .",
"debug": "electron . --inspect=9229",
"dist:mac": "electron-builder --mac --config electron-builder-config.js",
"dist:win": "electron-builder --win --config electron-builder-config.js",
"dist:linux": "electron-builder --linux AppImage --config electron-builder-config.js",
@@ -24,7 +25,7 @@
]
},
"dependencies": {
"@aws-sdk/credential-providers": "3.658.1",
"@aws-sdk/credential-providers": "3.750.0",
"@usebruno/common": "0.1.0",
"@usebruno/js": "0.12.0",
"@usebruno/lang": "0.12.0",
@@ -33,7 +34,7 @@
"@usebruno/vm2": "^3.9.13",
"about-window": "^1.15.2",
"aws4-axios": "^3.3.0",
"axios": "1.7.5",
"axios": "^1.8.3",
"axios-ntlm": "^1.4.2",
"chai": "^4.3.7",
"chokidar": "^3.5.3",

View File

@@ -1,8 +1,9 @@
const { sizeInMB } = require("../../utils/filesystem");
const WorkerQueue = require("../../workers");
const path = require("path");
const getSize = (data) => {
return typeof data === 'string' ? Buffer.byteLength(data, 'utf8') : Buffer.byteLength(JSON.stringify(data), 'utf8');
return sizeInMB(typeof data === 'string' ? Buffer.byteLength(data, 'utf8') : Buffer.byteLength(JSON.stringify(data), 'utf8'));
}
/**
@@ -12,7 +13,13 @@ const getSize = (data) => {
* This helps with parsing performance.
*/
const LANES = [{
maxSize: 0.005
},{
maxSize: 0.1
},{
maxSize: 1
},{
maxSize: 10
},{
maxSize: 100
}];

View File

@@ -1,14 +1,16 @@
const { workerData, parentPort } = require('worker_threads');
const { parentPort } = require('worker_threads');
const {
bruToJsonV2,
} = require('@usebruno/lang');
try {
const bru = workerData;
const json = bruToJsonV2(bru);
parentPort.postMessage(json);
}
catch(error) {
console.error(error);
parentPort.postMessage({ error: error?.message });
}
parentPort.on('message', (workerData) => {
try {
const bru = workerData;
const json = bruToJsonV2(bru);
parentPort.postMessage(json);
}
catch(error) {
console.error(error);
parentPort.postMessage({ error: error?.message });
}
});

View File

@@ -1,13 +1,16 @@
const { workerData, parentPort } = require('worker_threads');
const { parentPort } = require('worker_threads');
const {
jsonToBruV2,
} = require('@usebruno/lang');
try {
const json = workerData;
const bru = jsonToBruV2(json);
parentPort.postMessage(bru);
}
catch(error) {
console.error(error);
parentPort.postMessage({ error: error?.message });
}
parentPort.on('message', (workerData) => {
try {
const json = workerData;
const bru = jsonToBruV2(json);
parentPort.postMessage(bru);
}
catch(error) {
console.error(error);
parentPort.postMessage({ error: error?.message });
}
});

View File

@@ -30,9 +30,9 @@ const lastOpenedCollections = new LastOpenedCollections();
// Reference: https://content-security-policy.com/
const contentSecurityPolicy = [
"default-src 'self'",
"script-src * 'unsafe-inline' 'unsafe-eval'",
"connect-src * 'unsafe-inline'",
"connect-src 'self' https://*.posthog.com",
"font-src 'self' https:",
"frame-src data:",
// this has been commented out to make oauth2 work
// "form-action 'none'",
// we make an exception and allow http for images so that

View File

@@ -20,7 +20,6 @@ const {
normalizeWslPath,
normalizeAndResolvePath,
safeToRename,
sanitizeCollectionName,
isWindowsOS,
isValidFilename,
hasSubDirectories,
@@ -30,7 +29,7 @@ const {
const { openCollectionDialog } = require('../app/collections');
const { generateUidBasedOnHash, stringifyJson, safeParseJSON, safeStringifyJSON } = require('../utils/common');
const { moveRequestUid, deleteRequestUid } = require('../cache/requestUids');
const { deleteCookiesForDomain, getDomainsWithCookies } = require('../utils/cookies');
const { deleteCookiesForDomain, getDomainsWithCookies, addCookieForDomain, modifyCookieForDomain, parseCookieString, createCookieString, deleteCookie } = require('../utils/cookies');
const EnvironmentSecretsStore = require('../store/env-secrets');
const CollectionSecurityStore = require('../store/collection-security');
const UiStateSnapshotStore = require('../store/ui-state-snapshot');
@@ -41,9 +40,9 @@ const collectionSecurityStore = new CollectionSecurityStore();
const uiStateSnapshotStore = new UiStateSnapshotStore();
// size and file count limits to determine whether the bru files in the collection should be loaded asynchronously or not.
const MAX_COLLECTION_SIZE_IN_MB = 5;
const MAX_SINGLE_FILE_SIZE_IN_COLLECTION_IN_MB = 2;
const MAX_COLLECTION_FILES_COUNT = 100;
const MAX_COLLECTION_SIZE_IN_MB = 20;
const MAX_SINGLE_FILE_SIZE_IN_COLLECTION_IN_MB = 5;
const MAX_COLLECTION_FILES_COUNT = 2000;
const envHasSecrets = (environment = {}) => {
const secrets = _.filter(environment.variables, (v) => v.secret);
@@ -62,13 +61,11 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
});
// browse directory for file
ipcMain.handle('renderer:browse-files', async (event, pathname, request, filters) => {
ipcMain.handle('renderer:browse-files', async (_, filters, properties) => {
try {
const filePaths = await browseFiles(mainWindow, filters);
return filePaths;
return await browseFiles(mainWindow, filters, properties);
} catch (error) {
return Promise.reject(error);
throw error;
}
});
@@ -78,7 +75,6 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
async (event, collectionName, collectionFolderName, collectionLocation) => {
try {
collectionFolderName = sanitizeDirectoryName(collectionFolderName);
collectionName = sanitizeCollectionName(collectionName);
const dirPath = path.join(collectionLocation, collectionFolderName);
if (fs.existsSync(dirPath)) {
const files = fs.readdirSync(dirPath);
@@ -120,7 +116,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
ipcMain.handle(
'renderer:clone-collection',
async (event, collectionName, collectionFolderName, collectionLocation, previousPath) => {
collectionFolderName = sanitizeCollectionName(collectionFolderName);
collectionFolderName = sanitizeDirectoryName(collectionFolderName);
const dirPath = path.join(collectionLocation, collectionFolderName);
if (fs.existsSync(dirPath)) {
throw new Error(`collection: ${dirPath} already exists`);
@@ -141,7 +137,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
// Change new name of collection
let brunoConfig = JSON.parse(content);
brunoConfig.name = collectionName;
const cont = await stringifyJson(json);
const cont = await stringifyJson(brunoConfig);
// write the bruno.json to new dir
await writeFile(path.join(dirPath, 'bruno.json'), cont);
@@ -170,7 +166,6 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
// rename collection
ipcMain.handle('renderer:rename-collection', async (event, newName, collectionPathname) => {
try {
newName = sanitizeCollectionName(newName);
const brunoJsonFilePath = path.join(collectionPathname, 'bruno.json');
const content = fs.readFileSync(brunoJsonFilePath, 'utf8');
const json = JSON.parse(content);
@@ -400,7 +395,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
* And it is not WSL path (meaning its not linux running on windows using WSL)
* And it has sub directories
* Only then we need to use the temp dir approach to rename the folder
*
*
* Windows OS would sometimes throw error when renaming a folder with sub directories
* This is a alternative approach to avoid that error
*/
@@ -521,9 +516,13 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
}
});
ipcMain.handle('renderer:update-collection-paths', async (_, collectionPaths) => {
lastOpenedCollections.update(collectionPaths);
})
ipcMain.handle('renderer:import-collection', async (event, collection, collectionLocation) => {
try {
let collectionName = sanitizeCollectionName(collection.name);
let collectionName = sanitizeDirectoryName(collection.name);
let collectionPath = path.join(collectionLocation, collectionName);
if (fs.existsSync(collectionPath)) {
@@ -771,6 +770,54 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
}
});
ipcMain.handle('renderer:delete-cookie', async (event, domain, path, cookieKey) => {
try {
await deleteCookie(domain, path, cookieKey);
const domainsWithCookies = await getDomainsWithCookies();
mainWindow.webContents.send('main:cookies-update', safeParseJSON(safeStringifyJSON(domainsWithCookies)));
} catch (error) {
return Promise.reject(error);
}
});
// add cookie
ipcMain.handle('renderer:add-cookie', async (event, domain, cookie) => {
try {
await addCookieForDomain(domain, cookie);
const domainsWithCookies = await getDomainsWithCookies();
mainWindow.webContents.send('main:cookies-update', safeParseJSON(safeStringifyJSON(domainsWithCookies)));
} catch (error) {
return Promise.reject(error);
}
});
// modify cookie
ipcMain.handle('renderer:modify-cookie', async (event, domain, oldCookie, cookie) => {
try {
await modifyCookieForDomain(domain, oldCookie, cookie);
const domainsWithCookies = await getDomainsWithCookies();
mainWindow.webContents.send('main:cookies-update', safeParseJSON(safeStringifyJSON(domainsWithCookies)));
} catch (error) {
return Promise.reject(error);
}
});
ipcMain.handle('renderer:get-parsed-cookie', async (event, cookieStr) => {
try {
return parseCookieString(cookieStr);
} catch (error) {
return Promise.reject(error);
}
});
ipcMain.handle('renderer:create-cookie-string', async (event, cookie) => {
try {
return createCookieString(cookie);
} catch (error) {
return Promise.reject(error);
}
});
ipcMain.handle('renderer:save-collection-security-config', async (event, collectionPath, securityConfig) => {
try {
collectionSecurityStore.setSecurityConfigForCollection(collectionPath, {
@@ -909,6 +956,18 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
watcher.addWatcher(mainWindow, collectionPathname, collectionUid, brunoConfig, false, shouldLoadCollectionAsync);
});
ipcMain.handle('renderer:show-in-folder', async (event, filePath) => {
try {
if (!filePath) {
throw new Error('File path is required');
}
shell.showItemInFolder(filePath);
} catch (error) {
console.error('Error in show-in-folder: ', error);
throw error;
}
});
};
const registerMainEventHandlers = (mainWindow, watcher, lastOpenedCollections) => {

View File

@@ -28,7 +28,7 @@ const { makeAxiosInstance } = require('./axios-instance');
const { addAwsV4Interceptor, resolveAwsV4Credentials } = require('./awsv4auth-helper');
const { addDigestInterceptor } = require('./digestauth-helper');
const { shouldUseProxy, PatchedHttpsProxyAgent } = require('../../utils/proxy-util');
const { chooseFileToSave, writeBinaryFile, writeFile } = require('../../utils/filesystem');
const { chooseFileToSave, writeFile } = require('../../utils/filesystem');
const { getCookieStringForUrl, addCookieToJar, getDomainsWithCookies } = require('../../utils/cookies');
const {
resolveOAuth2AuthorizationCodeAccessToken,
@@ -274,7 +274,6 @@ const configureRequest = async (
});
}
let axiosInstance = makeAxiosInstance();
if (request.ntlmConfig) {
@@ -333,7 +332,30 @@ const configureRequest = async (
if (preferencesUtil.shouldSendCookies()) {
const cookieString = getCookieStringForUrl(request.url);
if (cookieString && typeof cookieString === 'string' && cookieString.length) {
request.headers['cookie'] = cookieString;
const existingCookieHeaderName = Object.keys(request.headers).find(
name => name.toLowerCase() === 'cookie'
);
const existingCookieString = existingCookieHeaderName ? request.headers[existingCookieHeaderName] : '';
// Helper function to parse cookies into an object
const parseCookies = (str) => str.split(';').reduce((cookies, cookie) => {
const [name, ...rest] = cookie.split('=');
if (name && name.trim()) {
cookies[name.trim()] = rest.join('=').trim();
}
return cookies;
}, {});
const mergedCookies = {
...parseCookies(existingCookieString),
...parseCookies(cookieString),
};
const combinedCookieString = Object.entries(mergedCookies)
.map(([name, value]) => `${name}=${value}`)
.join('; ');
request.headers[existingCookieHeaderName || 'Cookie'] = combinedCookieString;
}
}
@@ -376,18 +398,15 @@ const parseDataFromResponse = (response, disableParsingResponseJson = false) =>
// Filter out ZWNBSP character
// https://gist.github.com/antic183/619f42b559b78028d1fe9e7ae8a1352d
data = data.replace(/^\uFEFF/, '');
// If the response is a string and starts and ends with double quotes, it's a stringified JSON and should not be parsed
if ( !disableParsingResponseJson && ! (typeof data === 'string' && data.startsWith("\"") && data.endsWith("\""))) {
if (!disableParsingResponseJson) {
data = JSON.parse(data);
}
} catch {
console.log('Failed to parse response data as JSON');
}
} catch { }
return { data, dataBuffer };
};
const registerNetworkIpc = (mainWindow) => {
const onConsoleLog = (type, args) => {
console[type](...args);
@@ -403,7 +422,7 @@ const registerNetworkIpc = (mainWindow) => {
requestUid,
envVars,
collectionPath,
collectionRoot,
collection,
collectionUid,
runtimeVariables,
processEnvVars,
@@ -437,6 +456,8 @@ const registerNetworkIpc = (mainWindow) => {
mainWindow.webContents.send('main:global-environment-variables-update', {
globalEnvironmentVariables: scriptResult.globalEnvironmentVariables
});
collection.globalEnvironmentVariables = scriptResult.globalEnvironmentVariables;
}
// interpolate variables inside request
@@ -470,7 +491,7 @@ const registerNetworkIpc = (mainWindow) => {
requestUid,
envVars,
collectionPath,
collectionRoot,
collection,
collectionUid,
runtimeVariables,
processEnvVars,
@@ -507,6 +528,8 @@ const registerNetworkIpc = (mainWindow) => {
if (result?.error) {
mainWindow.webContents.send('main:display-error', result.error);
}
collection.globalEnvironmentVariables = result.globalEnvironmentVariables;
}
// run post-response script
@@ -537,11 +560,13 @@ const registerNetworkIpc = (mainWindow) => {
mainWindow.webContents.send('main:global-environment-variables-update', {
globalEnvironmentVariables: scriptResult.globalEnvironmentVariables
});
collection.globalEnvironmentVariables = scriptResult.globalEnvironmentVariables;
}
return scriptResult;
};
const runRequest = async ({ item, collection, environment, runtimeVariables, runInBackground = false }) => {
const runRequest = async ({ item, collection, envVars, processEnvVars, runtimeVariables, runInBackground = false }) => {
const collectionUid = collection.uid;
const collectionPath = collection.pathname;
const cancelTokenUid = uuid();
@@ -553,9 +578,9 @@ const registerNetworkIpc = (mainWindow) => {
if (itemPathname && !itemPathname?.endsWith('.bru')) {
itemPathname = `${itemPathname}.bru`;
}
const _item = findItemInCollectionByPathname(collection, itemPathname);
const _item = cloneDeep(findItemInCollectionByPathname(collection, itemPathname));
if(_item) {
const res = await runRequest({ item: _item, collection, environment, runtimeVariables, runInBackground: true });
const res = await runRequest({ item: _item, collection, envVars, processEnvVars, runtimeVariables, runInBackground: true });
resolve(res);
}
reject(`bru.runRequest: invalid request path - ${itemPathname}`);
@@ -570,33 +595,50 @@ const registerNetworkIpc = (mainWindow) => {
cancelTokenUid
});
const collectionRoot = get(collection, 'root', {});
const request = prepareRequest(item, collection);
const abortController = new AbortController();
const request = await prepareRequest(item, collection, abortController);
request.__bruno__executionMode = 'standalone';
const envVars = getEnvVars(environment);
const processEnvVars = getProcessEnvVars(collectionUid);
const brunoConfig = getBrunoConfig(collectionUid);
const scriptingConfig = get(brunoConfig, 'scripts', {});
scriptingConfig.runtime = getJsSandboxRuntime(collection);
try {
const controller = new AbortController();
request.signal = controller.signal;
saveCancelToken(cancelTokenUid, controller);
request.signal = abortController.signal;
saveCancelToken(cancelTokenUid, abortController);
await runPreRequest(
request,
requestUid,
envVars,
collectionPath,
collectionRoot,
collectionUid,
runtimeVariables,
processEnvVars,
scriptingConfig,
runRequestByItemPathname
);
try {
await runPreRequest(
request,
requestUid,
envVars,
collectionPath,
collection,
collectionUid,
runtimeVariables,
processEnvVars,
scriptingConfig,
runRequestByItemPathname
);
!runInBackground && mainWindow.webContents.send('main:run-request-event', {
type: 'pre-request-script-execution',
requestUid,
collectionUid,
itemUid: item.uid,
errorMessage: null,
});
} catch (error) {
!runInBackground && mainWindow.webContents.send('main:run-request-event', {
type: 'pre-request-script-execution',
requestUid,
collectionUid,
itemUid: item.uid,
errorMessage: error?.message || 'An error occurred in pre-request script',
});
return Promise.reject(error);
}
const axiosInstance = await configureRequest(
collectionUid,
request,
@@ -612,7 +654,7 @@ const registerNetworkIpc = (mainWindow) => {
url: request.url,
method: request.method,
headers: request.headers,
data: safeParseJSON(safeStringifyJSON(request.data)),
data: request.mode == 'file'? "<request body redacted>": safeParseJSON(safeStringifyJSON(request.data)) ,
timestamp: Date.now()
},
collectionUid,
@@ -668,19 +710,41 @@ const registerNetworkIpc = (mainWindow) => {
mainWindow.webContents.send('main:cookies-update', safeParseJSON(safeStringifyJSON(domainsWithCookies)));
await runPostResponse(
request,
response,
requestUid,
envVars,
collectionPath,
collectionRoot,
collectionUid,
runtimeVariables,
processEnvVars,
scriptingConfig,
runRequestByItemPathname
);
try {
await runPostResponse(
request,
response,
requestUid,
envVars,
collectionPath,
collection,
collectionUid,
runtimeVariables,
processEnvVars,
scriptingConfig,
runRequestByItemPathname
);
!runInBackground && mainWindow.webContents.send('main:run-request-event', {
type: 'post-response-script-execution',
requestUid,
collectionUid,
errorMessage: null,
itemUid: item.uid,
});
} catch (error) {
console.error('Post-response script error:', error);
// Format a more readable error message
const errorMessage = error?.message || 'An error occurred in post-response script';
!runInBackground && mainWindow.webContents.send('main:run-request-event', {
type: 'post-response-script-execution',
requestUid,
errorMessage,
collectionUid,
itemUid: item.uid,
});
}
// run assertions
const assertions = get(request, 'assertions');
@@ -758,7 +822,10 @@ const registerNetworkIpc = (mainWindow) => {
// handler for sending http request
ipcMain.handle('send-http-request', async (event, item, collection, environment, runtimeVariables) => {
return await runRequest({ item, collection, environment, runtimeVariables });
const collectionUid = collection.uid;
const envVars = getEnvVars(environment);
const processEnvVars = getProcessEnvVars(collectionUid);
return await runRequest({ item, collection, envVars, processEnvVars, runtimeVariables, runInBackground: false });
});
ipcMain.handle('send-collection-oauth2-request', async (event, collection, environment, runtimeVariables) => {
@@ -782,7 +849,7 @@ const registerNetworkIpc = (mainWindow) => {
requestUid,
envVars,
collectionPath,
collectionRoot,
collection,
collectionUid,
runtimeVariables,
processEnvVars,
@@ -818,7 +885,7 @@ const registerNetworkIpc = (mainWindow) => {
requestUid,
envVars,
collectionPath,
collectionRoot,
collection,
collectionUid,
runtimeVariables,
processEnvVars,
@@ -888,7 +955,7 @@ const registerNetworkIpc = (mainWindow) => {
requestUid,
envVars,
collectionPath,
collectionRoot,
collection,
collectionUid,
runtimeVariables,
processEnvVars,
@@ -912,7 +979,7 @@ const registerNetworkIpc = (mainWindow) => {
requestUid,
envVars,
collectionPath,
collectionRoot,
collection,
collectionUid,
runtimeVariables,
processEnvVars,
@@ -949,7 +1016,8 @@ const registerNetworkIpc = (mainWindow) => {
const brunoConfig = getBrunoConfig(collectionUid);
const scriptingConfig = get(brunoConfig, 'scripts', {});
scriptingConfig.runtime = getJsSandboxRuntime(collection);
const collectionRoot = get(collection, 'root', {});
const envVars = getEnvVars(environment);
const processEnvVars = getProcessEnvVars(collectionUid);
let stopRunnerExecution = false;
const abortController = new AbortController();
@@ -961,9 +1029,9 @@ const registerNetworkIpc = (mainWindow) => {
if (itemPathname && !itemPathname?.endsWith('.bru')) {
itemPathname = `${itemPathname}.bru`;
}
const _item = findItemInCollectionByPathname(collection, itemPathname);
const _item = cloneDeep(findItemInCollectionByPathname(collection, itemPathname));
if(_item) {
const res = await runRequest({ item: _item, collection, environment, runtimeVariables, runInBackground: true });
const res = await runRequest({ item: _item, collection, envVars, processEnvVars, runtimeVariables, runInBackground: true });
resolve(res);
}
reject(`bru.runRequest: invalid request path - ${itemPathname}`);
@@ -983,7 +1051,6 @@ const registerNetworkIpc = (mainWindow) => {
});
try {
const envVars = getEnvVars(environment);
let folderRequests = [];
if (recursive) {
@@ -1031,11 +1098,10 @@ const registerNetworkIpc = (mainWindow) => {
...eventData
});
const request = prepareRequest(item, collection);
const request = await prepareRequest(item, collection, abortController);
request.__bruno__executionMode = 'runner';
const requestUid = uuid();
const processEnvVars = getProcessEnvVars(collectionUid);
try {
const preRequestScriptResult = await runPreRequest(
@@ -1043,7 +1109,7 @@ const registerNetworkIpc = (mainWindow) => {
requestUid,
envVars,
collectionPath,
collectionRoot,
collection,
collectionUid,
runtimeVariables,
processEnvVars,
@@ -1181,7 +1247,7 @@ const registerNetworkIpc = (mainWindow) => {
requestUid,
envVars,
collectionPath,
collectionRoot,
collection,
collectionUid,
runtimeVariables,
processEnvVars,
@@ -1367,7 +1433,7 @@ const registerNetworkIpc = (mainWindow) => {
if (encoding === 'utf-8') {
await writeFile(filePath, data);
} else {
await writeBinaryFile(filePath, data);
await writeFile(filePath, data, true);
}
}
} catch (error) {

View File

@@ -65,7 +65,11 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc
const contentType = getContentType(request.headers);
if (contentType.includes('json')) {
/*
We explicitly avoid interpolating buffer values because the file content is read as a buffer object in raw body mode.
Even if the selected file's content type is JSON, this prevents the buffer object from being interpolated.
*/
if (contentType.includes('json') && !Buffer.isBuffer(request.data)) {
if (typeof request.data === 'string') {
if (request.data.length) {
request.data = _interpolate(request.data);

View File

@@ -1,8 +1,10 @@
const { get, each, filter } = require('lodash');
const { get, each, filter, find } = require('lodash');
const decomment = require('decomment');
const crypto = require('node:crypto');
const fs = require('node:fs/promises');
const { getTreePathFromCollectionToItem, mergeHeaders, mergeScripts, mergeVars } = require('../../utils/collection');
const { buildFormUrlEncodedPayload, createFormData } = require('../../utils/form-data');
const path = require('node:path');
const setAuthHeaders = (axiosRequest, request, collectionRoot) => {
@@ -174,10 +176,10 @@ const setAuthHeaders = (axiosRequest, request, collectionRoot) => {
return axiosRequest;
};
const prepareRequest = (item, collection) => {
const prepareRequest = async (item, collection = {}, abortController) => {
const request = item.draft ? item.draft.request : item.request;
const collectionRoot = get(collection, 'root', {});
const collectionPath = collection.pathname;
const collectionPath = collection?.pathname;
const headers = {};
let contentTypeDefined = false;
let url = request.url;
@@ -189,7 +191,7 @@ const prepareRequest = (item, collection) => {
}
});
const scriptFlow = collection.brunoConfig?.scripts?.flow ?? 'sandwich';
const scriptFlow = collection?.brunoConfig?.scripts?.flow ?? 'sandwich';
const requestTreePath = getTreePathFromCollectionToItem(collection, item);
if (requestTreePath && requestTreePath.length > 0) {
mergeHeaders(collection, request, requestTreePath);
@@ -251,6 +253,31 @@ const prepareRequest = (item, collection) => {
axiosRequest.data = request.body.sparql;
}
if (request.body.mode === 'file') {
if (!contentTypeDefined) {
axiosRequest.headers['content-type'] = 'application/octet-stream'; // Default headers for binary file uploads
}
const bodyFile = find(request.body.file, (param) => param.selected);
if (bodyFile) {
let { filePath, contentType } = bodyFile;
axiosRequest.headers['content-type'] = contentType;
if (filePath) {
if (!path.isAbsolute(filePath)) {
filePath = path.join(collectionPath, filePath);
}
try {
const fileContent = await fs.readFile(filePath);
axiosRequest.data = fileContent;
} catch (error) {
console.error('Error reading file:', error);
}
}
}
}
if (request.body.mode === 'formUrlEncoded') {
if (!contentTypeDefined) {
axiosRequest.headers['content-type'] = 'application/x-www-form-urlencoded';

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