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
189 changed files with 9151 additions and 1032 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,5 +1,7 @@
import styled from 'styled-components';
const Wrapper = styled.div``;
const Wrapper = styled.div`
max-width: 800px;
`;
export default Wrapper;

View File

@@ -1,14 +1,9 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
div.CodeMirror {
.CodeMirror-scroll {
padding-bottom: 0px;
}
}
.editing-mode {
cursor: pointer;
color: ${(props) => props.theme.colors.text.yellow};
}
`;

View File

@@ -8,6 +8,7 @@ import { saveCollectionRoot } from 'providers/ReduxStore/slices/collections/acti
import Markdown from 'components/MarkDown';
import CodeEditor from 'components/CodeEditor';
import StyledWrapper from './StyledWrapper';
import { IconEdit, IconX, IconFileText } from '@tabler/icons';
const Docs = ({ collection }) => {
const dispatch = useDispatch();
@@ -29,35 +30,95 @@ const Docs = ({ collection }) => {
);
};
const onSave = () => dispatch(saveCollectionRoot(collection.uid));
const handleDiscardChanges = () => {
dispatch(
updateCollectionDocs({
collectionUid: collection.uid,
docs: docs
})
);
toggleViewMode();
}
const onSave = () => {
dispatch(saveCollectionRoot(collection.uid));
toggleViewMode();
}
return (
<StyledWrapper className="mt-1 h-full w-full relative flex flex-col">
<div className="editing-mode flex justify-between items-center" role="tab" onClick={toggleViewMode}>
{isEditing ? 'Preview' : 'Edit'}
</div>
{isEditing ? (
<div className="mt-2 flex-1 max-h-[70vh]">
<CodeEditor
collection={collection}
theme={displayedTheme}
value={docs || ''}
onEdit={onEdit}
onSave={onSave}
mode="application/text"
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
/>
<button type="submit" className="submit btn btn-sm btn-secondary my-6" onClick={onSave}>
Save
</button>
<div className='flex flex-row w-full justify-between items-center mb-4'>
<div className='text-lg font-medium flex items-center gap-2'>
<IconFileText size={20} strokeWidth={1.5} />
Documentation
</div>
<div className='flex flex-row gap-2 items-center justify-center'>
{isEditing ? (
<>
<div className="editing-mode" role="tab" onClick={handleDiscardChanges}>
<IconX className="cursor-pointer" size={20} strokeWidth={1.5} />
</div>
<button type="submit" className="submit btn btn-sm btn-secondary" onClick={onSave}>
Save
</button>
</>
) : (
<div className="editing-mode" role="tab" onClick={toggleViewMode}>
<IconEdit className="cursor-pointer" size={20} strokeWidth={1.5} />
</div>
)}
</div>
</div>
{isEditing ? (
<CodeEditor
collection={collection}
theme={displayedTheme}
value={docs}
onEdit={onEdit}
onSave={onSave}
mode="application/text"
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
/>
) : (
<Markdown collectionPath={collection.pathname} onDoubleClick={toggleViewMode} content={docs} />
<div className='h-full overflow-auto pl-1'>
<div className='h-[1px] min-h-[500px]'>
{
docs?.length > 0 ?
<Markdown collectionPath={collection.pathname} onDoubleClick={toggleViewMode} content={docs} />
:
<Markdown collectionPath={collection.pathname} onDoubleClick={toggleViewMode} content={documentationPlaceholder} />
}
</div>
</div>
)}
</StyledWrapper>
);
};
export default Docs;
const documentationPlaceholder = `
Welcome to your collection documentation! This space is designed to help you document your API collection effectively.
## Overview
Use this section to provide a high-level overview of your collection. You can describe:
- The purpose of these API endpoints
- Key features and functionalities
- Target audience or users
## Best Practices
- Keep documentation up to date
- Include request/response examples
- Document error scenarios
- Add relevant links and references
## Markdown Support
This documentation supports Markdown formatting! You can use:
- **Bold** and *italic* text
- \`code blocks\` and syntax highlighting
- Tables and lists
- [Links](https://example.com)
- And more!
`;

View File

@@ -1,6 +1,8 @@
import styled from 'styled-components';
const Wrapper = styled.div`
max-width: 800px;
table {
width: 100%;
border-collapse: collapse;

View File

@@ -1,13 +0,0 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
table {
td {
&:first-child {
width: 120px;
}
}
}
`;
export default StyledWrapper;

View File

