mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-03 09:28:33 +00:00
Compare commits
89 Commits
feat/parse
...
feature/de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de603ecbfb | ||
|
|
6a85635c49 | ||
|
|
0fbbe8a996 | ||
|
|
4ff4e3b732 | ||
|
|
7c65317b07 | ||
|
|
b57c996564 | ||
|
|
5de75892a2 | ||
|
|
d5e828aef2 | ||
|
|
51c86bc0e9 | ||
|
|
253cb8b315 | ||
|
|
0876ad0dab | ||
|
|
a1c133b303 | ||
|
|
38cf206075 | ||
|
|
9d598db55e | ||
|
|
233c57e625 | ||
|
|
b399576dab | ||
|
|
655eec09c1 | ||
|
|
51eda3f08c | ||
|
|
a438c06b97 | ||
|
|
4977dbeb11 | ||
|
|
7cacc255b4 | ||
|
|
b28b60d4a7 | ||
|
|
2fc45de430 | ||
|
|
c58604716e | ||
|
|
31409c6206 | ||
|
|
dfb0b1b966 | ||
|
|
f8b4a0b85b | ||
|
|
200732bac5 | ||
|
|
c997924c42 | ||
|
|
711eb24c01 | ||
|
|
3b8a613914 | ||
|
|
81930f6fe6 | ||
|
|
e0750148e6 | ||
|
|
528f822294 | ||
|
|
31c11830a6 | ||
|
|
eff3b29bfb | ||
|
|
4656958f2f | ||
|
|
aa4575b0ea | ||
|
|
14d798eea7 | ||
|
|
8af285d41e | ||
|
|
6a0eb1ccde | ||
|
|
4a613ed1b7 | ||
|
|
7566d658d4 | ||
|
|
70ee819bae | ||
|
|
8810b9e291 | ||
|
|
89a4cd62bc | ||
|
|
4c1765e9f9 | ||
|
|
8351589994 | ||
|
|
4d063b2d0b | ||
|
|
288628003f | ||
|
|
ced6ddfab5 | ||
|
|
5291bbaef7 | ||
|
|
667b15386c | ||
|
|
086e943c04 | ||
|
|
901c4e1ca2 | ||
|
|
14e9227e07 | ||
|
|
d8a2e6f405 | ||
|
|
5c096cff7b | ||
|
|
0913e668e0 | ||
|
|
f0c3125227 | ||
|
|
a5d23599a1 | ||
|
|
28ca0494e0 | ||
|
|
722d9788ca | ||
|
|
038f2d1f0b | ||
|
|
8f604efc7e | ||
|
|
af182a9c00 | ||
|
|
dd23962958 | ||
|
|
324b7cec51 | ||
|
|
8abf8ff9c8 | ||
|
|
ec5a5c9b56 | ||
|
|
4598acd068 | ||
|
|
062ab00a66 | ||
|
|
650cb47a8b | ||
|
|
08139a8f3e | ||
|
|
51e087efba | ||
|
|
ff5683f19f | ||
|
|
d13e4b3b54 | ||
|
|
2d08567d8d | ||
|
|
b51f8109a2 | ||
|
|
8f241a32ae | ||
|
|
b7fda331dc | ||
|
|
10e0fde2a8 | ||
|
|
9f5f975f70 | ||
|
|
b5bd259a1b | ||
|
|
0633d45a10 | ||
|
|
16e27d2ca4 | ||
|
|
956d5a38e9 | ||
|
|
bfc1101371 | ||
|
|
05be59f00c |
42
.github/ISSUE_TEMPLATE/BugReport.yaml
vendored
42
.github/ISSUE_TEMPLATE/BugReport.yaml
vendored
@@ -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
|
||||
|
||||
14
.github/ISSUE_TEMPLATE/FeatureRequest.yaml
vendored
14
.github/ISSUE_TEMPLATE/FeatureRequest.yaml
vendored
@@ -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
|
||||
|
||||
9
.github/workflows/tests.yml
vendored
9
.github/workflows/tests.yml
vendored
@@ -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
3695
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
62
packages/bruno-app/src/components/Accordion/index.js
Normal file
62
packages/bruno-app/src/components/Accordion/index.js
Normal 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;
|
||||
28
packages/bruno-app/src/components/Accordion/styledWrapper.js
Normal file
28
packages/bruno-app/src/components/Accordion/styledWrapper.js
Normal 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 };
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
div.CodeMirror {
|
||||
.CodeMirror-scroll {
|
||||
padding-bottom: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.editing-mode {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
import React from 'react';
|
||||
import React from "react";
|
||||
import { getTotalRequestCountInCollection } from 'utils/collections/';
|
||||
import { IconFolder, IconFileOff, IconWorld, IconApi } from '@tabler/icons';
|
||||
import { IconFolder, IconWorld, IconApi, IconShare } from '@tabler/icons';
|
||||
import { areItemsLoading, getItemsLoadStats } from "utils/collections/index";
|
||||
import { useState } from "react";
|
||||
import ShareCollection from "components/ShareCollection/index";
|
||||
|
||||
const Info = ({ collection }) => {
|
||||
const totalRequestsInCollection = getTotalRequestCountInCollection(collection);
|
||||
|
||||
const isCollectionLoading = areItemsLoading(collection);
|
||||
const { loading: itemsLoadingCount, total: totalItems } = getItemsLoadStats(collection);
|
||||
const [showShareCollectionModal, toggleShowShareCollectionModal] = useState(false);
|
||||
|
||||
const handleToggleShowShareCollectionModal = (value) => (e) => {
|
||||
toggleShowShareCollectionModal(value);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col h-fit">
|
||||
<div className="rounded-lg py-6">
|
||||
@@ -42,15 +53,30 @@ const Info = ({ collection }) => {
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<div className="font-semibold text-sm">Requests</div>
|
||||
<div className="mt-1 text-sm text-muted">
|
||||
{totalRequestsInCollection} request{totalRequestsInCollection !== 1 ? 's' : ''} in collection
|
||||
<div className="mt-1 text-sm text-muted font-mono">
|
||||
{
|
||||
isCollectionLoading? `${totalItems - itemsLoadingCount} out of ${totalItems} requests in the collection loaded` : `${totalRequestsInCollection} request${totalRequestsInCollection !== 1 ? 's' : ''} in collection`
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start group cursor-pointer" onClick={handleToggleShowShareCollectionModal(true)}>
|
||||
<div className="flex-shrink-0 p-3 bg-indigo-50 dark:bg-indigo-900/20 rounded-lg">
|
||||
<IconShare className="w-5 h-5 text-indigo-500" stroke={1.5} />
|
||||
</div>
|
||||
<div className="ml-4 h-full flex flex-col justify-start">
|
||||
<div className="font-semibold text-sm h-fit my-auto">Share</div>
|
||||
<div className="mt-1 text-sm group-hover:underline text-link">
|
||||
Share Collection
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{showShareCollectionModal && <ShareCollection collection={collection} onClose={handleToggleShowShareCollectionModal(false)} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Info;
|
||||
export default Info;
|
||||
@@ -2,8 +2,15 @@ import React from 'react';
|
||||
import { flattenItems } from "utils/collections";
|
||||
import { IconAlertTriangle } from '@tabler/icons';
|
||||
import StyledWrapper from "./StyledWrapper";
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { isItemARequest, itemIsOpenedInTabs } from 'utils/tabs/index';
|
||||
import { getDefaultRequestPaneTab } from 'utils/collections/index';
|
||||
import { addTab, focusTab } from 'providers/ReduxStore/slices/tabs';
|
||||
import { hideHomePage } from 'providers/ReduxStore/slices/app';
|
||||
|
||||
const RequestsNotLoaded = ({ collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
const flattenedItems = flattenItems(collection.items);
|
||||
const itemsFailedLoading = flattenedItems?.filter(item => item?.partial && !item?.loading);
|
||||
|
||||
@@ -11,6 +18,29 @@ const RequestsNotLoaded = ({ collection }) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleRequestClick = (item) => e => {
|
||||
e.preventDefault();
|
||||
if (isItemARequest(item)) {
|
||||
dispatch(hideHomePage());
|
||||
if (itemIsOpenedInTabs(item, tabs)) {
|
||||
dispatch(
|
||||
focusTab({
|
||||
uid: item.uid
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
dispatch(
|
||||
addTab({
|
||||
uid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
requestPaneTab: getDefaultRequestPaneTab(item)
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full card my-2">
|
||||
<div className="flex items-center gap-2 px-3 py-2 title bg-yellow-50 dark:bg-yellow-900/20">
|
||||
@@ -31,7 +61,7 @@ const RequestsNotLoaded = ({ collection }) => {
|
||||
<tbody>
|
||||
{flattenedItems?.map((item, index) => (
|
||||
item?.partial && !item?.loading ? (
|
||||
<tr key={index}>
|
||||
<tr key={index} className='cursor-pointer' onClick={handleRequestClick(item)}>
|
||||
<td className="py-1.5 px-3">
|
||||
{item?.pathname?.split(`${collection?.pathname}/`)?.[1]}
|
||||
</td>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div``;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
104
packages/bruno-app/src/components/Icons/OpenAPILogo/index.js
Normal file
104
packages/bruno-app/src/components/Icons/OpenAPILogo/index.js
Normal 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;
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -15,14 +15,14 @@ const StyledMarkdownBodyWrapper = styled.div`
|
||||
margin: 0.67em 0;
|
||||
font-weight: var(--base-text-weight-semibold, 600);
|
||||
padding-bottom: 0.3em;
|
||||
font-size: 1.3;
|
||||
font-size: 1.3em;
|
||||
border-bottom: 1px solid var(--color-border-muted);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-weight: var(--base-text-weight-semibold, 600);
|
||||
padding-bottom: 0.3em;
|
||||
font-size: 1.2;
|
||||
font-size: 1.2em;
|
||||
border-bottom: 1px solid var(--color-border-muted);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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;
|
||||
164
packages/bruno-app/src/components/RequestPane/FileBody/index.js
Normal file
164
packages/bruno-app/src/components/RequestPane/FileBody/index.js
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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%' }
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IconLoader2, IconFile } from '@tabler/icons';
|
||||
import { IconLoader2, IconFile, IconAlertTriangle } from '@tabler/icons';
|
||||
import { loadRequest, loadRequestViaWorker } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
@@ -15,65 +15,59 @@ const RequestNotLoaded = ({ collection, item }) => {
|
||||
|
||||
return <StyledWrapper>
|
||||
<div className='flex flex-col p-4'>
|
||||
<div className='card shadow-sm rounded-md p-4 w-[600px]'>
|
||||
<div className='card shadow-sm rounded-md p-4 w-[685px]'>
|
||||
<div>
|
||||
<div className='font-medium flex items-center gap-2 pb-4'>
|
||||
<IconFile size={16} strokeWidth={1.5} className="text-gray-400" />
|
||||
File Info
|
||||
</div>
|
||||
<div className='hr'/>
|
||||
<div className='hr' />
|
||||
|
||||
<div className='flex items-center mt-2'>
|
||||
<span className='w-12 mr-2 text-muted'>Name:</span>
|
||||
<div>{item?.name}</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className='flex items-center mt-1'>
|
||||
<span className='w-12 mr-2 text-muted'>Path:</span>
|
||||
<div className='break-all'>{item?.pathname}</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className='flex items-center mt-1 pb-4'>
|
||||
<span className='w-12 mr-2 text-muted'>Size:</span>
|
||||
<div>{item?.size?.toFixed?.(2)} MB</div>
|
||||
</div>
|
||||
|
||||
{!item?.error && (
|
||||
<>
|
||||
<div className='hr'/>
|
||||
<div className='text-muted text-xs mt-4 mb-2'>
|
||||
Due to its large size, this request wasn't loaded automatically.
|
||||
<div className='flex flex-col'>
|
||||
<div className='flex items-center gap-2 px-3 py-2 title bg-yellow-50 dark:bg-yellow-900/20'>
|
||||
<IconAlertTriangle size={16} className="text-yellow-500" />
|
||||
<span>The request wasn't loaded due to its large size. Please try again with the following options:</span>
|
||||
</div>
|
||||
<div className='flex flex-col gap-6 mt-4'>
|
||||
<div className='flex flex-col'>
|
||||
<button
|
||||
className={`submit btn btn-sm btn-secondary w-fit h-fit flex flex-row gap-2 ${item?.loading? 'opacity-50 cursor-blocked': ''}`}
|
||||
onClick={handleLoadRequest}
|
||||
>
|
||||
Load Request
|
||||
</button>
|
||||
<small className='text-muted mt-1'>
|
||||
May cause the app to freeze temporarily while it runs.
|
||||
</small>
|
||||
</div>
|
||||
<div className='flex flex-col'>
|
||||
<div className='flex flex-row mt-6 gap-2 items-center w-full'>
|
||||
<button
|
||||
className={`submit btn btn-sm btn-secondary w-fit h-fit flex flex-row gap-2 ${item?.loading? 'opacity-50 cursor-blocked': ''}`}
|
||||
onClick={handleLoadRequestViaWorker}
|
||||
>
|
||||
Load Request in Background
|
||||
Load in background
|
||||
</button>
|
||||
<small className='text-muted mt-1'>
|
||||
Runs in background.
|
||||
</small>
|
||||
</div>
|
||||
<p>(Runs in background)</p>
|
||||
</div>
|
||||
</>
|
||||
<div className='flex flex-row mt-6 items-center gap-2 w-full'>
|
||||
<button
|
||||
className={`submit btn btn-sm btn-secondary w-fit h-fit flex flex-row gap-2 ${item?.loading? 'opacity-50 cursor-blocked': ''}`}
|
||||
onClick={handleLoadRequest}
|
||||
>
|
||||
Force load
|
||||
</button>
|
||||
<p>(May cause the app to freeze temporarily while it runs)</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item?.loading && (
|
||||
<>
|
||||
<div className='hr mt-4'/>
|
||||
<div className='hr mt-4' />
|
||||
<div className='flex items-center gap-2 mt-4'>
|
||||
<IconLoader2 className="animate-spin" size={16} strokeWidth={2} />
|
||||
<span>Loading...</span>
|
||||
|
||||
@@ -35,7 +35,7 @@ const CollectionToolBar = ({ collection }) => {
|
||||
const viewCollectionSettings = () => {
|
||||
dispatch(
|
||||
addTab({
|
||||
uid: uuid(),
|
||||
uid: collection.uid,
|
||||
collectionUid: collection.uid,
|
||||
type: 'collection-settings'
|
||||
})
|
||||
|
||||
@@ -2,15 +2,15 @@ import React from 'react';
|
||||
import CloseTabIcon from './CloseTabIcon';
|
||||
import { IconVariable, IconSettings, IconRun, IconFolder, IconShieldLock } from '@tabler/icons';
|
||||
|
||||
const SpecialTab = ({ handleCloseClick, type, tabName }) => {
|
||||
const SpecialTab = ({ handleCloseClick, type, tabName, handleDoubleClick }) => {
|
||||
const getTabInfo = (type, tabName) => {
|
||||
switch (type) {
|
||||
case 'collection-settings': {
|
||||
return (
|
||||
<>
|
||||
<div onDoubleClick={handleDoubleClick} className="flex items-center flex-nowrap overflow-hidden">
|
||||
<IconSettings size={18} strokeWidth={1.5} className="text-yellow-600" />
|
||||
<span className="ml-1 leading-6">Collection</span>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case 'collection-overview': {
|
||||
@@ -31,7 +31,7 @@ const SpecialTab = ({ handleCloseClick, type, tabName }) => {
|
||||
}
|
||||
case 'folder-settings': {
|
||||
return (
|
||||
<div className="flex items-center flex-nowrap overflow-hidden">
|
||||
<div onDoubleClick={handleDoubleClick} className="flex items-center flex-nowrap overflow-hidden">
|
||||
<IconFolder size={18} strokeWidth={1.5} className="text-yellow-600 min-w-[18px]" />
|
||||
<span className="ml-1 leading-6 truncate">{tabName || 'Folder'}</span>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useRef, Fragment } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { closeTabs } from 'providers/ReduxStore/slices/tabs';
|
||||
import { closeTabs, makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
|
||||
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { deleteRequestDraft } from 'providers/ReduxStore/slices/collections';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
@@ -73,13 +73,13 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
|
||||
if (['collection-settings', 'collection-overview', 'folder-settings', 'variables', 'collection-runner', 'security-settings'].includes(tab.type)) {
|
||||
return (
|
||||
<StyledWrapper
|
||||
className="flex items-center justify-between tab-container px-1"
|
||||
className={`flex items-center justify-between tab-container px-1 ${tab.preview ? "italic" : ""}`}
|
||||
onMouseUp={handleMouseUp} // Add middle-click behavior here
|
||||
>
|
||||
{tab.type === 'folder-settings' ? (
|
||||
<SpecialTab handleCloseClick={handleCloseClick} type={tab.type} tabName={folder?.name} />
|
||||
<SpecialTab handleCloseClick={handleCloseClick} handleDoubleClick={() => dispatch(makeTabPermanent({ uid: tab.uid }))} type={tab.type} tabName={folder?.name} />
|
||||
) : (
|
||||
<SpecialTab handleCloseClick={handleCloseClick} type={tab.type} />
|
||||
<SpecialTab handleCloseClick={handleCloseClick} handleDoubleClick={() => dispatch(makeTabPermanent({ uid: tab.uid }))} type={tab.type} />
|
||||
)}
|
||||
</StyledWrapper>
|
||||
);
|
||||
@@ -144,8 +144,9 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="flex items-baseline tab-label pl-2"
|
||||
className={`flex items-baseline tab-label pl-2 ${tab.preview ? "italic" : ""}`}
|
||||
onContextMenu={handleRightClick}
|
||||
onDoubleClick={() => dispatch(makeTabPermanent({ uid: tab.uid }))}
|
||||
onMouseUp={(e) => {
|
||||
if (!item.draft) return handleMouseUp(e);
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
@@ -11,14 +11,14 @@ import ResponsePane from './ResponsePane';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { areItemsLoading } from 'utils/collections';
|
||||
|
||||
const getRelativePath = (fullPath, pathname) => {
|
||||
const getDisplayName = (fullPath, pathname, name) => {
|
||||
// convert to unix style path
|
||||
fullPath = slash(fullPath);
|
||||
pathname = slash(pathname);
|
||||
|
||||
let relativePath = path.relative(fullPath, pathname);
|
||||
const { dir, name } = path.parse(relativePath);
|
||||
return path.join(dir, name);
|
||||
const { dir } = path.parse(relativePath);
|
||||
return [dir, name].filter(i => i).join('/');
|
||||
};
|
||||
|
||||
export default function RunnerResults({ collection }) {
|
||||
@@ -58,7 +58,7 @@ export default function RunnerResults({ collection }) {
|
||||
type: info.type,
|
||||
filename: info.filename,
|
||||
pathname: info.pathname,
|
||||
relativePath: getRelativePath(collection.pathname, info.pathname)
|
||||
displayName: getDisplayName(collection.pathname, info.pathname, info.name)
|
||||
};
|
||||
if (newItem.status !== 'error' && newItem.status !== 'skipped') {
|
||||
if (newItem.testResults) {
|
||||
@@ -186,7 +186,7 @@ export default function RunnerResults({ collection }) {
|
||||
<span
|
||||
className={`mr-1 ml-2 ${item.status == 'error' || item.status == 'skipped' || item.testStatus == 'fail' ? 'danger' : ''}`}
|
||||
>
|
||||
{item.relativePath}
|
||||
{item.displayName}
|
||||
</span>
|
||||
{item.status !== 'error' && item.status !== 'skipped' && item.status !== 'completed' ? (
|
||||
<IconRefresh className="animate-spin ml-1" size={18} strokeWidth={1.5} />
|
||||
@@ -266,7 +266,7 @@ export default function RunnerResults({ collection }) {
|
||||
<div className="flex flex-1 w-[50%]">
|
||||
<div className="flex flex-col w-full overflow-auto">
|
||||
<div className="flex items-center px-3 mb-4 font-medium">
|
||||
<span className="mr-2">{selectedItem.relativePath}</span>
|
||||
<span className="mr-2">{selectedItem.displayName}</span>
|
||||
<span>
|
||||
{selectedItem.testStatus === 'pass' ? (
|
||||
<IconCircleCheck className="test-success" size={20} strokeWidth={1.5} />
|
||||
@@ -275,7 +275,6 @@ export default function RunnerResults({ collection }) {
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{/* <div className='px-3 mb-4 font-medium'>{selectedItem.relativePath}</div> */}
|
||||
<ResponsePane item={selectedItem} collection={collection} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
60
packages/bruno-app/src/components/ShareCollection/index.js
Normal file
60
packages/bruno-app/src/components/ShareCollection/index.js
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
@@ -5,8 +5,8 @@ import classnames from 'classnames';
|
||||
import { useDrag, useDrop } from 'react-dnd';
|
||||
import { IconChevronRight, IconDots } from '@tabler/icons';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { addTab, focusTab } from 'providers/ReduxStore/slices/tabs';
|
||||
import { moveItem, sendRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { addTab, focusTab, makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
|
||||
import { moveItem, showInFolder, sendRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { collectionFolderClicked } from 'providers/ReduxStore/slices/collections';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import NewRequest from 'components/Sidebar/NewRequest';
|
||||
@@ -23,13 +23,16 @@ import { hideHomePage } from 'providers/ReduxStore/slices/app';
|
||||
import toast from 'react-hot-toast';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import NetworkError from 'components/ResponsePane/NetworkError/index';
|
||||
import CollectionItemIcon from './CollectionItemIcon/index';
|
||||
import { findItemInCollection } from 'utils/collections';
|
||||
import CollectionItemIcon from './CollectionItemIcon';
|
||||
import { scrollToTheActiveTab } from 'utils/tabs';
|
||||
|
||||
const CollectionItem = ({ item, collection, searchText }) => {
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
|
||||
const isSidebarDragging = useSelector((state) => state.app.isDragging);
|
||||
const dispatch = useDispatch();
|
||||
const collectionItemRef = useRef(null);
|
||||
|
||||
const [renameItemModalOpen, setRenameItemModalOpen] = useState(false);
|
||||
const [cloneItemModalOpen, setCloneItemModalOpen] = useState(false);
|
||||
@@ -43,28 +46,31 @@ const CollectionItem = ({ item, collection, searchText }) => {
|
||||
const itemIsCollapsed = hasSearchText ? false : item.collapsed;
|
||||
|
||||
const [{ isDragging }, drag] = useDrag({
|
||||
type: `COLLECTION_ITEM_${collection.uid}`,
|
||||
type: `collection-item-${collection.uid}`,
|
||||
item: item,
|
||||
collect: (monitor) => ({
|
||||
isDragging: monitor.isDragging()
|
||||
})
|
||||
}),
|
||||
options: {
|
||||
dropEffect: "move"
|
||||
}
|
||||
});
|
||||
|
||||
const [{ isOver }, drop] = useDrop({
|
||||
accept: `COLLECTION_ITEM_${collection.uid}`,
|
||||
accept: `collection-item-${collection.uid}`,
|
||||
drop: (draggedItem) => {
|
||||
if (draggedItem.uid !== item.uid) {
|
||||
dispatch(moveItem(collection.uid, draggedItem.uid, item.uid));
|
||||
}
|
||||
dispatch(moveItem(collection.uid, draggedItem.uid, item.uid));
|
||||
},
|
||||
canDrop: (draggedItem) => {
|
||||
return draggedItem.uid !== item.uid;
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isOver: monitor.isOver()
|
||||
})
|
||||
isOver: monitor.isOver(),
|
||||
}),
|
||||
});
|
||||
|
||||
drag(drop(collectionItemRef));
|
||||
|
||||
const dropdownTippyRef = useRef();
|
||||
const MenuIcon = forwardRef((props, ref) => {
|
||||
return (
|
||||
@@ -83,13 +89,6 @@ const CollectionItem = ({ item, collection, searchText }) => {
|
||||
'item-hovered': isOver
|
||||
});
|
||||
|
||||
const scrollToTheActiveTab = () => {
|
||||
const activeTab = document.querySelector('.request-tab.active');
|
||||
if (activeTab) {
|
||||
activeTab.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleRun = async () => {
|
||||
dispatch(sendRequest(item, collection.uid)).catch((err) =>
|
||||
toast.custom((t) => <NetworkError onClose={() => toast.dismiss(t.id)} />, {
|
||||
@@ -99,10 +98,13 @@ const CollectionItem = ({ item, collection, searchText }) => {
|
||||
};
|
||||
|
||||
const handleClick = (event) => {
|
||||
if (event.detail != 1) return;
|
||||
//scroll to the active tab
|
||||
setTimeout(scrollToTheActiveTab, 50);
|
||||
|
||||
if (isItemARequest(item)) {
|
||||
|
||||
const isRequest = isItemARequest(item);
|
||||
|
||||
if (isRequest) {
|
||||
dispatch(hideHomePage());
|
||||
if (itemIsOpenedInTabs(item, tabs)) {
|
||||
dispatch(
|
||||
@@ -112,20 +114,21 @@ const CollectionItem = ({ item, collection, searchText }) => {
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(
|
||||
addTab({
|
||||
uid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
requestPaneTab: getDefaultRequestPaneTab(item)
|
||||
requestPaneTab: getDefaultRequestPaneTab(item),
|
||||
type: 'request',
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
dispatch(
|
||||
addTab({
|
||||
uid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
type: 'folder-settings'
|
||||
type: 'folder-settings',
|
||||
})
|
||||
);
|
||||
dispatch(
|
||||
@@ -134,9 +137,12 @@ const CollectionItem = ({ item, collection, searchText }) => {
|
||||
collectionUid: collection.uid
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFolderCollapse = () => {
|
||||
const handleFolderCollapse = (e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
dispatch(
|
||||
collectionFolderClicked({
|
||||
itemUid: item.uid,
|
||||
@@ -156,10 +162,6 @@ const CollectionItem = ({ item, collection, searchText }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDoubleClick = (event) => {
|
||||
setRenameItemModalOpen(true);
|
||||
};
|
||||
|
||||
let indents = range(item.depth);
|
||||
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
|
||||
const isFolder = isItemAFolder(item);
|
||||
@@ -180,6 +182,10 @@ const CollectionItem = ({ item, collection, searchText }) => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleDoubleClick = (event) => {
|
||||
dispatch(makeTabPermanent({ uid: item.uid }))
|
||||
};
|
||||
|
||||
// we need to sort request items by seq property
|
||||
const sortRequestItems = (items = []) => {
|
||||
return items.sort((a, b) => a.seq - b.seq);
|
||||
@@ -220,6 +226,13 @@ const CollectionItem = ({ item, collection, searchText }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleShowInFolder = () => {
|
||||
dispatch(showInFolder(item.pathname)).catch((error) => {
|
||||
console.error('Error opening the folder', error);
|
||||
toast.error('Error opening the folder');
|
||||
});
|
||||
};
|
||||
|
||||
const requestItems = sortRequestItems(filter(item.items, (i) => isItemARequest(i)));
|
||||
const folderItems = sortFolderItems(filter(item.items, (i) => isItemAFolder(i)));
|
||||
|
||||
@@ -246,7 +259,7 @@ const CollectionItem = ({ item, collection, searchText }) => {
|
||||
{generateCodeItemModalOpen && (
|
||||
<GenerateCodeItem collection={collection} item={item} onClose={() => setGenerateCodeItemModalOpen(false)} />
|
||||
)}
|
||||
<div className={itemRowClassName} ref={(node) => drag(drop(node))}>
|
||||
<div className={itemRowClassName} ref={collectionItemRef}>
|
||||
<div className="flex items-center h-full w-full">
|
||||
{indents && indents.length
|
||||
? indents.map((i) => {
|
||||
@@ -273,6 +286,9 @@ const CollectionItem = ({ item, collection, searchText }) => {
|
||||
style={{
|
||||
paddingLeft: 8
|
||||
}}
|
||||
onClick={handleClick}
|
||||
onContextMenu={handleRightClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
>
|
||||
<div style={{ width: 16, minWidth: 16 }}>
|
||||
{isFolder ? (
|
||||
@@ -288,9 +304,6 @@ const CollectionItem = ({ item, collection, searchText }) => {
|
||||
|
||||
<div
|
||||
className="ml-1 flex w-full h-full items-center overflow-hidden"
|
||||
onClick={handleClick}
|
||||
onContextMenu={handleRightClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
>
|
||||
<CollectionItemIcon item={item} />
|
||||
<span className="item-name" title={item.name}>
|
||||
@@ -371,6 +384,15 @@ const CollectionItem = ({ item, collection, searchText }) => {
|
||||
Generate Code
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={(e) => {
|
||||
dropdownTippyRef.current.hide();
|
||||
handleShowInFolder();
|
||||
}}
|
||||
>
|
||||
Show in Folder
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item delete-item"
|
||||
onClick={(e) => {
|
||||
|
||||
@@ -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'] {
|
||||
|
||||
@@ -2,35 +2,37 @@ import React, { useState, forwardRef, useRef, useEffect } from 'react';
|
||||
import classnames from 'classnames';
|
||||
import { uuid } from 'utils/common';
|
||||
import filter from 'lodash/filter';
|
||||
import { useDrop } from 'react-dnd';
|
||||
import { useDrop, useDrag } from 'react-dnd';
|
||||
import { IconChevronRight, IconDots, IconLoader2 } from '@tabler/icons';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import { collapseCollection } from 'providers/ReduxStore/slices/collections';
|
||||
import { mountCollection, moveItemToRootOfCollection } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { addTab } from 'providers/ReduxStore/slices/tabs';
|
||||
import { mountCollection, moveItemToRootOfCollection, moveCollectionAndPersist } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { addTab, makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
|
||||
import NewRequest from 'components/Sidebar/NewRequest';
|
||||
import NewFolder from 'components/Sidebar/NewFolder';
|
||||
import CollectionItem from './CollectionItem';
|
||||
import RemoveCollection from './RemoveCollection';
|
||||
import ExportCollection from './ExportCollection';
|
||||
import { doesCollectionHaveItemsMatchingSearchText } from 'utils/collections/search';
|
||||
import { isItemAFolder, isItemARequest } from 'utils/collections';
|
||||
|
||||
import RenameCollection from './RenameCollection';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import CloneCollection from './CloneCollection';
|
||||
import { areItemsLoading } from 'utils/collections';
|
||||
import { areItemsLoading, findItemInCollection } from 'utils/collections';
|
||||
import { scrollToTheActiveTab } from 'utils/tabs';
|
||||
import ShareCollection from 'components/ShareCollection/index';
|
||||
|
||||
const Collection = ({ collection, searchText }) => {
|
||||
const [showNewFolderModal, setShowNewFolderModal] = useState(false);
|
||||
const [showNewRequestModal, setShowNewRequestModal] = useState(false);
|
||||
const [showRenameCollectionModal, setShowRenameCollectionModal] = useState(false);
|
||||
const [showCloneCollectionModalOpen, setShowCloneCollectionModalOpen] = useState(false);
|
||||
const [showExportCollectionModal, setShowExportCollectionModal] = useState(false);
|
||||
const [showShareCollectionModal, setShowShareCollectionModal] = useState(false);
|
||||
const [showRemoveCollectionModal, setShowRemoveCollectionModal] = useState(false);
|
||||
const dispatch = useDispatch();
|
||||
const isLoading = areItemsLoading(collection);
|
||||
const collectionRef = useRef(null);
|
||||
|
||||
const menuDropdownTippyRef = useRef();
|
||||
const onMenuDropdownCreate = (ref) => (menuDropdownTippyRef.current = ref);
|
||||
@@ -52,6 +54,16 @@ const Collection = ({ collection, searchText }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const ensureCollectionIsMounted = () => {
|
||||
if (collection.mountStatus === 'unmounted') {
|
||||
dispatch(mountCollection({
|
||||
collectionUid: collection.uid,
|
||||
collectionPathname: collection.pathname,
|
||||
brunoConfig: collection.brunoConfig
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
const hasSearchText = searchText && searchText?.trim()?.length;
|
||||
const collectionIsCollapsed = hasSearchText ? false : collection.collapsed;
|
||||
|
||||
@@ -60,30 +72,37 @@ const Collection = ({ collection, searchText }) => {
|
||||
});
|
||||
|
||||
const handleClick = (event) => {
|
||||
if (event.detail != 1) return;
|
||||
// Check if the click came from the chevron icon
|
||||
const isChevronClick = event.target.closest('svg')?.classList.contains('chevron-icon');
|
||||
|
||||
if (collection.mountStatus === 'unmounted') {
|
||||
dispatch(mountCollection({
|
||||
collectionUid: collection.uid,
|
||||
collectionPathname: collection.pathname,
|
||||
brunoConfig: collection.brunoConfig
|
||||
}));
|
||||
}
|
||||
dispatch(collapseCollection(collection.uid));
|
||||
setTimeout(scrollToTheActiveTab, 50);
|
||||
|
||||
// Only open collection settings if not clicking the chevron
|
||||
ensureCollectionIsMounted();
|
||||
|
||||
dispatch(collapseCollection(collection.uid));
|
||||
|
||||
if(!isChevronClick) {
|
||||
dispatch(
|
||||
addTab({
|
||||
uid: uuid(),
|
||||
uid: collection.uid,
|
||||
collectionUid: collection.uid,
|
||||
type: 'collection-settings'
|
||||
type: 'collection-settings',
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDoubleClick = (event) => {
|
||||
dispatch(makeTabPermanent({ uid: collection.uid }))
|
||||
};
|
||||
|
||||
const handleCollectionCollapse = (e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
ensureCollectionIsMounted();
|
||||
dispatch(collapseCollection(collection.uid));
|
||||
}
|
||||
|
||||
const handleRightClick = (event) => {
|
||||
const _menuDropdown = menuDropdownTippyRef.current;
|
||||
if (_menuDropdown) {
|
||||
@@ -98,33 +117,58 @@ const Collection = ({ collection, searchText }) => {
|
||||
const viewCollectionSettings = () => {
|
||||
dispatch(
|
||||
addTab({
|
||||
uid: uuid(),
|
||||
uid: collection.uid,
|
||||
collectionUid: collection.uid,
|
||||
type: 'collection-settings'
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const isCollectionItem = (itemType) => {
|
||||
return itemType.startsWith('collection-item');
|
||||
};
|
||||
|
||||
const [{ isDragging }, drag] = useDrag({
|
||||
type: "collection",
|
||||
item: collection,
|
||||
collect: (monitor) => ({
|
||||
isDragging: monitor.isDragging(),
|
||||
}),
|
||||
options: {
|
||||
dropEffect: "move"
|
||||
}
|
||||
});
|
||||
|
||||
const [{ isOver }, drop] = useDrop({
|
||||
accept: `COLLECTION_ITEM_${collection.uid}`,
|
||||
drop: (draggedItem) => {
|
||||
dispatch(moveItemToRootOfCollection(collection.uid, draggedItem.uid));
|
||||
accept: ["collection", `collection-item-${collection.uid}`],
|
||||
drop: (draggedItem, monitor) => {
|
||||
const itemType = monitor.getItemType();
|
||||
if (isCollectionItem(itemType)) {
|
||||
dispatch(moveItemToRootOfCollection(collection.uid, draggedItem.uid))
|
||||
} else {
|
||||
dispatch(moveCollectionAndPersist({draggedItem, targetItem: collection}));
|
||||
}
|
||||
},
|
||||
canDrop: (draggedItem) => {
|
||||
// todo need to make sure that draggedItem belongs to the collection
|
||||
return true;
|
||||
return draggedItem.uid !== collection.uid;
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isOver: monitor.isOver()
|
||||
})
|
||||
isOver: monitor.isOver(),
|
||||
}),
|
||||
});
|
||||
|
||||
drag(drop(collectionRef));
|
||||
|
||||
if (searchText && searchText.length) {
|
||||
if (!doesCollectionHaveItemsMatchingSearchText(collection, searchText)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const collectionRowClassName = classnames('flex py-1 collection-name items-center', {
|
||||
'item-hovered': isOver
|
||||
});
|
||||
|
||||
// we need to sort request items by seq property
|
||||
const sortRequestItems = (items = []) => {
|
||||
return items.sort((a, b) => a.seq - b.seq);
|
||||
@@ -148,16 +192,19 @@ const Collection = ({ collection, searchText }) => {
|
||||
{showRemoveCollectionModal && (
|
||||
<RemoveCollection collection={collection} onClose={() => setShowRemoveCollectionModal(false)} />
|
||||
)}
|
||||
{showExportCollectionModal && (
|
||||
<ExportCollection collection={collection} onClose={() => setShowExportCollectionModal(false)} />
|
||||
{showShareCollectionModal && (
|
||||
<ShareCollection collection={collection} onClose={() => setShowShareCollectionModal(false)} />
|
||||
)}
|
||||
{showCloneCollectionModalOpen && (
|
||||
<CloneCollection collection={collection} onClose={() => setShowCloneCollectionModalOpen(false)} />
|
||||
)}
|
||||
<div className="flex py-1 collection-name items-center" ref={drop}>
|
||||
<div className={collectionRowClassName}
|
||||
ref={collectionRef}
|
||||
>
|
||||
<div
|
||||
className="flex flex-grow items-center overflow-hidden"
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onContextMenu={handleRightClick}
|
||||
>
|
||||
<IconChevronRight
|
||||
@@ -165,6 +212,7 @@ const Collection = ({ collection, searchText }) => {
|
||||
strokeWidth={2}
|
||||
className={`chevron-icon ${iconClassName}`}
|
||||
style={{ width: 16, minWidth: 16, color: 'rgb(160 160 160)' }}
|
||||
onClick={handleCollectionCollapse}
|
||||
/>
|
||||
<div className="ml-1" id="sidebar-collection-name">
|
||||
{collection.name}
|
||||
@@ -222,10 +270,10 @@ const Collection = ({ collection, searchText }) => {
|
||||
className="dropdown-item"
|
||||
onClick={(e) => {
|
||||
menuDropdownTippyRef.current.hide();
|
||||
setShowExportCollectionModal(true);
|
||||
setShowShareCollectionModal(true);
|
||||
}}
|
||||
>
|
||||
Export
|
||||
Share
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -5,6 +5,7 @@ import Preferences from 'components/Preferences';
|
||||
import Cookies from 'components/Cookies';
|
||||
import ToolHint from 'components/ToolHint';
|
||||
import GoldenEdition from './GoldenEdition';
|
||||
import { useApp } from 'providers/App';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
@@ -20,7 +21,7 @@ const Sidebar = () => {
|
||||
const leftSidebarWidth = useSelector((state) => state.app.leftSidebarWidth);
|
||||
const preferencesOpen = useSelector((state) => state.app.showPreferences);
|
||||
const [goldenEditionOpen, setGoldenEditionOpen] = useState(false);
|
||||
|
||||
const { version } = useApp();
|
||||
const [asideWidth, setAsideWidth] = useState(leftSidebarWidth);
|
||||
const [cookiesOpen, setCookiesOpen] = useState(false);
|
||||
|
||||
@@ -184,7 +185,7 @@ const Sidebar = () => {
|
||||
Star
|
||||
</GitHubButton> */}
|
||||
</div>
|
||||
<div className="flex flex-grow items-center justify-end text-xs mr-2">v1.36.1</div>
|
||||
<div className="flex flex-grow items-center justify-end text-xs mr-2">v{version}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
`;
|
||||
15
packages/bruno-app/src/components/ToggleSwitch/index.js
Normal file
15
packages/bruno-app/src/components/ToggleSwitch/index.js
Normal 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;
|
||||
@@ -34,7 +34,7 @@ const ToolHint = ({
|
||||
<StyledWrapper theme={appliedTheme}>
|
||||
<ReactToolHint
|
||||
anchorId={toolhintId}
|
||||
html={text}
|
||||
content={text}
|
||||
className="toolhint"
|
||||
offset={offset}
|
||||
place={place}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
transformRequestToSaveToFilesystem
|
||||
} from 'utils/collections';
|
||||
import { uuid, waitForNextTick } from 'utils/common';
|
||||
import { PATH_SEPARATOR, getDirectoryName } from 'utils/common/platform';
|
||||
import { PATH_SEPARATOR, getDirectoryName, isWindowsPath } from 'utils/common/platform';
|
||||
import { cancelNetworkRequest, sendNetworkRequest } from 'utils/network';
|
||||
import { callIpc } from 'utils/common/ipc';
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
selectEnvironment as _selectEnvironment,
|
||||
sortCollections as _sortCollections,
|
||||
updateCollectionMountStatus,
|
||||
moveCollection,
|
||||
requestCancelled,
|
||||
resetRunResults,
|
||||
responseReceived,
|
||||
@@ -493,7 +494,7 @@ export const cloneItem = (newName, itemUid, collectionUid) => (dispatch, getStat
|
||||
);
|
||||
if (!reqWithSameNameExists) {
|
||||
const dirname = getDirectoryName(item.pathname);
|
||||
const fullName = path.join(dirname, filename);
|
||||
const fullName = isWindowsPath(item.pathname) ? path.win32.join(dirname, filename) : path.join(dirname, filename);
|
||||
const { ipcRenderer } = window;
|
||||
const requestItems = filter(parentItem.items, (i) => i.type !== 'folder');
|
||||
itemToSave.seq = requestItems ? requestItems.length + 1 : 1;
|
||||
@@ -758,7 +759,8 @@ export const newHttpRequest = (params) => (dispatch, getState) => {
|
||||
xml: null,
|
||||
sparql: null,
|
||||
multipartForm: null,
|
||||
formUrlEncoded: null
|
||||
formUrlEncoded: null,
|
||||
file: null
|
||||
},
|
||||
auth: auth ?? {
|
||||
mode: 'none'
|
||||
@@ -1038,14 +1040,17 @@ export const browseDirectory = () => (dispatch, getState) => {
|
||||
};
|
||||
|
||||
export const browseFiles =
|
||||
(filters = []) =>
|
||||
(dispatch, getState) => {
|
||||
(filters, properties) =>
|
||||
(_dispatch, _getState) => {
|
||||
const { ipcRenderer } = window;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
ipcRenderer.invoke('renderer:browse-files', filters).then(resolve).catch(reject);
|
||||
ipcRenderer
|
||||
.invoke('renderer:browse-files', filters, properties)
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export const updateBrunoConfig = (brunoConfig, collectionUid) => (dispatch, getState) => {
|
||||
const state = getState();
|
||||
@@ -1147,6 +1152,22 @@ export const importCollection = (collection, collectionLocation) => (dispatch, g
|
||||
});
|
||||
};
|
||||
|
||||
export const moveCollectionAndPersist = ({ draggedItem, targetItem }) => (dispatch, getState) => {
|
||||
dispatch(moveCollection({ draggedItem, targetItem }));
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const { ipcRenderer } = window;
|
||||
const state = getState();
|
||||
|
||||
const collectionPaths = state.collections.collections.map((collection) => collection.pathname);
|
||||
|
||||
ipcRenderer
|
||||
.invoke('renderer:update-collection-paths', collectionPaths)
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
});
|
||||
};
|
||||
|
||||
export const saveCollectionSecurityConfig = (collectionUid, securityConfig) => (dispatch, getState) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const { ipcRenderer } = window;
|
||||
@@ -1219,3 +1240,10 @@ export const mountCollection = ({ collectionUid, collectionPathname, brunoConfig
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const showInFolder = (collectionPath) => () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const { ipcRenderer } = window;
|
||||
ipcRenderer.invoke('renderer:show-in-folder', collectionPath).then(resolve).catch(reject);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { uuid } from 'utils/common';
|
||||
import { find, map, forOwn, concat, filter, each, cloneDeep, get, set } from 'lodash';
|
||||
import { find, map, forOwn, concat, filter, each, cloneDeep, get, set, findIndex } from 'lodash';
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
import {
|
||||
addDepth,
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
import { parsePathParams, parseQueryParams, splitOnFirst, stringifyQueryParams } from 'utils/url';
|
||||
import { getDirectoryName, getSubdirectoriesFromRoot, PATH_SEPARATOR } from 'utils/common/platform';
|
||||
import toast from 'react-hot-toast';
|
||||
import mime from 'mime-types';
|
||||
import path from 'node:path';
|
||||
|
||||
const initialState = {
|
||||
collections: [],
|
||||
@@ -57,7 +59,9 @@ export const collectionsSlice = createSlice({
|
||||
updateCollectionMountStatus: (state, action) => {
|
||||
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
|
||||
if (collection) {
|
||||
collection.mountStatus = action.payload.mountStatus;
|
||||
if (action.payload.mountStatus) {
|
||||
collection.mountStatus = action.payload.mountStatus;
|
||||
}
|
||||
}
|
||||
},
|
||||
setCollectionSecurityConfig: (state, action) => {
|
||||
@@ -98,6 +102,12 @@ export const collectionsSlice = createSlice({
|
||||
break;
|
||||
}
|
||||
},
|
||||
moveCollection: (state, action) => {
|
||||
const { draggedItem, targetItem } = action.payload;
|
||||
state.collections = state.collections.filter((i) => i.uid !== draggedItem.uid); // Remove dragged item
|
||||
const targetItemIndex = state.collections.findIndex((i) => i.uid === targetItem.uid); // Find target item
|
||||
state.collections.splice(targetItemIndex, 0, draggedItem); // Insert dragged-item above target-item
|
||||
},
|
||||
updateLastAction: (state, action) => {
|
||||
const { collectionUid, lastAction } = action.payload;
|
||||
const collection = findCollectionByUid(state.collections, collectionUid);
|
||||
@@ -895,6 +905,76 @@ export const collectionsSlice = createSlice({
|
||||
}
|
||||
}
|
||||
},
|
||||
addFile: (state, action) => {
|
||||
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
|
||||
|
||||
if (collection) {
|
||||
const item = findItemInCollection(collection, action.payload.itemUid);
|
||||
|
||||
if (item && isItemARequest(item)) {
|
||||
if (!item.draft) {
|
||||
item.draft = cloneDeep(item);
|
||||
}
|
||||
item.draft.request.body.file = item.draft.request.body.file || [];
|
||||
|
||||
item.draft.request.body.file.push({
|
||||
uid: uuid(),
|
||||
filePath: '',
|
||||
contentType: '',
|
||||
selected: false
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
updateFile: (state, action) => {
|
||||
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
|
||||
|
||||
if (collection) {
|
||||
const item = findItemInCollection(collection, action.payload.itemUid);
|
||||
|
||||
if (item && isItemARequest(item)) {
|
||||
if (!item.draft) {
|
||||
item.draft = cloneDeep(item);
|
||||
}
|
||||
|
||||
const param = find(item.draft.request.body.file, (p) => p.uid === action.payload.param.uid);
|
||||
|
||||
if (param) {
|
||||
const contentType = mime.contentType(path.extname(action.payload.param.filePath));
|
||||
param.filePath = action.payload.param.filePath;
|
||||
param.contentType = action.payload.param.contentType || contentType || '';
|
||||
param.selected = action.payload.param.selected;
|
||||
|
||||
item.draft.request.body.file = item.draft.request.body.file.map((p) => {
|
||||
p.selected = p.uid === param.uid;
|
||||
return p;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
deleteFile: (state, action) => {
|
||||
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
|
||||
|
||||
if (collection) {
|
||||
const item = findItemInCollection(collection, action.payload.itemUid);
|
||||
|
||||
if (item && isItemARequest(item)) {
|
||||
if (!item.draft) {
|
||||
item.draft = cloneDeep(item);
|
||||
}
|
||||
|
||||
item.draft.request.body.file = filter(
|
||||
item.draft.request.body.file,
|
||||
(p) => p.uid !== action.payload.paramUid
|
||||
);
|
||||
|
||||
if (item.draft.request.body.file.length > 0) {
|
||||
item.draft.request.body.file[0].selected = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
updateRequestAuthMode: (state, action) => {
|
||||
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
|
||||
|
||||
@@ -951,6 +1031,10 @@ export const collectionsSlice = createSlice({
|
||||
item.draft.request.body.sparql = action.payload.content;
|
||||
break;
|
||||
}
|
||||
case 'file': {
|
||||
item.draft.request.body.file = action.payload.content;
|
||||
break;
|
||||
}
|
||||
case 'formUrlEncoded': {
|
||||
item.draft.request.body.formUrlEncoded = action.payload.content;
|
||||
break;
|
||||
@@ -1685,6 +1769,12 @@ export const collectionsSlice = createSlice({
|
||||
// we don't want to lose the draft in this case
|
||||
if (areItemsTheSameExceptSeqUpdate(item, file.data)) {
|
||||
item.seq = file.data.seq;
|
||||
if (item?.draft) {
|
||||
item.draft.seq = file.data.seq;
|
||||
}
|
||||
if (item?.draft && areItemsTheSameExceptSeqUpdate(item?.draft, file.data)) {
|
||||
item.draft = null;
|
||||
}
|
||||
} else {
|
||||
item.name = file.data.name;
|
||||
item.type = file.data.type;
|
||||
@@ -1763,12 +1853,22 @@ export const collectionsSlice = createSlice({
|
||||
}
|
||||
},
|
||||
runRequestEvent: (state, action) => {
|
||||
const { itemUid, collectionUid, type, requestUid } = action.payload;
|
||||
const { itemUid, collectionUid, type, requestUid, hasError } = action.payload;
|
||||
const collection = findCollectionByUid(state.collections, collectionUid);
|
||||
|
||||
if (collection) {
|
||||
const item = findItemInCollection(collection, itemUid);
|
||||
if (item) {
|
||||
if (type === 'pre-request-script-execution') {
|
||||
item.requestUid = requestUid;
|
||||
item.preRequestScriptErrorMessage = action.payload.errorMessage;
|
||||
}
|
||||
|
||||
if(type === 'post-response-script-execution') {
|
||||
item.requestUid = requestUid;
|
||||
item.postResponseScriptErrorMessage = action.payload.errorMessage;
|
||||
}
|
||||
|
||||
if (type === 'request-queued') {
|
||||
const { cancelTokenUid } = action.payload;
|
||||
item.requestUid = requestUid;
|
||||
@@ -1952,6 +2052,9 @@ export const {
|
||||
addMultipartFormParam,
|
||||
updateMultipartFormParam,
|
||||
deleteMultipartFormParam,
|
||||
addFile,
|
||||
updateFile,
|
||||
deleteFile,
|
||||
moveMultipartFormParam,
|
||||
updateRequestAuthMode,
|
||||
updateRequestBodyMode,
|
||||
@@ -2003,7 +2106,8 @@ export const {
|
||||
runFolderEvent,
|
||||
resetCollectionRunner,
|
||||
updateRequestDocs,
|
||||
updateFolderDocs
|
||||
updateFolderDocs,
|
||||
moveCollection
|
||||
} = collectionsSlice.actions;
|
||||
|
||||
export default collectionsSlice.reducer;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
import { findIndex } from 'lodash';
|
||||
import filter from 'lodash/filter';
|
||||
import find from 'lodash/find';
|
||||
import last from 'lodash/last';
|
||||
@@ -19,31 +20,59 @@ export const tabsSlice = createSlice({
|
||||
initialState,
|
||||
reducers: {
|
||||
addTab: (state, action) => {
|
||||
const alreadyExists = find(state.tabs, (tab) => tab.uid === action.payload.uid);
|
||||
if (alreadyExists) {
|
||||
const { uid, collectionUid, type, requestPaneTab, preview } = action.payload;
|
||||
const nonReplaceableTabTypes = [
|
||||
"variables",
|
||||
"collection-runner",
|
||||
"security-settings",
|
||||
];
|
||||
|
||||
const existingTab = find(state.tabs, (tab) => tab.uid === uid);
|
||||
if (existingTab) {
|
||||
state.activeTabUid = existingTab.uid;
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
['variables', 'collection-settings', 'collection-overview', 'collection-runner', 'security-settings'].includes(action.payload.type)
|
||||
) {
|
||||
const tab = tabTypeAlreadyExists(state.tabs, action.payload.collectionUid, action.payload.type);
|
||||
if (tab) {
|
||||
state.activeTabUid = tab.uid;
|
||||
if (nonReplaceableTabTypes.includes(type)) {
|
||||
const existingTab = tabTypeAlreadyExists(state.tabs, collectionUid, type);
|
||||
if (existingTab) {
|
||||
state.activeTabUid = existingTab.uid;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const lastTab = state.tabs[state.tabs.length - 1];
|
||||
if (state.tabs.length > 0 && lastTab.preview) {
|
||||
state.tabs[state.tabs.length - 1] = {
|
||||
uid,
|
||||
collectionUid,
|
||||
requestPaneWidth: null,
|
||||
requestPaneTab: requestPaneTab || 'params',
|
||||
responsePaneTab: 'response',
|
||||
type: type || 'request',
|
||||
preview: preview !== undefined
|
||||
? preview
|
||||
: !nonReplaceableTabTypes.includes(type),
|
||||
...(uid ? { folderUid: uid } : {})
|
||||
}
|
||||
|
||||
state.activeTabUid = uid;
|
||||
return
|
||||
}
|
||||
|
||||
state.tabs.push({
|
||||
uid: action.payload.uid,
|
||||
collectionUid: action.payload.collectionUid,
|
||||
uid,
|
||||
collectionUid,
|
||||
requestPaneWidth: null,
|
||||
requestPaneTab: action.payload.requestPaneTab || 'params',
|
||||
requestPaneTab: requestPaneTab || 'params',
|
||||
responsePaneTab: 'response',
|
||||
type: action.payload.type || 'request',
|
||||
...(action.payload.uid ? { folderUid: action.payload.uid } : {})
|
||||
type: type || 'request',
|
||||
...(uid ? { folderUid: uid } : {}),
|
||||
preview: preview !== undefined
|
||||
? preview
|
||||
: !nonReplaceableTabTypes.includes(type)
|
||||
});
|
||||
state.activeTabUid = action.payload.uid;
|
||||
state.activeTabUid = uid;
|
||||
},
|
||||
focusTab: (state, action) => {
|
||||
state.activeTabUid = action.payload.uid;
|
||||
@@ -124,6 +153,15 @@ export const tabsSlice = createSlice({
|
||||
const collectionUid = action.payload.collectionUid;
|
||||
state.tabs = filter(state.tabs, (t) => t.collectionUid !== collectionUid);
|
||||
state.activeTabUid = null;
|
||||
},
|
||||
makeTabPermanent: (state, action) => {
|
||||
const { uid } = action.payload;
|
||||
const tab = find(state.tabs, (t) => t.uid === uid);
|
||||
if (tab) {
|
||||
tab.preview = false;
|
||||
} else{
|
||||
console.error("Tab not found!")
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -136,7 +174,8 @@ export const {
|
||||
updateRequestPaneTab,
|
||||
updateResponsePaneTab,
|
||||
closeTabs,
|
||||
closeAllCollectionTabs
|
||||
closeAllCollectionTabs,
|
||||
makeTabPermanent
|
||||
} = tabsSlice.actions;
|
||||
|
||||
export default tabsSlice.reducer;
|
||||
export default tabsSlice.reducer;
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,13 +1,4 @@
|
||||
import get from 'lodash/get';
|
||||
import each from 'lodash/each';
|
||||
import find from 'lodash/find';
|
||||
import findIndex from 'lodash/findIndex';
|
||||
import isString from 'lodash/isString';
|
||||
import map from 'lodash/map';
|
||||
import filter from 'lodash/filter';
|
||||
import sortBy from 'lodash/sortBy';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import {cloneDeep, isEqual, sortBy, filter, map, isString, findIndex, find, each, get } from 'lodash';
|
||||
import { uuid } from 'utils/common';
|
||||
import path from 'path';
|
||||
import slash from 'utils/common/slash';
|
||||
@@ -146,6 +137,20 @@ export const areItemsLoading = (folder) => {
|
||||
}, false);
|
||||
}
|
||||
|
||||
export const getItemsLoadStats = (folder) => {
|
||||
let loadingCount = 0;
|
||||
let flattenedItems = flattenItems(folder.items);
|
||||
flattenedItems?.forEach(i => {
|
||||
if(i?.loading) {
|
||||
loadingCount += 1;
|
||||
}
|
||||
});
|
||||
return {
|
||||
loading: loadingCount,
|
||||
total: flattenedItems?.length
|
||||
};
|
||||
}
|
||||
|
||||
export const moveCollectionItem = (collection, draggedItem, targetItem) => {
|
||||
let draggedItemParent = findParentItemInCollection(collection, draggedItem.uid);
|
||||
|
||||
@@ -281,6 +286,17 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
|
||||
});
|
||||
};
|
||||
|
||||
const copyFileParams = (params = []) => {
|
||||
return map(params, (param) => {
|
||||
return {
|
||||
uid: param.uid,
|
||||
filePath: param.filePath,
|
||||
contentType: param.contentType,
|
||||
selected: param.selected
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const copyItems = (sourceItems, destItems) => {
|
||||
each(sourceItems, (si) => {
|
||||
if (!isItemAFolder(si) && !isItemARequest(si) && si.type !== 'js') {
|
||||
@@ -308,7 +324,8 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
|
||||
graphql: si.request.body.graphql,
|
||||
sparql: si.request.body.sparql,
|
||||
formUrlEncoded: copyFormUrlEncodedParams(si.request.body.formUrlEncoded),
|
||||
multipartForm: copyMultipartFormParams(si.request.body.multipartForm)
|
||||
multipartForm: copyMultipartFormParams(si.request.body.multipartForm),
|
||||
file: copyFileParams(si.request.body.file)
|
||||
},
|
||||
script: si.request.script,
|
||||
vars: si.request.vars,
|
||||
@@ -661,6 +678,10 @@ export const humanizeRequestBodyMode = (mode) => {
|
||||
label = 'SPARQL';
|
||||
break;
|
||||
}
|
||||
case 'file': {
|
||||
label = 'File / Binary';
|
||||
break;
|
||||
}
|
||||
case 'formUrlEncoded': {
|
||||
label = 'Form URL Encoded';
|
||||
break;
|
||||
@@ -761,6 +782,7 @@ export const refreshUidsInItem = (item) => {
|
||||
each(get(item, 'request.params'), (param) => (param.uid = uuid()));
|
||||
each(get(item, 'request.body.multipartForm'), (param) => (param.uid = uuid()));
|
||||
each(get(item, 'request.body.formUrlEncoded'), (param) => (param.uid = uuid()));
|
||||
each(get(item, 'request.body.file'), (param) => (param.uid = uuid()));
|
||||
|
||||
return item;
|
||||
};
|
||||
@@ -771,11 +793,13 @@ export const deleteUidsInItem = (item) => {
|
||||
const headers = get(item, 'request.headers', []);
|
||||
const bodyFormUrlEncoded = get(item, 'request.body.formUrlEncoded', []);
|
||||
const bodyMultipartForm = get(item, 'request.body.multipartForm', []);
|
||||
const file = get(item, 'request.body.file', []);
|
||||
|
||||
params.forEach((param) => delete param.uid);
|
||||
headers.forEach((header) => delete header.uid);
|
||||
bodyFormUrlEncoded.forEach((param) => delete param.uid);
|
||||
bodyMultipartForm.forEach((param) => delete param.uid);
|
||||
file.forEach((param) => delete param.uid);
|
||||
|
||||
return item;
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { customAlphabet } from 'nanoid';
|
||||
import xmlFormat from 'xml-formatter';
|
||||
import { format as jsoncFormat, applyEdits as jsoncApplyEdits } from 'jsonc-parser';
|
||||
|
||||
// a customized version of nanoid without using _ and -
|
||||
export const uuid = () => {
|
||||
@@ -26,6 +27,13 @@ export const waitForNextTick = () => {
|
||||
});
|
||||
};
|
||||
|
||||
export const prettifyJson = (doc) => {
|
||||
return jsoncApplyEdits(
|
||||
doc,
|
||||
jsoncFormat(doc, null, {insertSpaces: true, tabSize: 2})
|
||||
);
|
||||
}
|
||||
|
||||
export const safeParseJSON = (str) => {
|
||||
if (!str || !str.length || typeof str !== 'string') {
|
||||
return str;
|
||||
@@ -94,6 +102,8 @@ export const getContentType = (headers) => {
|
||||
if (contentType && contentType.length) {
|
||||
if (typeof contentType[0] == 'string' && /^[\w\-]+\/([\w\-]+\+)?json/.test(contentType[0])) {
|
||||
return 'application/ld+json';
|
||||
} else if (typeof contentType[0] === 'string' && /^image\/svg\+xml/i.test(contentType[0])) {
|
||||
return 'image/svg+xml';
|
||||
} else if (typeof contentType[0] == 'string' && /^[\w\-]+\/([\w\-]+\+)?xml/.test(contentType[0])) {
|
||||
return 'application/xml';
|
||||
}
|
||||
@@ -174,3 +184,9 @@ export const generateUidBasedOnHash = (str) => {
|
||||
};
|
||||
|
||||
export const stringifyIfNot = v => typeof v === 'string' ? v : String(v);
|
||||
|
||||
export const getEncoding = (headers) => {
|
||||
// Parse the charset from content type: https://stackoverflow.com/a/33192813
|
||||
const charsetMatch = /charset=([^()<>@,;:"/[\]?.=\s]*)/i.exec(headers['content-type'] || '');
|
||||
return charsetMatch?.[1];
|
||||
}
|
||||
|
||||
@@ -24,11 +24,25 @@ export const getSubdirectoriesFromRoot = (rootPath, pathname) => {
|
||||
return relativePath ? relativePath.split(path.sep) : [];
|
||||
};
|
||||
|
||||
export const getDirectoryName = (pathname) => {
|
||||
// convert to unix style path
|
||||
pathname = slash(pathname);
|
||||
|
||||
return path.dirname(pathname);
|
||||
export const isWindowsPath = (pathname) => {
|
||||
|
||||
if (!isWindowsOS()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for Windows drive letter format (e.g., "C:\")
|
||||
const hasDriveLetter = /^[a-zA-Z]:\\/.test(pathname);
|
||||
|
||||
// Check for UNC path format (e.g., "\\server\share") a.k.a. network path || WSL path
|
||||
const isUNCPath = pathname.startsWith('\\\\');
|
||||
|
||||
return hasDriveLetter || isUNCPath;
|
||||
};
|
||||
|
||||
|
||||
export const getDirectoryName = (pathname) => {
|
||||
return isWindowsPath(pathname) ? path.win32.dirname(pathname) : path.dirname(pathname);
|
||||
};
|
||||
|
||||
export const isWindowsOS = () => {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import parseCurlCommand from './parse-curl';
|
||||
import * as querystring from 'query-string';
|
||||
import * as jsesc from 'jsesc';
|
||||
import * as path from 'path';
|
||||
|
||||
function getContentType(headers = {}) {
|
||||
const contentType = Object.keys(headers).find((key) => key.toLowerCase() === 'content-type');
|
||||
@@ -99,9 +100,30 @@ function getMultipleDataString(request, parsedQueryString) {
|
||||
function getFilesString(request) {
|
||||
const data = {};
|
||||
|
||||
data.files = {};
|
||||
data.data = {};
|
||||
|
||||
if (request.isDataBinary) {
|
||||
let filePath = '';
|
||||
|
||||
if (request.data.startsWith('@')) {
|
||||
filePath = request.data.slice(1);
|
||||
} else {
|
||||
filePath = request.data;
|
||||
}
|
||||
|
||||
data.data = [
|
||||
{
|
||||
filePath: repr(filePath),
|
||||
contentType: request.headers['Content-Type'],
|
||||
selected: true,
|
||||
}
|
||||
];
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
data.files = {};
|
||||
|
||||
for (const multipartKey in request.multipartUploads) {
|
||||
const multipartValue = request.multipartUploads[multipartKey];
|
||||
if (multipartValue.startsWith('@')) {
|
||||
@@ -140,6 +162,7 @@ const curlToJson = (curlCommand) => {
|
||||
requestJson.url = request.urlWithoutQuery;
|
||||
requestJson.raw_url = request.url;
|
||||
requestJson.method = request.method;
|
||||
requestJson.isDataBinary = request.isDataBinary;
|
||||
|
||||
if (request.cookies) {
|
||||
const cookies = {};
|
||||
@@ -161,12 +184,10 @@ const curlToJson = (curlCommand) => {
|
||||
|
||||
if (request.query) {
|
||||
requestJson.queries = getQueries(request);
|
||||
}
|
||||
|
||||
if (typeof request.data === 'string' || typeof request.data === 'number') {
|
||||
Object.assign(requestJson, getDataString(request));
|
||||
} else if (request.multipartUploads) {
|
||||
} else if (request.multipartUploads || request.isDataBinary) {
|
||||
Object.assign(requestJson, getFilesString(request));
|
||||
} else if (typeof request.data === 'string' || typeof request.data === 'number') {
|
||||
Object.assign(requestJson, getDataString(request));
|
||||
}
|
||||
|
||||
if (request.insecure) {
|
||||
|
||||
@@ -86,4 +86,38 @@ describe('curlToJson', () => {
|
||||
method: 'get'
|
||||
});
|
||||
});
|
||||
|
||||
it('should return a parse a curl with a post body with binary file type', () => {
|
||||
const curlCommand = `curl 'https://www.usebruno.com'
|
||||
-H 'Accept: application/json, text/plain, */*'
|
||||
-H 'Accept-Language: en-US,en;q=0.9,hi;q=0.8'
|
||||
-H 'Content-Type: application/json;charset=utf-8'
|
||||
-H 'Origin: https://www.usebruno.com'
|
||||
-H 'Referer: https://www.usebruno.com/'
|
||||
--data-binary '@/path/to/file'
|
||||
`;
|
||||
|
||||
const result = curlToJson(curlCommand);
|
||||
|
||||
expect(result).toEqual({
|
||||
url: 'https://www.usebruno.com',
|
||||
raw_url: 'https://www.usebruno.com',
|
||||
method: 'post',
|
||||
headers: {
|
||||
Accept: 'application/json, text/plain, */*',
|
||||
'Accept-Language': 'en-US,en;q=0.9,hi;q=0.8',
|
||||
'Content-Type': 'application/json;charset=utf-8',
|
||||
Origin: 'https://www.usebruno.com',
|
||||
Referer: 'https://www.usebruno.com/'
|
||||
},
|
||||
isDataBinary: true,
|
||||
data: [
|
||||
{
|
||||
filePath: '/path/to/file',
|
||||
contentType: 'application/json;charset=utf-8',
|
||||
selected: true
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -50,14 +50,18 @@ export const getRequestFromCurlCommand = (curlCommand, requestType = 'http-reque
|
||||
sparql: null,
|
||||
multipartForm: null,
|
||||
formUrlEncoded: null,
|
||||
graphql: null
|
||||
graphql: null,
|
||||
file: null
|
||||
};
|
||||
|
||||
if (parsedBody && contentType && typeof contentType === 'string') {
|
||||
if (requestType === 'graphql-request' && (contentType.includes('application/json') || contentType.includes('application/graphql'))) {
|
||||
body.mode = 'graphql';
|
||||
body.graphql = parseGraphQL(parsedBody);
|
||||
} else if (contentType.includes('application/json')) {
|
||||
} else if (requestType === 'http-request' && request.isDataBinary) {
|
||||
body.mode = 'file';
|
||||
body.file = parsedBody;
|
||||
}else if (contentType.includes('application/json')) {
|
||||
body.mode = 'json';
|
||||
body.json = convertToCodeMirrorJson(parsedBody);
|
||||
} else if (contentType.includes('xml')) {
|
||||
|
||||
@@ -35,6 +35,7 @@ export const updateUidsInCollection = (_collection) => {
|
||||
each(get(item, 'request.assertions'), (a) => (a.uid = uuid()));
|
||||
each(get(item, 'request.body.multipartForm'), (param) => (param.uid = uuid()));
|
||||
each(get(item, 'request.body.formUrlEncoded'), (param) => (param.uid = uuid()));
|
||||
each(get(item, 'request.body.file'), (param) => (param.uid = uuid()));
|
||||
|
||||
if (item.items && item.items.length) {
|
||||
updateItemUids(item.items);
|
||||
|
||||
@@ -229,26 +229,27 @@ const transformOpenapiRequestItem = (request) => {
|
||||
return brunoRequestItem;
|
||||
};
|
||||
|
||||
const resolveRefs = (spec, components = spec?.components, visitedItems = new Set()) => {
|
||||
const resolveRefs = (spec, components = spec?.components, cache = new Map()) => {
|
||||
if (!spec || typeof spec !== 'object') {
|
||||
return spec;
|
||||
}
|
||||
|
||||
if (cache.has(spec)) {
|
||||
return cache.get(spec);
|
||||
}
|
||||
|
||||
if (Array.isArray(spec)) {
|
||||
return spec.map((item) => resolveRefs(item, components, visitedItems));
|
||||
return spec.map(item => resolveRefs(item, components, cache));
|
||||
}
|
||||
|
||||
if ('$ref' in spec) {
|
||||
const refPath = spec.$ref;
|
||||
|
||||
if (visitedItems.has(refPath)) {
|
||||
return spec;
|
||||
} else {
|
||||
visitedItems.add(refPath);
|
||||
if (cache.has(refPath)) {
|
||||
return cache.get(refPath);
|
||||
}
|
||||
|
||||
if (refPath.startsWith('#/components/')) {
|
||||
// Local reference within components
|
||||
const refKeys = refPath.replace('#/components/', '').split('/');
|
||||
let ref = components;
|
||||
|
||||
@@ -256,25 +257,26 @@ const resolveRefs = (spec, components = spec?.components, visitedItems = new Set
|
||||
if (ref && ref[key]) {
|
||||
ref = ref[key];
|
||||
} else {
|
||||
// Handle invalid references gracefully?
|
||||
return spec;
|
||||
}
|
||||
}
|
||||
|
||||
return resolveRefs(ref, components, visitedItems);
|
||||
} else {
|
||||
// Handle external references (not implemented here)
|
||||
// You would need to fetch the external reference and resolve it.
|
||||
// Example: Fetch and resolve an external reference from a URL.
|
||||
cache.set(refPath, {});
|
||||
const resolved = resolveRefs(ref, components, cache);
|
||||
cache.set(refPath, resolved);
|
||||
return resolved;
|
||||
}
|
||||
return spec;
|
||||
}
|
||||
|
||||
// Recursively resolve references in nested objects
|
||||
for (const prop in spec) {
|
||||
spec[prop] = resolveRefs(spec[prop], components, new Set(visitedItems));
|
||||
const resolved = {};
|
||||
cache.set(spec, resolved);
|
||||
|
||||
for (const [key, value] of Object.entries(spec)) {
|
||||
resolved[key] = resolveRefs(value, components, cache);
|
||||
}
|
||||
|
||||
return spec;
|
||||
return resolved;
|
||||
};
|
||||
|
||||
const groupRequestsByTags = (requests) => {
|
||||
|
||||
@@ -181,6 +181,7 @@ const importPostmanV2CollectionItem = (brunoParent, item, parentAuth, options) =
|
||||
brunoParent.items = brunoParent.items || [];
|
||||
const folderMap = {};
|
||||
const requestMap = {};
|
||||
const requestMethods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS', 'TRACE']
|
||||
|
||||
each(item, (i) => {
|
||||
if (isItemAFolder(i)) {
|
||||
@@ -230,6 +231,11 @@ const importPostmanV2CollectionItem = (brunoParent, item, parentAuth, options) =
|
||||
|
||||
} else {
|
||||
if (i.request) {
|
||||
if(!requestMethods.includes(i?.request?.method.toUpperCase())){
|
||||
console.warn("Unexpected request.method")
|
||||
return;
|
||||
}
|
||||
|
||||
const baseRequestName = i.name;
|
||||
let requestName = baseRequestName;
|
||||
let count = 1;
|
||||
@@ -422,7 +428,7 @@ const importPostmanV2CollectionItem = (brunoParent, item, parentAuth, options) =
|
||||
brunoRequestItem.request.auth.mode = 'apikey';
|
||||
brunoRequestItem.request.auth.apikey = {
|
||||
key: authValues.key,
|
||||
value: authValues.value,
|
||||
value: authValues.value?.toString(), // Convert the value to a string as Postman's schema does not rigidly define the type of it,
|
||||
placement: "header" //By default we are placing the apikey values in headers!
|
||||
}
|
||||
}
|
||||
@@ -525,7 +531,9 @@ const parsePostmanCollection = (str, options) => {
|
||||
|
||||
let v2Schemas = [
|
||||
'https://schema.getpostman.com/json/collection/v2.0.0/collection.json',
|
||||
'https://schema.getpostman.com/json/collection/v2.1.0/collection.json'
|
||||
'https://schema.getpostman.com/json/collection/v2.1.0/collection.json',
|
||||
'https://schema.postman.com/json/collection/v2.0.0/collection.json',
|
||||
'https://schema.postman.com/json/collection/v2.1.0/collection.json'
|
||||
];
|
||||
|
||||
if (v2Schemas.includes(schema)) {
|
||||
|
||||
@@ -24,6 +24,10 @@ const replacements = {
|
||||
'postman\\.setEnvironmentVariable\\(': 'bru.setEnvVar(',
|
||||
'postman\\.getEnvironmentVariable\\(': 'bru.getEnvVar(',
|
||||
'postman\\.clearEnvironmentVariable\\(': 'bru.deleteEnvVar(',
|
||||
'pm\\.execution\\.skipRequest\\(\\)': 'bru.runner.skipRequest()',
|
||||
'pm\\.execution\\.skipRequest': 'bru.runner.skipRequest',
|
||||
'pm\\.execution\\.setNextRequest\\(null\\)': 'bru.runner.stopExecution()',
|
||||
'pm\\.execution\\.setNextRequest\\(\'null\'\\)': 'bru.runner.stopExecution()',
|
||||
};
|
||||
|
||||
const extendedReplacements = Object.keys(replacements).reduce((acc, key) => {
|
||||
@@ -50,7 +54,7 @@ export const postmanTranslation = (script, logCallback) => {
|
||||
}
|
||||
if (modifiedScript.includes('pm.') || modifiedScript.includes('postman.')) {
|
||||
modifiedScript = modifiedScript.replace(/^(.*(pm\.|postman\.).*)$/gm, '// $1');
|
||||
logCallback?.();
|
||||
//logCallback?.();
|
||||
}
|
||||
return modifiedScript;
|
||||
} catch (e) {
|
||||
|
||||
@@ -11,3 +11,10 @@ export const isItemAFolder = (item) => {
|
||||
export const itemIsOpenedInTabs = (item, tabs) => {
|
||||
return find(tabs, (t) => t.uid === item.uid);
|
||||
};
|
||||
|
||||
export const scrollToTheActiveTab = () => {
|
||||
const activeTab = document.querySelector('.request-tab.active');
|
||||
if (activeTab) {
|
||||
activeTab.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
};
|
||||
@@ -46,13 +46,13 @@
|
||||
"package.json"
|
||||
],
|
||||
"dependencies": {
|
||||
"@aws-sdk/credential-providers": "3.658.1",
|
||||
"@aws-sdk/credential-providers": "3.750.0",
|
||||
"@usebruno/common": "0.1.0",
|
||||
"@usebruno/js": "0.12.0",
|
||||
"@usebruno/lang": "0.12.0",
|
||||
"@usebruno/vm2": "^3.9.13",
|
||||
"aws4-axios": "^3.3.0",
|
||||
"axios": "1.7.5",
|
||||
"axios": "^1.8.3",
|
||||
"axios-ntlm": "^1.4.2",
|
||||
"chai": "^4.3.7",
|
||||
"chalk": "^3.0.0",
|
||||
@@ -66,7 +66,6 @@
|
||||
"qs": "^6.11.0",
|
||||
"socks-proxy-agent": "^8.0.2",
|
||||
"tough-cookie": "^4.1.3",
|
||||
"@usebruno/vm2": "^3.9.13",
|
||||
"xmlbuilder": "^15.1.1",
|
||||
"yargs": "^17.6.2"
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ const { rpad } = require('../utils/common');
|
||||
const { bruToJson, getOptions, collectionBruToJson } = require('../utils/bru');
|
||||
const { dotenvToJson } = require('@usebruno/lang');
|
||||
const constants = require('../constants');
|
||||
const { findItemInCollection } = require('../utils/collection');
|
||||
const command = 'run [filename]';
|
||||
const desc = 'Run a request';
|
||||
|
||||
@@ -18,6 +19,7 @@ const printRunSummary = (results) => {
|
||||
let totalRequests = 0;
|
||||
let passedRequests = 0;
|
||||
let failedRequests = 0;
|
||||
let skippedRequests = 0;
|
||||
let totalAssertions = 0;
|
||||
let passedAssertions = 0;
|
||||
let failedAssertions = 0;
|
||||
@@ -49,7 +51,10 @@ const printRunSummary = (results) => {
|
||||
failedAssertions += 1;
|
||||
}
|
||||
}
|
||||
if (!hasAnyTestsOrAssertions && result.error) {
|
||||
if (!hasAnyTestsOrAssertions && result.skipped) {
|
||||
skippedRequests += 1;
|
||||
}
|
||||
else if (!hasAnyTestsOrAssertions && result.error) {
|
||||
failedRequests += 1;
|
||||
} else {
|
||||
passedRequests += 1;
|
||||
@@ -62,6 +67,9 @@ const printRunSummary = (results) => {
|
||||
if (failedRequests > 0) {
|
||||
requestSummary += `, ${chalk.red(`${failedRequests} failed`)}`;
|
||||
}
|
||||
if (skippedRequests > 0) {
|
||||
requestSummary += `, ${chalk.magenta(`${skippedRequests} skipped`)}`;
|
||||
}
|
||||
requestSummary += `, ${totalRequests} total`;
|
||||
|
||||
let assertSummary = `${rpad('Tests:', maxLength)} ${chalk.green(`${passedTests} passed`)}`;
|
||||
@@ -84,6 +92,7 @@ const printRunSummary = (results) => {
|
||||
totalRequests,
|
||||
passedRequests,
|
||||
failedRequests,
|
||||
skippedRequests,
|
||||
totalAssertions,
|
||||
passedAssertions,
|
||||
failedAssertions,
|
||||
@@ -144,7 +153,7 @@ const createCollectionFromPath = (collectionPath) => {
|
||||
});
|
||||
}
|
||||
}
|
||||
return currentDirItems
|
||||
return currentDirItems;
|
||||
};
|
||||
collection.items = traverse(collectionPath);
|
||||
return collection;
|
||||
@@ -634,6 +643,34 @@ const handler = async function (argv) {
|
||||
}
|
||||
|
||||
const runtime = getJsSandboxRuntime(sandbox);
|
||||
|
||||
const runSingleRequestByPathname = async (relativeItemPathname) => {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
let itemPathname = path.join(collectionPath, relativeItemPathname);
|
||||
if (itemPathname && !itemPathname?.endsWith('.bru')) {
|
||||
itemPathname = `${itemPathname}.bru`;
|
||||
}
|
||||
const bruJson = cloneDeep(findItemInCollection(collection, itemPathname));
|
||||
if (bruJson) {
|
||||
const res = await runSingleRequest(
|
||||
itemPathname,
|
||||
bruJson,
|
||||
collectionPath,
|
||||
runtimeVariables,
|
||||
envVars,
|
||||
processEnvVars,
|
||||
brunoConfig,
|
||||
collectionRoot,
|
||||
runtime,
|
||||
collection,
|
||||
runSingleRequestByPathname
|
||||
);
|
||||
resolve(res?.response);
|
||||
}
|
||||
reject(`bru.runRequest: invalid request path - ${itemPathname}`);
|
||||
});
|
||||
}
|
||||
|
||||
let currentRequestIndex = 0;
|
||||
let nJumps = 0; // count the number of jumps to avoid infinite loops
|
||||
while (currentRequestIndex < bruJsons.length) {
|
||||
@@ -651,7 +688,8 @@ const handler = async function (argv) {
|
||||
brunoConfig,
|
||||
collectionRoot,
|
||||
runtime,
|
||||
collection
|
||||
collection,
|
||||
runSingleRequestByPathname
|
||||
);
|
||||
|
||||
results.push({
|
||||
@@ -701,6 +739,11 @@ const handler = async function (argv) {
|
||||
|
||||
// determine next request
|
||||
const nextRequestName = result?.nextRequestName;
|
||||
|
||||
if (result?.shouldStopRunnerExecution) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (nextRequestName !== undefined) {
|
||||
nJumps++;
|
||||
if (nJumps > 10000) {
|
||||
|
||||
@@ -62,7 +62,10 @@ const makeJUnitOutput = async (results, outputPath) => {
|
||||
suite.testcase.push(testcase);
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
if (result?.skipped) {
|
||||
suite['@skipped'] = 1;
|
||||
}
|
||||
else if (result.error) {
|
||||
suite['@errors'] = 1;
|
||||
suite['@tests'] = 1;
|
||||
suite.testcase = [
|
||||
|
||||
@@ -40,11 +40,13 @@ const runSingleRequest = async function (
|
||||
brunoConfig,
|
||||
collectionRoot,
|
||||
runtime,
|
||||
collection
|
||||
collection,
|
||||
runSingleRequestByPathname
|
||||
) {
|
||||
try {
|
||||
let request;
|
||||
let nextRequestName;
|
||||
let shouldStopRunnerExecution = false;
|
||||
let item = {
|
||||
pathname: path.join(collectionPath, filename),
|
||||
...bruJson
|
||||
@@ -68,11 +70,41 @@ const runSingleRequest = async function (
|
||||
collectionPath,
|
||||
onConsoleLog,
|
||||
processEnvVars,
|
||||
scriptingConfig
|
||||
scriptingConfig,
|
||||
runSingleRequestByPathname
|
||||
);
|
||||
if (result?.nextRequestName !== undefined) {
|
||||
nextRequestName = result.nextRequestName;
|
||||
}
|
||||
|
||||
if (result?.stopExecution) {
|
||||
shouldStopRunnerExecution = true;
|
||||
}
|
||||
|
||||
if (result?.skipRequest) {
|
||||
return {
|
||||
test: {
|
||||
filename: filename
|
||||
},
|
||||
request: {
|
||||
method: request.method,
|
||||
url: request.url,
|
||||
headers: request.headers,
|
||||
data: request.data
|
||||
},
|
||||
response: {
|
||||
status: 'skipped',
|
||||
statusText: 'request skipped via pre-request script',
|
||||
data: null,
|
||||
responseTime: 0
|
||||
},
|
||||
error: 'Request has been skipped from pre-request script',
|
||||
skipped: true,
|
||||
assertionResults: [],
|
||||
testResults: [],
|
||||
shouldStopRunnerExecution
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// interpolate variables inside request
|
||||
@@ -233,7 +265,30 @@ const runSingleRequest = async function (
|
||||
if (!options.disableCookies) {
|
||||
const cookieString = getCookieStringForUrl(request.url);
|
||||
if (cookieString && typeof cookieString === 'string' && cookieString.length) {
|
||||
request.headers['cookie'] = cookieString;
|
||||
const existingCookieHeaderName = Object.keys(request.headers).find(
|
||||
name => name.toLowerCase() === 'cookie'
|
||||
);
|
||||
const existingCookieString = existingCookieHeaderName ? request.headers[existingCookieHeaderName] : '';
|
||||
|
||||
// Helper function to parse cookies into an object
|
||||
const parseCookies = (str) => str.split(';').reduce((cookies, cookie) => {
|
||||
const [name, ...rest] = cookie.split('=');
|
||||
if (name && name.trim()) {
|
||||
cookies[name.trim()] = rest.join('=').trim();
|
||||
}
|
||||
return cookies;
|
||||
}, {});
|
||||
|
||||
const mergedCookies = {
|
||||
...parseCookies(existingCookieString),
|
||||
...parseCookies(cookieString),
|
||||
};
|
||||
|
||||
const combinedCookieString = Object.entries(mergedCookies)
|
||||
.map(([name, value]) => `${name}=${value}`)
|
||||
.join('; ');
|
||||
|
||||
request.headers[existingCookieHeaderName || 'Cookie'] = combinedCookieString;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,7 +378,8 @@ const runSingleRequest = async function (
|
||||
error: err?.message || err?.errors?.map(e => e?.message)?.at(0) || err?.code || 'Request Failed!',
|
||||
assertionResults: [],
|
||||
testResults: [],
|
||||
nextRequestName: nextRequestName
|
||||
nextRequestName: nextRequestName,
|
||||
shouldStopRunnerExecution
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -363,11 +419,16 @@ const runSingleRequest = async function (
|
||||
collectionPath,
|
||||
null,
|
||||
processEnvVars,
|
||||
scriptingConfig
|
||||
scriptingConfig,
|
||||
runSingleRequestByPathname
|
||||
);
|
||||
if (result?.nextRequestName !== undefined) {
|
||||
nextRequestName = result.nextRequestName;
|
||||
}
|
||||
|
||||
if (result?.stopExecution) {
|
||||
shouldStopRunnerExecution = true;
|
||||
}
|
||||
}
|
||||
|
||||
// run assertions
|
||||
@@ -408,13 +469,18 @@ const runSingleRequest = async function (
|
||||
collectionPath,
|
||||
null,
|
||||
processEnvVars,
|
||||
scriptingConfig
|
||||
scriptingConfig,
|
||||
runSingleRequestByPathname
|
||||
);
|
||||
testResults = get(result, 'results', []);
|
||||
|
||||
if (result?.nextRequestName !== undefined) {
|
||||
nextRequestName = result.nextRequestName;
|
||||
}
|
||||
|
||||
if (result?.stopExecution) {
|
||||
shouldStopRunnerExecution = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (testResults?.length) {
|
||||
@@ -447,7 +513,8 @@ const runSingleRequest = async function (
|
||||
error: null,
|
||||
assertionResults,
|
||||
testResults,
|
||||
nextRequestName: nextRequestName
|
||||
nextRequestName: nextRequestName,
|
||||
shouldStopRunnerExecution
|
||||
};
|
||||
} catch (err) {
|
||||
console.log(chalk.red(stripExtension(filename)) + chalk.dim(` (${err.message})`));
|
||||
|
||||
@@ -204,5 +204,6 @@ module.exports = {
|
||||
mergeHeaders,
|
||||
mergeVars,
|
||||
mergeScripts,
|
||||
findItemInCollection,
|
||||
getTreePathFromCollectionToItem
|
||||
}
|
||||
@@ -34,14 +34,10 @@ const parseDataFromResponse = (response, disableParsingResponseJson = false) =>
|
||||
// Filter out ZWNBSP character
|
||||
// https://gist.github.com/antic183/619f42b559b78028d1fe9e7ae8a1352d
|
||||
data = data.replace(/^\uFEFF/, '');
|
||||
|
||||
// If the response is a string and starts and ends with double quotes, it's a stringified JSON and should not be parsed
|
||||
if (!disableParsingResponseJson && !(typeof data === 'string' && data.startsWith('"') && data.endsWith('"'))) {
|
||||
if (!disableParsingResponseJson) {
|
||||
data = JSON.parse(data);
|
||||
}
|
||||
} catch {
|
||||
|
||||
}
|
||||
} catch { }
|
||||
|
||||
return { data, dataBuffer };
|
||||
};
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"scripts": {
|
||||
"clean": "rimraf dist",
|
||||
"dev": "electron .",
|
||||
"debug": "electron . --inspect=9229",
|
||||
"dist:mac": "electron-builder --mac --config electron-builder-config.js",
|
||||
"dist:win": "electron-builder --win --config electron-builder-config.js",
|
||||
"dist:linux": "electron-builder --linux AppImage --config electron-builder-config.js",
|
||||
@@ -24,7 +25,7 @@
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/credential-providers": "3.658.1",
|
||||
"@aws-sdk/credential-providers": "3.750.0",
|
||||
"@usebruno/common": "0.1.0",
|
||||
"@usebruno/js": "0.12.0",
|
||||
"@usebruno/lang": "0.12.0",
|
||||
@@ -33,7 +34,7 @@
|
||||
"@usebruno/vm2": "^3.9.13",
|
||||
"about-window": "^1.15.2",
|
||||
"aws4-axios": "^3.3.0",
|
||||
"axios": "1.7.5",
|
||||
"axios": "^1.8.3",
|
||||
"axios-ntlm": "^1.4.2",
|
||||
"chai": "^4.3.7",
|
||||
"chokidar": "^3.5.3",
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
const { sizeInMB } = require("../../utils/filesystem");
|
||||
const WorkerQueue = require("../../workers");
|
||||
const path = require("path");
|
||||
|
||||
const getSize = (data) => {
|
||||
return typeof data === 'string' ? Buffer.byteLength(data, 'utf8') : Buffer.byteLength(JSON.stringify(data), 'utf8');
|
||||
return sizeInMB(typeof data === 'string' ? Buffer.byteLength(data, 'utf8') : Buffer.byteLength(JSON.stringify(data), 'utf8'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -12,7 +13,13 @@ const getSize = (data) => {
|
||||
* This helps with parsing performance.
|
||||
*/
|
||||
const LANES = [{
|
||||
maxSize: 0.005
|
||||
},{
|
||||
maxSize: 0.1
|
||||
},{
|
||||
maxSize: 1
|
||||
},{
|
||||
maxSize: 10
|
||||
},{
|
||||
maxSize: 100
|
||||
}];
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
const { workerData, parentPort } = require('worker_threads');
|
||||
const { parentPort } = require('worker_threads');
|
||||
const {
|
||||
bruToJsonV2,
|
||||
} = require('@usebruno/lang');
|
||||
|
||||
try {
|
||||
const bru = workerData;
|
||||
const json = bruToJsonV2(bru);
|
||||
parentPort.postMessage(json);
|
||||
}
|
||||
catch(error) {
|
||||
console.error(error);
|
||||
parentPort.postMessage({ error: error?.message });
|
||||
}
|
||||
parentPort.on('message', (workerData) => {
|
||||
try {
|
||||
const bru = workerData;
|
||||
const json = bruToJsonV2(bru);
|
||||
parentPort.postMessage(json);
|
||||
}
|
||||
catch(error) {
|
||||
console.error(error);
|
||||
parentPort.postMessage({ error: error?.message });
|
||||
}
|
||||
});
|
||||
@@ -1,13 +1,16 @@
|
||||
const { workerData, parentPort } = require('worker_threads');
|
||||
const { parentPort } = require('worker_threads');
|
||||
const {
|
||||
jsonToBruV2,
|
||||
} = require('@usebruno/lang');
|
||||
try {
|
||||
const json = workerData;
|
||||
const bru = jsonToBruV2(json);
|
||||
parentPort.postMessage(bru);
|
||||
}
|
||||
catch(error) {
|
||||
console.error(error);
|
||||
parentPort.postMessage({ error: error?.message });
|
||||
}
|
||||
|
||||
parentPort.on('message', (workerData) => {
|
||||
try {
|
||||
const json = workerData;
|
||||
const bru = jsonToBruV2(json);
|
||||
parentPort.postMessage(bru);
|
||||
}
|
||||
catch(error) {
|
||||
console.error(error);
|
||||
parentPort.postMessage({ error: error?.message });
|
||||
}
|
||||
});
|
||||
@@ -30,9 +30,9 @@ const lastOpenedCollections = new LastOpenedCollections();
|
||||
// Reference: https://content-security-policy.com/
|
||||
const contentSecurityPolicy = [
|
||||
"default-src 'self'",
|
||||
"script-src * 'unsafe-inline' 'unsafe-eval'",
|
||||
"connect-src * 'unsafe-inline'",
|
||||
"connect-src 'self' https://*.posthog.com",
|
||||
"font-src 'self' https:",
|
||||
"frame-src data:",
|
||||
// this has been commented out to make oauth2 work
|
||||
// "form-action 'none'",
|
||||
// we make an exception and allow http for images so that
|
||||
|
||||
@@ -20,7 +20,6 @@ const {
|
||||
normalizeWslPath,
|
||||
normalizeAndResolvePath,
|
||||
safeToRename,
|
||||
sanitizeCollectionName,
|
||||
isWindowsOS,
|
||||
isValidFilename,
|
||||
hasSubDirectories,
|
||||
@@ -30,7 +29,7 @@ const {
|
||||
const { openCollectionDialog } = require('../app/collections');
|
||||
const { generateUidBasedOnHash, stringifyJson, safeParseJSON, safeStringifyJSON } = require('../utils/common');
|
||||
const { moveRequestUid, deleteRequestUid } = require('../cache/requestUids');
|
||||
const { deleteCookiesForDomain, getDomainsWithCookies } = require('../utils/cookies');
|
||||
const { deleteCookiesForDomain, getDomainsWithCookies, addCookieForDomain, modifyCookieForDomain, parseCookieString, createCookieString, deleteCookie } = require('../utils/cookies');
|
||||
const EnvironmentSecretsStore = require('../store/env-secrets');
|
||||
const CollectionSecurityStore = require('../store/collection-security');
|
||||
const UiStateSnapshotStore = require('../store/ui-state-snapshot');
|
||||
@@ -41,9 +40,9 @@ const collectionSecurityStore = new CollectionSecurityStore();
|
||||
const uiStateSnapshotStore = new UiStateSnapshotStore();
|
||||
|
||||
// size and file count limits to determine whether the bru files in the collection should be loaded asynchronously or not.
|
||||
const MAX_COLLECTION_SIZE_IN_MB = 5;
|
||||
const MAX_SINGLE_FILE_SIZE_IN_COLLECTION_IN_MB = 2;
|
||||
const MAX_COLLECTION_FILES_COUNT = 100;
|
||||
const MAX_COLLECTION_SIZE_IN_MB = 20;
|
||||
const MAX_SINGLE_FILE_SIZE_IN_COLLECTION_IN_MB = 5;
|
||||
const MAX_COLLECTION_FILES_COUNT = 2000;
|
||||
|
||||
const envHasSecrets = (environment = {}) => {
|
||||
const secrets = _.filter(environment.variables, (v) => v.secret);
|
||||
@@ -62,13 +61,11 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
|
||||
});
|
||||
|
||||
// browse directory for file
|
||||
ipcMain.handle('renderer:browse-files', async (event, pathname, request, filters) => {
|
||||
ipcMain.handle('renderer:browse-files', async (_, filters, properties) => {
|
||||
try {
|
||||
const filePaths = await browseFiles(mainWindow, filters);
|
||||
|
||||
return filePaths;
|
||||
return await browseFiles(mainWindow, filters, properties);
|
||||
} catch (error) {
|
||||
return Promise.reject(error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -78,7 +75,6 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
|
||||
async (event, collectionName, collectionFolderName, collectionLocation) => {
|
||||
try {
|
||||
collectionFolderName = sanitizeDirectoryName(collectionFolderName);
|
||||
collectionName = sanitizeCollectionName(collectionName);
|
||||
const dirPath = path.join(collectionLocation, collectionFolderName);
|
||||
if (fs.existsSync(dirPath)) {
|
||||
const files = fs.readdirSync(dirPath);
|
||||
@@ -120,7 +116,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
|
||||
ipcMain.handle(
|
||||
'renderer:clone-collection',
|
||||
async (event, collectionName, collectionFolderName, collectionLocation, previousPath) => {
|
||||
collectionFolderName = sanitizeCollectionName(collectionFolderName);
|
||||
collectionFolderName = sanitizeDirectoryName(collectionFolderName);
|
||||
const dirPath = path.join(collectionLocation, collectionFolderName);
|
||||
if (fs.existsSync(dirPath)) {
|
||||
throw new Error(`collection: ${dirPath} already exists`);
|
||||
@@ -141,7 +137,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
|
||||
// Change new name of collection
|
||||
let brunoConfig = JSON.parse(content);
|
||||
brunoConfig.name = collectionName;
|
||||
const cont = await stringifyJson(json);
|
||||
const cont = await stringifyJson(brunoConfig);
|
||||
|
||||
// write the bruno.json to new dir
|
||||
await writeFile(path.join(dirPath, 'bruno.json'), cont);
|
||||
@@ -170,7 +166,6 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
|
||||
// rename collection
|
||||
ipcMain.handle('renderer:rename-collection', async (event, newName, collectionPathname) => {
|
||||
try {
|
||||
newName = sanitizeCollectionName(newName);
|
||||
const brunoJsonFilePath = path.join(collectionPathname, 'bruno.json');
|
||||
const content = fs.readFileSync(brunoJsonFilePath, 'utf8');
|
||||
const json = JSON.parse(content);
|
||||
@@ -400,7 +395,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
|
||||
* And it is not WSL path (meaning its not linux running on windows using WSL)
|
||||
* And it has sub directories
|
||||
* Only then we need to use the temp dir approach to rename the folder
|
||||
*
|
||||
*
|
||||
* Windows OS would sometimes throw error when renaming a folder with sub directories
|
||||
* This is a alternative approach to avoid that error
|
||||
*/
|
||||
@@ -521,9 +516,13 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('renderer:update-collection-paths', async (_, collectionPaths) => {
|
||||
lastOpenedCollections.update(collectionPaths);
|
||||
})
|
||||
|
||||
ipcMain.handle('renderer:import-collection', async (event, collection, collectionLocation) => {
|
||||
try {
|
||||
let collectionName = sanitizeCollectionName(collection.name);
|
||||
let collectionName = sanitizeDirectoryName(collection.name);
|
||||
let collectionPath = path.join(collectionLocation, collectionName);
|
||||
|
||||
if (fs.existsSync(collectionPath)) {
|
||||
@@ -771,6 +770,54 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('renderer:delete-cookie', async (event, domain, path, cookieKey) => {
|
||||
try {
|
||||
await deleteCookie(domain, path, cookieKey);
|
||||
const domainsWithCookies = await getDomainsWithCookies();
|
||||
mainWindow.webContents.send('main:cookies-update', safeParseJSON(safeStringifyJSON(domainsWithCookies)));
|
||||
} catch (error) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
// add cookie
|
||||
ipcMain.handle('renderer:add-cookie', async (event, domain, cookie) => {
|
||||
try {
|
||||
await addCookieForDomain(domain, cookie);
|
||||
const domainsWithCookies = await getDomainsWithCookies();
|
||||
mainWindow.webContents.send('main:cookies-update', safeParseJSON(safeStringifyJSON(domainsWithCookies)));
|
||||
} catch (error) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
// modify cookie
|
||||
ipcMain.handle('renderer:modify-cookie', async (event, domain, oldCookie, cookie) => {
|
||||
try {
|
||||
await modifyCookieForDomain(domain, oldCookie, cookie);
|
||||
const domainsWithCookies = await getDomainsWithCookies();
|
||||
mainWindow.webContents.send('main:cookies-update', safeParseJSON(safeStringifyJSON(domainsWithCookies)));
|
||||
} catch (error) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('renderer:get-parsed-cookie', async (event, cookieStr) => {
|
||||
try {
|
||||
return parseCookieString(cookieStr);
|
||||
} catch (error) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('renderer:create-cookie-string', async (event, cookie) => {
|
||||
try {
|
||||
return createCookieString(cookie);
|
||||
} catch (error) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('renderer:save-collection-security-config', async (event, collectionPath, securityConfig) => {
|
||||
try {
|
||||
collectionSecurityStore.setSecurityConfigForCollection(collectionPath, {
|
||||
@@ -909,6 +956,18 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
|
||||
|
||||
watcher.addWatcher(mainWindow, collectionPathname, collectionUid, brunoConfig, false, shouldLoadCollectionAsync);
|
||||
});
|
||||
|
||||
ipcMain.handle('renderer:show-in-folder', async (event, filePath) => {
|
||||
try {
|
||||
if (!filePath) {
|
||||
throw new Error('File path is required');
|
||||
}
|
||||
shell.showItemInFolder(filePath);
|
||||
} catch (error) {
|
||||
console.error('Error in show-in-folder: ', error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const registerMainEventHandlers = (mainWindow, watcher, lastOpenedCollections) => {
|
||||
|
||||
@@ -28,7 +28,7 @@ const { makeAxiosInstance } = require('./axios-instance');
|
||||
const { addAwsV4Interceptor, resolveAwsV4Credentials } = require('./awsv4auth-helper');
|
||||
const { addDigestInterceptor } = require('./digestauth-helper');
|
||||
const { shouldUseProxy, PatchedHttpsProxyAgent } = require('../../utils/proxy-util');
|
||||
const { chooseFileToSave, writeBinaryFile, writeFile } = require('../../utils/filesystem');
|
||||
const { chooseFileToSave, writeFile } = require('../../utils/filesystem');
|
||||
const { getCookieStringForUrl, addCookieToJar, getDomainsWithCookies } = require('../../utils/cookies');
|
||||
const {
|
||||
resolveOAuth2AuthorizationCodeAccessToken,
|
||||
@@ -274,7 +274,6 @@ const configureRequest = async (
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
let axiosInstance = makeAxiosInstance();
|
||||
|
||||
if (request.ntlmConfig) {
|
||||
@@ -333,7 +332,30 @@ const configureRequest = async (
|
||||
if (preferencesUtil.shouldSendCookies()) {
|
||||
const cookieString = getCookieStringForUrl(request.url);
|
||||
if (cookieString && typeof cookieString === 'string' && cookieString.length) {
|
||||
request.headers['cookie'] = cookieString;
|
||||
const existingCookieHeaderName = Object.keys(request.headers).find(
|
||||
name => name.toLowerCase() === 'cookie'
|
||||
);
|
||||
const existingCookieString = existingCookieHeaderName ? request.headers[existingCookieHeaderName] : '';
|
||||
|
||||
// Helper function to parse cookies into an object
|
||||
const parseCookies = (str) => str.split(';').reduce((cookies, cookie) => {
|
||||
const [name, ...rest] = cookie.split('=');
|
||||
if (name && name.trim()) {
|
||||
cookies[name.trim()] = rest.join('=').trim();
|
||||
}
|
||||
return cookies;
|
||||
}, {});
|
||||
|
||||
const mergedCookies = {
|
||||
...parseCookies(existingCookieString),
|
||||
...parseCookies(cookieString),
|
||||
};
|
||||
|
||||
const combinedCookieString = Object.entries(mergedCookies)
|
||||
.map(([name, value]) => `${name}=${value}`)
|
||||
.join('; ');
|
||||
|
||||
request.headers[existingCookieHeaderName || 'Cookie'] = combinedCookieString;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -376,18 +398,15 @@ const parseDataFromResponse = (response, disableParsingResponseJson = false) =>
|
||||
// Filter out ZWNBSP character
|
||||
// https://gist.github.com/antic183/619f42b559b78028d1fe9e7ae8a1352d
|
||||
data = data.replace(/^\uFEFF/, '');
|
||||
|
||||
// If the response is a string and starts and ends with double quotes, it's a stringified JSON and should not be parsed
|
||||
if ( !disableParsingResponseJson && ! (typeof data === 'string' && data.startsWith("\"") && data.endsWith("\""))) {
|
||||
if (!disableParsingResponseJson) {
|
||||
data = JSON.parse(data);
|
||||
}
|
||||
} catch {
|
||||
console.log('Failed to parse response data as JSON');
|
||||
}
|
||||
} catch { }
|
||||
|
||||
return { data, dataBuffer };
|
||||
};
|
||||
|
||||
|
||||
const registerNetworkIpc = (mainWindow) => {
|
||||
const onConsoleLog = (type, args) => {
|
||||
console[type](...args);
|
||||
@@ -403,7 +422,7 @@ const registerNetworkIpc = (mainWindow) => {
|
||||
requestUid,
|
||||
envVars,
|
||||
collectionPath,
|
||||
collectionRoot,
|
||||
collection,
|
||||
collectionUid,
|
||||
runtimeVariables,
|
||||
processEnvVars,
|
||||
@@ -437,6 +456,8 @@ const registerNetworkIpc = (mainWindow) => {
|
||||
mainWindow.webContents.send('main:global-environment-variables-update', {
|
||||
globalEnvironmentVariables: scriptResult.globalEnvironmentVariables
|
||||
});
|
||||
|
||||
collection.globalEnvironmentVariables = scriptResult.globalEnvironmentVariables;
|
||||
}
|
||||
|
||||
// interpolate variables inside request
|
||||
@@ -470,7 +491,7 @@ const registerNetworkIpc = (mainWindow) => {
|
||||
requestUid,
|
||||
envVars,
|
||||
collectionPath,
|
||||
collectionRoot,
|
||||
collection,
|
||||
collectionUid,
|
||||
runtimeVariables,
|
||||
processEnvVars,
|
||||
@@ -507,6 +528,8 @@ const registerNetworkIpc = (mainWindow) => {
|
||||
if (result?.error) {
|
||||
mainWindow.webContents.send('main:display-error', result.error);
|
||||
}
|
||||
|
||||
collection.globalEnvironmentVariables = result.globalEnvironmentVariables;
|
||||
}
|
||||
|
||||
// run post-response script
|
||||
@@ -537,11 +560,13 @@ const registerNetworkIpc = (mainWindow) => {
|
||||
mainWindow.webContents.send('main:global-environment-variables-update', {
|
||||
globalEnvironmentVariables: scriptResult.globalEnvironmentVariables
|
||||
});
|
||||
|
||||
collection.globalEnvironmentVariables = scriptResult.globalEnvironmentVariables;
|
||||
}
|
||||
return scriptResult;
|
||||
};
|
||||
|
||||
const runRequest = async ({ item, collection, environment, runtimeVariables, runInBackground = false }) => {
|
||||
const runRequest = async ({ item, collection, envVars, processEnvVars, runtimeVariables, runInBackground = false }) => {
|
||||
const collectionUid = collection.uid;
|
||||
const collectionPath = collection.pathname;
|
||||
const cancelTokenUid = uuid();
|
||||
@@ -553,9 +578,9 @@ const registerNetworkIpc = (mainWindow) => {
|
||||
if (itemPathname && !itemPathname?.endsWith('.bru')) {
|
||||
itemPathname = `${itemPathname}.bru`;
|
||||
}
|
||||
const _item = findItemInCollectionByPathname(collection, itemPathname);
|
||||
const _item = cloneDeep(findItemInCollectionByPathname(collection, itemPathname));
|
||||
if(_item) {
|
||||
const res = await runRequest({ item: _item, collection, environment, runtimeVariables, runInBackground: true });
|
||||
const res = await runRequest({ item: _item, collection, envVars, processEnvVars, runtimeVariables, runInBackground: true });
|
||||
resolve(res);
|
||||
}
|
||||
reject(`bru.runRequest: invalid request path - ${itemPathname}`);
|
||||
@@ -570,33 +595,50 @@ const registerNetworkIpc = (mainWindow) => {
|
||||
cancelTokenUid
|
||||
});
|
||||
|
||||
const collectionRoot = get(collection, 'root', {});
|
||||
const request = prepareRequest(item, collection);
|
||||
const abortController = new AbortController();
|
||||
const request = await prepareRequest(item, collection, abortController);
|
||||
request.__bruno__executionMode = 'standalone';
|
||||
const envVars = getEnvVars(environment);
|
||||
const processEnvVars = getProcessEnvVars(collectionUid);
|
||||
const brunoConfig = getBrunoConfig(collectionUid);
|
||||
const scriptingConfig = get(brunoConfig, 'scripts', {});
|
||||
scriptingConfig.runtime = getJsSandboxRuntime(collection);
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
request.signal = controller.signal;
|
||||
saveCancelToken(cancelTokenUid, controller);
|
||||
request.signal = abortController.signal;
|
||||
saveCancelToken(cancelTokenUid, abortController);
|
||||
|
||||
await runPreRequest(
|
||||
request,
|
||||
requestUid,
|
||||
envVars,
|
||||
collectionPath,
|
||||
collectionRoot,
|
||||
collectionUid,
|
||||
runtimeVariables,
|
||||
processEnvVars,
|
||||
scriptingConfig,
|
||||
runRequestByItemPathname
|
||||
);
|
||||
|
||||
try {
|
||||
await runPreRequest(
|
||||
request,
|
||||
requestUid,
|
||||
envVars,
|
||||
collectionPath,
|
||||
collection,
|
||||
collectionUid,
|
||||
runtimeVariables,
|
||||
processEnvVars,
|
||||
scriptingConfig,
|
||||
runRequestByItemPathname
|
||||
);
|
||||
|
||||
!runInBackground && mainWindow.webContents.send('main:run-request-event', {
|
||||
type: 'pre-request-script-execution',
|
||||
requestUid,
|
||||
collectionUid,
|
||||
itemUid: item.uid,
|
||||
errorMessage: null,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
!runInBackground && mainWindow.webContents.send('main:run-request-event', {
|
||||
type: 'pre-request-script-execution',
|
||||
requestUid,
|
||||
collectionUid,
|
||||
itemUid: item.uid,
|
||||
errorMessage: error?.message || 'An error occurred in pre-request script',
|
||||
});
|
||||
return Promise.reject(error);
|
||||
}
|
||||
const axiosInstance = await configureRequest(
|
||||
collectionUid,
|
||||
request,
|
||||
@@ -612,7 +654,7 @@ const registerNetworkIpc = (mainWindow) => {
|
||||
url: request.url,
|
||||
method: request.method,
|
||||
headers: request.headers,
|
||||
data: safeParseJSON(safeStringifyJSON(request.data)),
|
||||
data: request.mode == 'file'? "<request body redacted>": safeParseJSON(safeStringifyJSON(request.data)) ,
|
||||
timestamp: Date.now()
|
||||
},
|
||||
collectionUid,
|
||||
@@ -668,19 +710,41 @@ const registerNetworkIpc = (mainWindow) => {
|
||||
|
||||
mainWindow.webContents.send('main:cookies-update', safeParseJSON(safeStringifyJSON(domainsWithCookies)));
|
||||
|
||||
await runPostResponse(
|
||||
request,
|
||||
response,
|
||||
requestUid,
|
||||
envVars,
|
||||
collectionPath,
|
||||
collectionRoot,
|
||||
collectionUid,
|
||||
runtimeVariables,
|
||||
processEnvVars,
|
||||
scriptingConfig,
|
||||
runRequestByItemPathname
|
||||
);
|
||||
try {
|
||||
await runPostResponse(
|
||||
request,
|
||||
response,
|
||||
requestUid,
|
||||
envVars,
|
||||
collectionPath,
|
||||
collection,
|
||||
collectionUid,
|
||||
runtimeVariables,
|
||||
processEnvVars,
|
||||
scriptingConfig,
|
||||
runRequestByItemPathname
|
||||
);
|
||||
!runInBackground && mainWindow.webContents.send('main:run-request-event', {
|
||||
type: 'post-response-script-execution',
|
||||
requestUid,
|
||||
collectionUid,
|
||||
errorMessage: null,
|
||||
itemUid: item.uid,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Post-response script error:', error);
|
||||
|
||||
// Format a more readable error message
|
||||
const errorMessage = error?.message || 'An error occurred in post-response script';
|
||||
|
||||
!runInBackground && mainWindow.webContents.send('main:run-request-event', {
|
||||
type: 'post-response-script-execution',
|
||||
requestUid,
|
||||
errorMessage,
|
||||
collectionUid,
|
||||
itemUid: item.uid,
|
||||
});
|
||||
}
|
||||
|
||||
// run assertions
|
||||
const assertions = get(request, 'assertions');
|
||||
@@ -758,7 +822,10 @@ const registerNetworkIpc = (mainWindow) => {
|
||||
|
||||
// handler for sending http request
|
||||
ipcMain.handle('send-http-request', async (event, item, collection, environment, runtimeVariables) => {
|
||||
return await runRequest({ item, collection, environment, runtimeVariables });
|
||||
const collectionUid = collection.uid;
|
||||
const envVars = getEnvVars(environment);
|
||||
const processEnvVars = getProcessEnvVars(collectionUid);
|
||||
return await runRequest({ item, collection, envVars, processEnvVars, runtimeVariables, runInBackground: false });
|
||||
});
|
||||
|
||||
ipcMain.handle('send-collection-oauth2-request', async (event, collection, environment, runtimeVariables) => {
|
||||
@@ -782,7 +849,7 @@ const registerNetworkIpc = (mainWindow) => {
|
||||
requestUid,
|
||||
envVars,
|
||||
collectionPath,
|
||||
collectionRoot,
|
||||
collection,
|
||||
collectionUid,
|
||||
runtimeVariables,
|
||||
processEnvVars,
|
||||
@@ -818,7 +885,7 @@ const registerNetworkIpc = (mainWindow) => {
|
||||
requestUid,
|
||||
envVars,
|
||||
collectionPath,
|
||||
collectionRoot,
|
||||
collection,
|
||||
collectionUid,
|
||||
runtimeVariables,
|
||||
processEnvVars,
|
||||
@@ -888,7 +955,7 @@ const registerNetworkIpc = (mainWindow) => {
|
||||
requestUid,
|
||||
envVars,
|
||||
collectionPath,
|
||||
collectionRoot,
|
||||
collection,
|
||||
collectionUid,
|
||||
runtimeVariables,
|
||||
processEnvVars,
|
||||
@@ -912,7 +979,7 @@ const registerNetworkIpc = (mainWindow) => {
|
||||
requestUid,
|
||||
envVars,
|
||||
collectionPath,
|
||||
collectionRoot,
|
||||
collection,
|
||||
collectionUid,
|
||||
runtimeVariables,
|
||||
processEnvVars,
|
||||
@@ -949,7 +1016,8 @@ const registerNetworkIpc = (mainWindow) => {
|
||||
const brunoConfig = getBrunoConfig(collectionUid);
|
||||
const scriptingConfig = get(brunoConfig, 'scripts', {});
|
||||
scriptingConfig.runtime = getJsSandboxRuntime(collection);
|
||||
const collectionRoot = get(collection, 'root', {});
|
||||
const envVars = getEnvVars(environment);
|
||||
const processEnvVars = getProcessEnvVars(collectionUid);
|
||||
let stopRunnerExecution = false;
|
||||
|
||||
const abortController = new AbortController();
|
||||
@@ -961,9 +1029,9 @@ const registerNetworkIpc = (mainWindow) => {
|
||||
if (itemPathname && !itemPathname?.endsWith('.bru')) {
|
||||
itemPathname = `${itemPathname}.bru`;
|
||||
}
|
||||
const _item = findItemInCollectionByPathname(collection, itemPathname);
|
||||
const _item = cloneDeep(findItemInCollectionByPathname(collection, itemPathname));
|
||||
if(_item) {
|
||||
const res = await runRequest({ item: _item, collection, environment, runtimeVariables, runInBackground: true });
|
||||
const res = await runRequest({ item: _item, collection, envVars, processEnvVars, runtimeVariables, runInBackground: true });
|
||||
resolve(res);
|
||||
}
|
||||
reject(`bru.runRequest: invalid request path - ${itemPathname}`);
|
||||
@@ -983,7 +1051,6 @@ const registerNetworkIpc = (mainWindow) => {
|
||||
});
|
||||
|
||||
try {
|
||||
const envVars = getEnvVars(environment);
|
||||
let folderRequests = [];
|
||||
|
||||
if (recursive) {
|
||||
@@ -1031,11 +1098,10 @@ const registerNetworkIpc = (mainWindow) => {
|
||||
...eventData
|
||||
});
|
||||
|
||||
const request = prepareRequest(item, collection);
|
||||
const request = await prepareRequest(item, collection, abortController);
|
||||
request.__bruno__executionMode = 'runner';
|
||||
|
||||
const requestUid = uuid();
|
||||
const processEnvVars = getProcessEnvVars(collectionUid);
|
||||
|
||||
try {
|
||||
const preRequestScriptResult = await runPreRequest(
|
||||
@@ -1043,7 +1109,7 @@ const registerNetworkIpc = (mainWindow) => {
|
||||
requestUid,
|
||||
envVars,
|
||||
collectionPath,
|
||||
collectionRoot,
|
||||
collection,
|
||||
collectionUid,
|
||||
runtimeVariables,
|
||||
processEnvVars,
|
||||
@@ -1181,7 +1247,7 @@ const registerNetworkIpc = (mainWindow) => {
|
||||
requestUid,
|
||||
envVars,
|
||||
collectionPath,
|
||||
collectionRoot,
|
||||
collection,
|
||||
collectionUid,
|
||||
runtimeVariables,
|
||||
processEnvVars,
|
||||
@@ -1367,7 +1433,7 @@ const registerNetworkIpc = (mainWindow) => {
|
||||
if (encoding === 'utf-8') {
|
||||
await writeFile(filePath, data);
|
||||
} else {
|
||||
await writeBinaryFile(filePath, data);
|
||||
await writeFile(filePath, data, true);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -65,7 +65,11 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc
|
||||
|
||||
const contentType = getContentType(request.headers);
|
||||
|
||||
if (contentType.includes('json')) {
|
||||
/*
|
||||
We explicitly avoid interpolating buffer values because the file content is read as a buffer object in raw body mode.
|
||||
Even if the selected file's content type is JSON, this prevents the buffer object from being interpolated.
|
||||
*/
|
||||
if (contentType.includes('json') && !Buffer.isBuffer(request.data)) {
|
||||
if (typeof request.data === 'string') {
|
||||
if (request.data.length) {
|
||||
request.data = _interpolate(request.data);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
const { get, each, filter } = require('lodash');
|
||||
const { get, each, filter, find } = require('lodash');
|
||||
const decomment = require('decomment');
|
||||
const crypto = require('node:crypto');
|
||||
const fs = require('node:fs/promises');
|
||||
const { getTreePathFromCollectionToItem, mergeHeaders, mergeScripts, mergeVars } = require('../../utils/collection');
|
||||
const { buildFormUrlEncodedPayload, createFormData } = require('../../utils/form-data');
|
||||
const path = require('node:path');
|
||||
|
||||
const setAuthHeaders = (axiosRequest, request, collectionRoot) => {
|
||||
|
||||
@@ -174,10 +176,10 @@ const setAuthHeaders = (axiosRequest, request, collectionRoot) => {
|
||||
return axiosRequest;
|
||||
};
|
||||
|
||||
const prepareRequest = (item, collection) => {
|
||||
const prepareRequest = async (item, collection = {}, abortController) => {
|
||||
const request = item.draft ? item.draft.request : item.request;
|
||||
const collectionRoot = get(collection, 'root', {});
|
||||
const collectionPath = collection.pathname;
|
||||
const collectionPath = collection?.pathname;
|
||||
const headers = {};
|
||||
let contentTypeDefined = false;
|
||||
let url = request.url;
|
||||
@@ -189,7 +191,7 @@ const prepareRequest = (item, collection) => {
|
||||
}
|
||||
});
|
||||
|
||||
const scriptFlow = collection.brunoConfig?.scripts?.flow ?? 'sandwich';
|
||||
const scriptFlow = collection?.brunoConfig?.scripts?.flow ?? 'sandwich';
|
||||
const requestTreePath = getTreePathFromCollectionToItem(collection, item);
|
||||
if (requestTreePath && requestTreePath.length > 0) {
|
||||
mergeHeaders(collection, request, requestTreePath);
|
||||
@@ -251,6 +253,31 @@ const prepareRequest = (item, collection) => {
|
||||
axiosRequest.data = request.body.sparql;
|
||||
}
|
||||
|
||||
if (request.body.mode === 'file') {
|
||||
if (!contentTypeDefined) {
|
||||
axiosRequest.headers['content-type'] = 'application/octet-stream'; // Default headers for binary file uploads
|
||||
}
|
||||
|
||||
const bodyFile = find(request.body.file, (param) => param.selected);
|
||||
if (bodyFile) {
|
||||
let { filePath, contentType } = bodyFile;
|
||||
|
||||
axiosRequest.headers['content-type'] = contentType;
|
||||
if (filePath) {
|
||||
if (!path.isAbsolute(filePath)) {
|
||||
filePath = path.join(collectionPath, filePath);
|
||||
}
|
||||
|
||||
try {
|
||||
const fileContent = await fs.readFile(filePath);
|
||||
axiosRequest.data = fileContent;
|
||||
} catch (error) {
|
||||
console.error('Error reading file:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (request.body.mode === 'formUrlEncoded') {
|
||||
if (!contentTypeDefined) {
|
||||
axiosRequest.headers['content-type'] = 'application/x-www-form-urlencoded';
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user