@@ -1,39 +0,0 @@
import React from 'react';
import StyledWrapper from './StyledWrapper';
import { getTotalRequestCountInCollection } from 'utils/collections/';
const Info = ({ collection }) => {
const totalRequestsInCollection = getTotalRequestCountInCollection(collection);
return (
<StyledWrapper className="w-full flex flex-col h-full">
<div className="text-xs mb-4 text-muted">General information about the collection.</div>
<table className="w-full border-collapse">
<tbody>
<tr className="">
<td className="py-2 px-2 text-right">Name&nbsp;:</td>
<td className="py-2 px-2">{collection.name}</td>
</tr>
<tr className="">
<td className="py-2 px-2 text-right">Location&nbsp;:</td>
<td className="py-2 px-2 break-all">{collection.pathname}</td>
</tr>
<tr className="">
<td className="py-2 px-2 text-right">Ignored files&nbsp;:</td>
<td className="py-2 px-2 break-all">{collection.brunoConfig?.ignore?.map((x) => `'${x}'`).join(', ')}</td>
</tr>
<tr className="">
<td className="py-2 px-2 text-right">Environments&nbsp;:</td>
<td className="py-2 px-2">{collection.environments?.length || 0}</td>
</tr>
<tr className="">
<td className="py-2 px-2 text-right">Requests&nbsp;:</td>
<td className="py-2 px-2">{totalRequestsInCollection}</td>
</tr>
</tbody>
</table>
</StyledWrapper>
);
};
export default Info;

View File

@@ -0,0 +1,82 @@
import React from "react";
import { getTotalRequestCountInCollection } from 'utils/collections/';
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">
<div className="grid gap-6">
{/* Location Row */}
<div className="flex items-start">
<div className="flex-shrink-0 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
<IconFolder className="w-5 h-5 text-blue-500" stroke={1.5} />
</div>
<div className="ml-4">
<div className="font-semibold text-sm">Location</div>
<div className="mt-1 text-sm text-muted break-all">
{collection.pathname}
</div>
</div>
</div>
{/* Environments Row */}
<div className="flex items-start">
<div className="flex-shrink-0 p-3 bg-green-50 dark:bg-green-900/20 rounded-lg">
<IconWorld className="w-5 h-5 text-green-500" stroke={1.5} />
</div>
<div className="ml-4">
<div className="font-semibold text-sm">Environments</div>
<div className="mt-1 text-sm text-muted">
{collection.environments?.length || 0} environment{collection.environments?.length !== 1 ? 's' : ''} configured
</div>
</div>
</div>
{/* Requests Row */}
<div className="flex items-start">
<div className="flex-shrink-0 p-3 bg-purple-50 dark:bg-purple-900/20 rounded-lg">
<IconApi className="w-5 h-5 text-purple-500" stroke={1.5} />
</div>
<div className="ml-4">
<div className="font-semibold text-sm">Requests</div>
<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;

View File

@@ -0,0 +1,25 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
&.card {
background-color: ${(props) => props.theme.requestTabPanel.card.bg};
.title {
border-top: 1px solid ${(props) => props.theme.requestTabPanel.cardTable.border};
border-left: 1px solid ${(props) => props.theme.requestTabPanel.cardTable.border};
border-right: 1px solid ${(props) => props.theme.requestTabPanel.cardTable.border};
border-top-left-radius: 3px;
border-top-right-radius: 3px;
}
.table {
thead {
background-color: ${(props) => props.theme.requestTabPanel.cardTable.table.thead.bg};
color: ${(props) => props.theme.requestTabPanel.cardTable.table.thead.color};
}
}
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,80 @@
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);
if (!itemsFailedLoading?.length) {
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">
<IconAlertTriangle size={16} className="text-yellow-500" />
<span className="font-medium">Following requests were not loaded</span>
</div>
<table className="w-full border-collapse">
<thead>
<tr>
<th className="py-2 px-3 text-left font-medium">
Pathname
</th>
<th className="py-2 px-3 text-left font-medium">
Size
</th>
</tr>
</thead>
<tbody>
{flattenedItems?.map((item, index) => (
item?.partial && !item?.loading ? (
<tr key={index} className='cursor-pointer' onClick={handleRequestClick(item)}>
<td className="py-1.5 px-3">
{item?.pathname?.split(`${collection?.pathname}/`)?.[1]}
</td>
<td className="py-1.5 px-3">
{item?.size?.toFixed?.(2)}&nbsp;MB
</td>
</tr>
) : null
))}
</tbody>
</table>
</StyledWrapper>
);
};
export default RequestsNotLoaded;

View File

@@ -0,0 +1,25 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.partial {
color: ${(props) => props.theme.colors.text.yellow};
opacity: 0.8;
}
.loading {
color: ${(props) => props.theme.colors.text.muted};
opacity: 0.8;
}
.completed {
color: ${(props) => props.theme.colors.text.green};
opacity: 0.8;
}
.failed {
color: ${(props) => props.theme.colors.text.danger};
opacity: 0.8;
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,27 @@
import StyledWrapper from "./StyledWrapper";
import Docs from "../Docs";
import Info from "./Info";
import { IconBox } from '@tabler/icons';
import RequestsNotLoaded from "./RequestsNotLoaded";
const Overview = ({ collection }) => {
return (
<div className="h-full">
<div className="grid grid-cols-5 gap-4 h-full">
<div className="col-span-2">
<div className="text-xl font-semibold flex items-center gap-2">
<IconBox size={24} stroke={1.5} />
{collection?.name}
</div>
<Info collection={collection} />
<RequestsNotLoaded collection={collection} />
</div>
<div className="col-span-3">
<Docs collection={collection} />
</div>
</div>
</div>
);
}
export default Overview;

View File

@@ -1,6 +1,8 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
max-width: 800px;
.settings-label {
width: 110px;
}

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

@@ -1,6 +1,8 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
max-width: 800px;
div.CodeMirror {
height: inherit;
}

View File

@@ -1,8 +1,6 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
max-width: 800px;
div.tabs {
div.tab {
padding: 6px 0px;

View File

@@ -1,5 +1,7 @@
import styled from 'styled-components';
const StyledWrapper = styled.div``;
const StyledWrapper = styled.div`
max-width: 800px;
`;
export default StyledWrapper;

View File

@@ -1,6 +1,8 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
max-width: 800px;
div.title {
color: var(--color-tab-inactive);
}

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

@@ -12,12 +12,11 @@ import Headers from './Headers';
import Auth from './Auth';
import Script from './Script';
import Test from './Tests';
import Docs from './Docs';
import Presets from './Presets';
import Info from './Info';
import StyledWrapper from './StyledWrapper';
import Vars from './Vars/index';
import DotIcon from 'components/Icons/Dot';
import Overview from './Overview/index';
const ContentIndicator = () => {
return (
@@ -97,6 +96,9 @@ const CollectionSettings = ({ collection }) => {
const getTabPanel = (tab) => {
switch (tab) {
case 'overview': {
return <Overview collection={collection} />;
}
case 'headers': {
return <Headers collection={collection} />;
}
@@ -128,12 +130,6 @@ const CollectionSettings = ({ collection }) => {
/>
);
}
case 'docs': {
return <Docs collection={collection} />;
}
case 'info': {
return <Info collection={collection} />;
}
}
};
@@ -146,6 +142,9 @@ const CollectionSettings = ({ collection }) => {
return (
<StyledWrapper className="flex flex-col h-full relative px-4 py-4">
<div className="flex flex-wrap items-center tabs" role="tablist">
<div className={getTabClassname('overview')} role="tab" onClick={() => setTab('overview')}>
Overview
</div>
<div className={getTabClassname('headers')} role="tab" onClick={() => setTab('headers')}>
Headers
{activeHeadersCount > 0 && <sup className="ml-1 font-medium">{activeHeadersCount}</sup>}
@@ -177,13 +176,6 @@ const CollectionSettings = ({ collection }) => {
Client Certificates
{clientCertConfig.length > 0 && <ContentIndicator />}
</div>
<div className={getTabClassname('docs')} role="tab" onClick={() => setTab('docs')}>
Docs
{hasDocs && <ContentIndicator />}
</div>
<div className={getTabClassname('info')} role="tab" onClick={() => setTab('info')}>
Info
</div>
</div>
<section className="mt-4 h-full">{getTabPanel(tab)}</section>
</StyledWrapper>

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

@@ -3,7 +3,6 @@ import styled from 'styled-components';
const StyledWrapper = styled.div`
.editing-mode {
cursor: pointer;
color: ${(props) => props.theme.colors.text.yellow};
}
`;

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

@@ -9,21 +9,20 @@ const StyledMarkdownBodyWrapper = styled.div`
box-sizing: border-box;
height: 100%;
margin: 0 auto;
padding-top: 0.5rem;
font-size: 0.875rem;
h1 {
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);
}
@@ -80,12 +79,6 @@ const StyledMarkdownBodyWrapper = styled.div`
}
}
}
@media (max-width: 767px) {
.markdown-body {
padding: 15px;
}
}
`;
export default StyledMarkdownBodyWrapper;

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

@@ -0,0 +1,19 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
div.card {
background: ${(props) => props.theme.requestTabPanel.card.bg};
border: 1px solid ${(props) => props.theme.requestTabPanel.card.border};
div.hr {
border-bottom: 1px solid ${(props) => props.theme.requestTabPanel.card.hr};
height: 1px;
}
div.border-top {
border-top: 1px solid ${(props) => props.theme.requestTabPanel.card.border};
}
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,47 @@
import { IconLoader2, IconFile } from '@tabler/icons';
import StyledWrapper from './StyledWrapper';
const RequestIsLoading = ({ item }) => {
return <StyledWrapper>
<div className='flex flex-col p-4'>
<div className='card shadow-sm rounded-md p-4 w-[600px]'>
<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='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>
<div className='hr'/>
<div className='flex items-center gap-2 mt-4'>
<IconLoader2 className="animate-spin" size={16} strokeWidth={2} />
<span>Loading...</span>
</div>
</div>
</div>
</div>
</StyledWrapper>
}
export default RequestIsLoading;

View File

@@ -0,0 +1,19 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
div.card {
background: ${(props) => props.theme.requestTabPanel.card.bg};
border: 1px solid ${(props) => props.theme.requestTabPanel.card.border};
div.hr {
border-bottom: 1px solid ${(props) => props.theme.requestTabPanel.card.hr};
height: 1px;
}
div.border-top {
border-top: 1px solid ${(props) => props.theme.requestTabPanel.card.border};
}
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,83 @@
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';
const RequestNotLoaded = ({ collection, item }) => {
const dispatch = useDispatch();
const handleLoadRequestViaWorker = () => {
!item?.loading && dispatch(loadRequestViaWorker({ collectionUid: collection?.uid, pathname: item?.pathname }));
}
const handleLoadRequest = () => {
!item?.loading && dispatch(loadRequest({ collectionUid: collection?.uid, pathname: item?.pathname }));
}
return <StyledWrapper>
<div className='flex flex-col p-4'>
<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='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='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-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 in background
</button>
<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='flex items-center gap-2 mt-4'>
<IconLoader2 className="animate-spin" size={16} strokeWidth={2} />
<span>Loading...</span>
</div>
</>
)}
</div>
</div>
</div>
</StyledWrapper>
}
export default RequestNotLoaded;

View File

@@ -22,6 +22,9 @@ import SecuritySettings from 'components/SecuritySettings';
import FolderSettings from 'components/FolderSettings';
import { getGlobalEnvironmentVariables, getGlobalEnvironmentVariablesMasked } from 'utils/collections/index';
import { produce } from 'immer';
import CollectionOverview from 'components/CollectionSettings/Overview';
import RequestNotLoaded from './RequestNotLoaded';
import RequestIsLoading from './RequestIsLoading';
const MIN_LEFT_PANE_WIDTH = 300;
const MIN_RIGHT_PANE_WIDTH = 350;
@@ -153,6 +156,11 @@ const RequestTabPanel = () => {
if (focusedTab.type === 'collection-settings') {
return <CollectionSettings collection={collection} />;
}
if (focusedTab.type === 'collection-overview') {
return <CollectionOverview collection={collection} />;
}
if (focusedTab.type === 'folder-settings') {
const folder = findItemInCollection(collection, focusedTab.folderUid);
return <FolderSettings collection={collection} folder={folder} />;
@@ -167,6 +175,14 @@ const RequestTabPanel = () => {
return <RequestNotFound itemUid={activeTabUid} />;
}
if (item?.partial) {
return <RequestNotLoaded item={item} collection={collection} />
}
if (item?.loading) {
return <RequestIsLoading item={item} />
}
const handleRun = async () => {
dispatch(sendRequest(item, collection.uid)).catch((err) =>
toast.custom((t) => <NetworkError onClose={() => toast.dismiss(t.id)} />, {

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,10 +2,18 @@ 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': {
return (
<>
<IconSettings size={18} strokeWidth={1.5} className="text-yellow-600" />
@@ -23,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';
@@ -70,16 +70,16 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
};
const folder = folderUid ? findItemInCollection(collection, folderUid) : null;
if (['collection-settings', 'folder-settings', 'variables', 'collection-runner', 'security-settings'].includes(tab.type)) {
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

@@ -9,15 +9,16 @@ import { IconRefresh, IconCircleCheck, IconCircleX, IconCheck, IconX, IconRun }
import slash from 'utils/common/slash';
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 }) {
@@ -57,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) {
@@ -106,6 +107,8 @@ export default function RunnerResults({ collection }) {
return (item.status !== 'error' && item.testStatus === 'fail') || item.assertionStatus === 'fail';
});
let isCollectionLoading = areItemsLoading(collection);
if (!items || !items.length) {
return (
<StyledWrapper className="px-4 pb-4">
@@ -116,7 +119,7 @@ export default function RunnerResults({ collection }) {
<div className="mt-6">
You have <span className="font-medium">{totalRequestsInCollection}</span> requests in this collection.
</div>
{isCollectionLoading ? <div className='my-1 danger'>Requests in this collection are still loading.</div> : null}
<div className="mt-6">
<label>Delay (in ms)</label>
<input
@@ -183,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} />
@@ -263,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} />
@@ -272,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

@@ -0,0 +1,12 @@
import styled from 'styled-components';
const Wrapper = styled.div`
.partial {
color: ${(props) => props.theme.colors.text.yellow};
}
.error {
color: ${(props) => props.theme.colors.text.danger};
}
`;
export default Wrapper;

View File

@@ -0,0 +1,21 @@
import RequestMethod from "../RequestMethod";
import { IconLoader2, IconAlertTriangle, IconAlertCircle } from '@tabler/icons';
import StyledWrapper from "./StyledWrapper";
const CollectionItemIcon = ({ item }) => {
if (item?.error) {
return <StyledWrapper><IconAlertCircle className="w-fit mr-2 error" size={18} strokeWidth={1.5} /></StyledWrapper>;
}
if (item?.loading) {
return <IconLoader2 className="animate-spin w-fit mr-2" size={18} strokeWidth={1.5} />;
}
if (item?.partial) {
return <StyledWrapper><IconAlertTriangle size={18} className="w-fit mr-2 partial" strokeWidth={1.5} /></StyledWrapper>;
}
return <RequestMethod item={item} />;
};
export default CollectionItemIcon;

View File

@@ -4,6 +4,9 @@ const Wrapper = styled.div`
.bruno-modal-content {
padding-bottom: 1rem;
}
.warning {
color: ${(props) => props.theme.colors.text.danger};
}
`;
export default Wrapper;

View File

@@ -7,6 +7,7 @@ import { addTab } from 'providers/ReduxStore/slices/tabs';
import { runCollectionFolder } from 'providers/ReduxStore/slices/collections/actions';
import { flattenItems } from 'utils/collections';
import StyledWrapper from './StyledWrapper';
import { areItemsLoading } from 'utils/collections';
const RunCollectionItem = ({ collection, item, onClose }) => {
const dispatch = useDispatch();
@@ -32,6 +33,10 @@ const RunCollectionItem = ({ collection, item, onClose }) => {
const flattenedItems = flattenItems(item ? item.items : collection.items);
const recursiveRunLength = getRequestsCount(flattenedItems);
const isFolderLoading = areItemsLoading(item);
console.log(item);
console.log(isFolderLoading);
return (
<StyledWrapper>
<Modal size="md" title="Collection Runner" hideFooter={true} handleCancel={onClose}>
@@ -44,13 +49,12 @@ const RunCollectionItem = ({ collection, item, onClose }) => {
<span className="ml-1 text-xs">({runLength} requests)</span>
</div>
<div className="mb-8">This will only run the requests in this folder.</div>
<div className="mb-1">
<span className="font-medium">Recursive Run</span>
<span className="ml-1 text-xs">({recursiveRunLength} requests)</span>
</div>
<div className="mb-8">This will run all the requests in this folder and all its subfolders.</div>
<div className={isFolderLoading ? "mb-2" : "mb-8"}>This will run all the requests in this folder and all its subfolders.</div>
{isFolderLoading ? <div className='mb-8 warning'>Requests in this folder are still loading.</div> : null}
<div className="flex justify-end bruno-modal-footer">
<span className="mr-3">
<button type="button" onClick={onClose} className="btn btn-md btn-close">

View File

@@ -5,13 +5,12 @@ 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';
import NewFolder from 'components/Sidebar/NewFolder';
import RequestMethod from './RequestMethod';
import RenameCollectionItem from './RenameCollectionItem';
import CloneCollectionItem from './CloneCollectionItem';
import DeleteCollectionItem from './DeleteCollectionItem';
@@ -24,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 { uuid } from 'utils/common';
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);
@@ -39,38 +41,35 @@ const CollectionItem = ({ item, collection, searchText }) => {
const [newRequestModalOpen, setNewRequestModalOpen] = useState(false);
const [newFolderModalOpen, setNewFolderModalOpen] = useState(false);
const [runCollectionModalOpen, setRunCollectionModalOpen] = useState(false);
const [itemIsCollapsed, setItemisCollapsed] = useState(item.collapsed);
const hasSearchText = searchText && searchText?.trim()?.length;
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(),
}),
});
useEffect(() => {
if (searchText && searchText.length) {
setItemisCollapsed(false);
} else {
setItemisCollapsed(item.collapsed);
}
}, [searchText, item]);
drag(drop(collectionItemRef));
const dropdownTippyRef = useRef();
const MenuIcon = forwardRef((props, ref) => {
@@ -90,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)} />, {
@@ -106,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(
@@ -119,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(
@@ -141,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,
@@ -163,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);
@@ -187,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);
@@ -227,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)));
@@ -253,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) => {
@@ -280,6 +286,9 @@ const CollectionItem = ({ item, collection, searchText }) => {
style={{
paddingLeft: 8
}}
onClick={handleClick}
onContextMenu={handleRightClick}
onDoubleClick={handleDoubleClick}
>
<div style={{ width: 16, minWidth: 16 }}>
{isFolder ? (
@@ -294,12 +303,9 @@ const CollectionItem = ({ item, collection, searchText }) => {
</div>
<div
className="ml-1 flex items-center overflow-hidden flex-1"
onClick={handleClick}
onContextMenu={handleRightClick}
onDoubleClick={handleDoubleClick}
className="ml-1 flex w-full h-full items-center overflow-hidden"
>
<RequestMethod item={item} />
<CollectionItemIcon item={item} />
<span className="item-name" title={item.name}>
{item.name}
</span>
@@ -378,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) => {
@@ -421,4 +436,4 @@ const CollectionItem = ({ item, collection, searchText }) => {
);
};
export default CollectionItem;
export default CollectionItem;

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 { IconChevronRight, IconDots } from '@tabler/icons';
import { useDrop, useDrag } from 'react-dnd';
import { IconChevronRight, IconDots, IconLoader2 } from '@tabler/icons';
import Dropdown from 'components/Dropdown';
import { collectionClicked } from 'providers/ReduxStore/slices/collections';
import { moveItemToRootOfCollection } from 'providers/ReduxStore/slices/collections/actions';
import { useDispatch } from 'react-redux';
import { addTab } from 'providers/ReduxStore/slices/tabs';
import { collapseCollection } from 'providers/ReduxStore/slices/collections';
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, transformCollectionToSaveToExportAsFile } from 'utils/collections';
import exportCollection from 'utils/collections/export';
import { isItemAFolder, isItemARequest } from 'utils/collections';
import RenameCollection from './RenameCollection';
import StyledWrapper from './StyledWrapper';
import CloneCollection from './CloneCollection/index';
import CloneCollection from './CloneCollection';
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 [collectionIsCollapsed, setCollectionIsCollapsed] = useState(collection.collapsed);
const dispatch = useDispatch();
const isLoading = areItemsLoading(collection);
const collectionRef = useRef(null);
const menuDropdownTippyRef = useRef();
const onMenuDropdownCreate = (ref) => (menuDropdownTippyRef.current = ref);
@@ -52,31 +54,53 @@ const Collection = ({ collection, searchText }) => {
);
};
useEffect(() => {
if (searchText && searchText.length) {
setCollectionIsCollapsed(false);
} else {
setCollectionIsCollapsed(collection.collapsed);
const ensureCollectionIsMounted = () => {
if (collection.mountStatus === 'unmounted') {
dispatch(mountCollection({
collectionUid: collection.uid,
collectionPathname: collection.pathname,
brunoConfig: collection.brunoConfig
}));
}
}, [searchText, collection]);
}
const hasSearchText = searchText && searchText?.trim()?.length;
const collectionIsCollapsed = hasSearchText ? false : collection.collapsed;
const iconClassName = classnames({
'rotate-90': !collectionIsCollapsed
});
const handleClick = (event) => {
dispatch(collectionClicked(collection.uid));
if (event.detail != 1) return;
// Check if the click came from the chevron icon
const isChevronClick = event.target.closest('svg')?.classList.contains('chevron-icon');
setTimeout(scrollToTheActiveTab, 50);
ensureCollectionIsMounted();
dispatch(collapseCollection(collection.uid));
if(!isChevronClick) {
dispatch(
addTab({
uid: collection.uid,
collectionUid: collection.uid,
type: 'collection-settings',
})
);
}
};
const handleCollapseCollection = () => {
dispatch(collectionClicked(collection.uid));
dispatch(
addTab({
uid: uuid(),
collectionUid: collection.uid,
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) => {
@@ -93,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);
@@ -143,28 +192,32 @@ 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={handleCollapseCollection}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
onContextMenu={handleRightClick}
>
<IconChevronRight
size={16}
strokeWidth={2}
className={iconClassName}
className={`chevron-icon ${iconClassName}`}
style={{ width: 16, minWidth: 16, color: 'rgb(160 160 160)' }}
onClick={handleClick}
onClick={handleCollectionCollapse}
/>
<div className="ml-1" id="sidebar-collection-name">
{collection.name}
</div>
{isLoading ? <IconLoader2 className="animate-spin mx-1" size={18} strokeWidth={1.5} /> : null}
</div>
<div className="collection-actions">
<Dropdown onCreate={onMenuDropdownCreate} icon={<MenuIcon />} placement="bottom-start">
@@ -217,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.0</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,8 +21,9 @@ 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';
import {
collectionAddEnvFileEvent as _collectionAddEnvFileEvent,
@@ -30,6 +31,8 @@ import {
removeCollection as _removeCollection,
selectEnvironment as _selectEnvironment,
sortCollections as _sortCollections,
updateCollectionMountStatus,
moveCollection,
requestCancelled,
resetRunResults,
responseReceived,
@@ -42,7 +45,6 @@ import { closeAllCollectionTabs } from 'providers/ReduxStore/slices/tabs';
import { resolveRequestFilename } from 'utils/common/platform';
import { parsePathParams, parseQueryParams, splitOnFirst } from 'utils/url/index';
import { sendCollectionOauth2Request as _sendCollectionOauth2Request } from 'utils/network/index';
import { name } from 'file-loader';
import slash from 'utils/common/slash';
import { getGlobalEnvironmentVariables } from 'utils/collections/index';
import { findCollectionByPathname, findEnvironmentInCollectionByName } from 'utils/collections/index';
@@ -161,7 +163,6 @@ export const saveFolderRoot = (collectionUid, folderUid) => (dispatch, getState)
if (!folder) {
return reject(new Error('Folder not found'));
}
console.log(collection);
const { ipcRenderer } = window;
@@ -170,7 +171,6 @@ export const saveFolderRoot = (collectionUid, folderUid) => (dispatch, getState)
pathname: folder.pathname,
root: folder.root
};
console.log(folderData);
ipcRenderer
.invoke('renderer:save-folder-root', folderData)
@@ -494,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;
@@ -759,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'
@@ -1039,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();
@@ -1148,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;
@@ -1192,4 +1212,38 @@ export const hydrateCollectionWithUiStateSnapshot = (payload) => (dispatch, getS
reject(error);
}
});
};
};
export const loadRequestViaWorker = ({ collectionUid, pathname }) => (dispatch, getState) => {
return new Promise(async (resolve, reject) => {
const { ipcRenderer } = window;
ipcRenderer.invoke('renderer:load-request-via-worker', { collectionUid, pathname }).then(resolve).catch(reject);
});
};
export const loadRequest = ({ collectionUid, pathname }) => (dispatch, getState) => {
return new Promise(async (resolve, reject) => {
const { ipcRenderer } = window;
ipcRenderer.invoke('renderer:load-request', { collectionUid, pathname }).then(resolve).catch(reject);
});
};
export const mountCollection = ({ collectionUid, collectionPathname, brunoConfig }) => (dispatch, getState) => {
dispatch(updateCollectionMountStatus({ collectionUid, mountStatus: 'mounting' }));
return new Promise(async (resolve, reject) => {
callIpc('renderer:mount-collection', { collectionUid, collectionPathname, brunoConfig })
.then(() => dispatch(updateCollectionMountStatus({ collectionUid, mountStatus: 'mounted' })))
.then(resolve)
.catch(() => {
dispatch(updateCollectionMountStatus({ collectionUid, mountStatus: 'unmounted' }));
reject();
});
});
};
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,10 +1,10 @@
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,
areItemsTheSameExceptSeqUpdate,
collapseCollection,
collapseAllItemsInCollection,
deleteItemInCollection,
deleteItemInCollectionByPathname,
findCollectionByPathname,
@@ -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: [],
@@ -32,9 +34,13 @@ export const collectionsSlice = createSlice({
const collectionUids = map(state.collections, (c) => c.uid);
const collection = action.payload;
collection.settingsSelectedTab = 'headers';
collection.settingsSelectedTab = 'overview';
collection.folderLevelSettingsSelectedTab = {};
// Collection mount status is used to track the mount status of the collection
// values can be 'unmounted', 'mounting', 'mounted'
collection.mountStatus = 'unmounted';
// TODO: move this to use the nextAction approach
// last action is used to track the last action performed on the collection
// this is optional
@@ -44,12 +50,20 @@ export const collectionsSlice = createSlice({
collection.importedAt = new Date().getTime();
collection.lastAction = null;
collapseCollection(collection);
collapseAllItemsInCollection(collection);
addDepth(collection.items);
if (!collectionUids.includes(collection.uid)) {
state.collections.push(collection);
}
},
updateCollectionMountStatus: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
if (collection) {
if (action.payload.mountStatus) {
collection.mountStatus = action.payload.mountStatus;
}
}
},
setCollectionSecurityConfig: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
if (collection) {
@@ -88,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);
@@ -358,7 +378,7 @@ export const collectionsSlice = createSlice({
collection.items.push(item);
}
},
collectionClicked: (state, action) => {
collapseCollection: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload);
if (collection) {
@@ -885,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);
@@ -941,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;
@@ -1582,7 +1676,7 @@ export const collectionsSlice = createSlice({
name: directoryName,
collapsed: true,
type: 'folder',
items: []
items: [],
};
currentSubItems.push(childItem);
}
@@ -1604,6 +1698,10 @@ export const collectionsSlice = createSlice({
currentItem.filename = file.meta.name;
currentItem.pathname = file.meta.pathname;
currentItem.draft = null;
currentItem.partial = file.partial;
currentItem.loading = file.loading;
currentItem.size = file.size;
currentItem.error = file.error;
} else {
currentSubItems.push({
uid: file.data.uid,
@@ -1613,7 +1711,11 @@ export const collectionsSlice = createSlice({
request: file.data.request,
filename: file.meta.name,
pathname: file.meta.pathname,
draft: null
draft: null,
partial: file.partial,
loading: file.loading,
size: file.size,
error: file.error
});
}
}
@@ -1667,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;
@@ -1745,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;
@@ -1890,6 +2008,7 @@ export const collectionsSlice = createSlice({
export const {
createCollection,
updateCollectionMountStatus,
setCollectionSecurityConfig,
brunoConfigUpdateEvent,
renameCollection,
@@ -1913,7 +2032,7 @@ export const {
saveRequest,
deleteRequestDraft,
newEphemeralHttpRequest,
collectionClicked,
collapseCollection,
collectionFolderClicked,
requestUrlChanged,
updateAuth,
@@ -1933,6 +2052,9 @@ export const {
addMultipartFormParam,
updateMultipartFormParam,
deleteMultipartFormParam,
addFile,
updateFile,
deleteFile,
moveMultipartFormParam,
updateRequestAuthMode,
updateRequestBodyMode,
@@ -1984,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-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

@@ -114,7 +114,25 @@ const darkTheme = {
responseStatus: '#ccc',
responseOk: '#8cd656',
responseError: '#f06f57',
responseOverlayBg: 'rgba(30, 30, 30, 0.6)'
responseOverlayBg: 'rgba(30, 30, 30, 0.6)',
card: {
bg: '#252526',
border: 'transparent',
borderDark: '#8cd656',
hr: '#424242'
},
cardTable: {
border: '#333',
bg: '#252526',
table: {
thead: {
bg: '#3D3D3D',
color: '#ccc'
}
}
}
},
collection: {

View File

@@ -114,7 +114,22 @@ const lightTheme = {
responseStatus: 'rgb(117 117 117)',
responseOk: '#047857',
responseError: 'rgb(185, 28, 28)',
responseOverlayBg: 'rgba(255, 255, 255, 0.6)'
responseOverlayBg: 'rgba(255, 255, 255, 0.6)',
card: {
bg: '#fff',
border: '#f4f4f4',
hr: '#f4f4f4'
},
cardTable: {
border: '#efefef',
bg: '#fff',
table: {
thead: {
bg: 'rgb(249, 250, 251)',
color: 'rgb(75 85 99)'
}
}
}
},
collection: {

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';
@@ -34,7 +25,7 @@ export const addDepth = (items = []) => {
depth(items, 1);
};
export const collapseCollection = (collection) => {
export const collapseAllItemsInCollection = (collection) => {
collection.collapsed = true;
const collapseItem = (items) => {
@@ -47,7 +38,7 @@ export const collapseCollection = (collection) => {
});
};
collapseItem(collection.items, 1);
collapseItem(collection.items);
};
export const sortItems = (collection) => {
@@ -136,6 +127,30 @@ export const findEnvironmentInCollectionByName = (collection, name) => {
return find(collection.environments, (e) => e.name === name);
};
export const areItemsLoading = (folder) => {
let flattenedItems = flattenItems(folder.items);
return flattenedItems?.reduce((isLoading, i) => {
if (i?.loading) {
isLoading = true;
}
return isLoading;
}, 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);
@@ -271,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') {
@@ -298,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,
@@ -651,6 +678,10 @@ export const humanizeRequestBodyMode = (mode) => {
label = 'SPARQL';
break;
}
case 'file': {
label = 'File / Binary';
break;
}
case 'formUrlEncoded': {
label = 'Form URL Encoded';
break;
@@ -751,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;
};
@@ -761,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;
};
@@ -991,4 +1025,4 @@ const mergeVars = (collection, requestTreePath = []) => {
folderVariables,
requestVariables
};
};
};

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

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