mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-03 17:38:36 +00:00
Compare commits
130 Commits
feature/pl
...
v2.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
942c0ee113 | ||
|
|
dbf8af1146 | ||
|
|
d7ccf1454e | ||
|
|
652d447f8b | ||
|
|
2f58379feb | ||
|
|
d4673a2f07 | ||
|
|
3a0c94577f | ||
|
|
c407b73c22 | ||
|
|
361add3385 | ||
|
|
9d6ab69d37 | ||
|
|
b699088dd6 | ||
|
|
458c070004 | ||
|
|
babac6df3c | ||
|
|
f58477931f | ||
|
|
2171d491a6 | ||
|
|
aa911f88f2 | ||
|
|
bdbcaeff67 | ||
|
|
b2756b3c63 | ||
|
|
27f11ab583 | ||
|
|
2776970317 | ||
|
|
9d28bf7e82 | ||
|
|
6455b00742 | ||
|
|
16179a3b50 | ||
|
|
6a37c9d076 | ||
|
|
1915b1c00a | ||
|
|
a9982d6e28 | ||
|
|
1daeb8fe93 | ||
|
|
3dfb158382 | ||
|
|
fb7d247fa7 | ||
|
|
6bf2312a94 | ||
|
|
0cdcb83a7a | ||
|
|
e4f48e81fc | ||
|
|
1d32a95a09 | ||
|
|
4c934a78a6 | ||
|
|
c47bc86d37 | ||
|
|
a125781312 | ||
|
|
dfa951e574 | ||
|
|
76779e6f95 | ||
|
|
e9a79a32da | ||
|
|
967170a7b2 | ||
|
|
3326784315 | ||
|
|
fc553e1009 | ||
|
|
da172ff9b5 | ||
|
|
fc422853ef | ||
|
|
2852c07ec7 | ||
|
|
ead1c9ecab | ||
|
|
5b5066577f | ||
|
|
4af0bb3943 | ||
|
|
f2eaa79318 | ||
|
|
2ee7ce5829 | ||
|
|
38c307d6f1 | ||
|
|
520567793a | ||
|
|
126186041e | ||
|
|
6379e1703c | ||
|
|
2b246e431b | ||
|
|
526fcabffe | ||
|
|
75ff31f0cf | ||
|
|
46dab6e474 | ||
|
|
c7e8c07d40 | ||
|
|
932d2b77dc | ||
|
|
049de84cbb | ||
|
|
3bd44ea7ef | ||
|
|
317ccccfc6 | ||
|
|
220da6b58e | ||
|
|
6a7750d354 | ||
|
|
529803f791 | ||
|
|
4c23ab5664 | ||
|
|
e3c28fd0ec | ||
|
|
56ab61c29c | ||
|
|
d3056ba843 | ||
|
|
e34e2ec1f1 | ||
|
|
524bb5e4b7 | ||
|
|
3f8ea7764e | ||
|
|
f0d1e6936e | ||
|
|
9a21eec1b9 | ||
|
|
1703346bb6 | ||
|
|
b93d8e73a2 | ||
|
|
17c9813c98 | ||
|
|
e5ebe20a20 | ||
|
|
54a03fd0d3 | ||
|
|
e8affcfde9 | ||
|
|
d376947a91 | ||
|
|
59e38fbdb0 | ||
|
|
492449b7c5 | ||
|
|
7cd21636d6 | ||
|
|
6ff49589be | ||
|
|
c950806541 | ||
|
|
3dcc12042f | ||
|
|
92925648e6 | ||
|
|
811c492bce | ||
|
|
73fa2e19e4 | ||
|
|
921e1af72b | ||
|
|
cc905da630 | ||
|
|
74bbfce8a0 | ||
|
|
8b67a0423d | ||
|
|
f1d527aa9c | ||
|
|
9e45d4d227 | ||
|
|
2dd0424d8f | ||
|
|
838f25b9db | ||
|
|
8809469f8e | ||
|
|
289f138c2a | ||
|
|
3d0dd60f56 | ||
|
|
9bb9a914ac | ||
|
|
44cef9999c | ||
|
|
3a792a021c | ||
|
|
2e5c63cfb9 | ||
|
|
9845363349 | ||
|
|
1a6fa7a799 | ||
|
|
6cd44662a8 | ||
|
|
9daf418886 | ||
|
|
37ee13353d | ||
|
|
8439e8871f | ||
|
|
4c1d3b4f3a | ||
|
|
cd3c66cb14 | ||
|
|
265b0114e4 | ||
|
|
17a63d599d | ||
|
|
d9e87fcd82 | ||
|
|
78c4cb11eb | ||
|
|
6feea75e45 | ||
|
|
2d1f7d0f33 | ||
|
|
841facc853 | ||
|
|
0e60bd3da7 | ||
|
|
5dc7f1ae2f | ||
|
|
6862cb4e58 | ||
|
|
0591530d44 | ||
|
|
592679538b | ||
|
|
9ef2699372 | ||
|
|
e4c37b916a | ||
|
|
7a8a0ae37e | ||
|
|
f972733426 |
4
.github/ISSUE_TEMPLATE/BugReport.yaml
vendored
4
.github/ISSUE_TEMPLATE/BugReport.yaml
vendored
@@ -27,7 +27,9 @@ body:
|
||||
required: false
|
||||
- label: annoying
|
||||
required: false
|
||||
|
||||
- label: this feature was working in a previous version but is broken in the current release.
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
attributes:
|
||||
label: Bruno version
|
||||
|
||||
44
.github/workflows/playwright.yml
vendored
Normal file
44
.github/workflows/playwright.yml
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
name: Playwright E2E Tests
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
e2e-test:
|
||||
timeout-minutes: 60
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: v22.11.x
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get --no-install-recommends install -y \
|
||||
libglib2.0-0 libnss3 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libgtk-3-0 libasound2t64 \
|
||||
xvfb
|
||||
npm ci --legacy-peer-deps
|
||||
sudo chown root /home/runner/work/bruno/bruno/node_modules/electron/dist/chrome-sandbox
|
||||
sudo chmod 4755 /home/runner/work/bruno/bruno/node_modules/electron/dist/chrome-sandbox
|
||||
|
||||
- name: Build libraries
|
||||
run: |
|
||||
npm run build:graphql-docs
|
||||
npm run build:bruno-query
|
||||
npm run build:bruno-common
|
||||
npm run sandbox:bundle-libraries --workspace=packages/bruno-js
|
||||
npm run build:bruno-converters
|
||||
npm run build:bruno-requests
|
||||
|
||||
- name: Run Playwright tests
|
||||
run: |
|
||||
xvfb-run npm run test:e2e
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
retention-days: 30
|
||||
9
.github/workflows/tests.yml
vendored
9
.github/workflows/tests.yml
vendored
@@ -28,6 +28,11 @@ jobs:
|
||||
npm run build --workspace=packages/bruno-common
|
||||
npm run build --workspace=packages/bruno-query
|
||||
npm run sandbox:bundle-libraries --workspace=packages/bruno-js
|
||||
npm run build --workspace=packages/bruno-converters
|
||||
npm run build --workspace=packages/bruno-requests
|
||||
|
||||
- name: Lint Check
|
||||
run: npm run lint
|
||||
|
||||
# tests
|
||||
- name: Test Package bruno-js
|
||||
@@ -45,6 +50,8 @@ jobs:
|
||||
run: npm run test --workspace=packages/bruno-app
|
||||
- name: Test Package bruno-common
|
||||
run: npm run test --workspace=packages/bruno-common
|
||||
- name: Test Package bruno-converters
|
||||
run: npm run test --workspace=packages/bruno-converters
|
||||
- name: Test Package bruno-electron
|
||||
run: npm run test --workspace=packages/bruno-electron
|
||||
|
||||
@@ -71,6 +78,8 @@ jobs:
|
||||
npm run build --workspace=packages/bruno-query
|
||||
npm run build --workspace=packages/bruno-common
|
||||
npm run sandbox:bundle-libraries --workspace=packages/bruno-js
|
||||
npm run build --workspace=packages/bruno-converters
|
||||
npm run build --workspace=packages/bruno-requests
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -46,4 +46,8 @@ yarn-error.log*
|
||||
|
||||
#dev editor
|
||||
bruno.iml
|
||||
.idea
|
||||
.idea
|
||||
.vscode
|
||||
|
||||
# Playwright
|
||||
/blob-report/
|
||||
|
||||
@@ -15,15 +15,15 @@
|
||||
| [正體中文](docs/contributing/contributing_zhtw.md)
|
||||
| [日本語](docs/contributing/contributing_ja.md)
|
||||
| [हिंदी](docs/contributing/contributing_hi.md)
|
||||
| [Nederlands](docs/contributing/contributing_nl.md)
|
||||
| [Dutch](docs/contributing/contributing_nl.md)
|
||||
|
||||
## Let's make Bruno better, together!!
|
||||
|
||||
We are happy that you are looking to improve Bruno. Below are the guidelines to get started bringing up Bruno on your computer.
|
||||
We are happy that you are looking to improve Bruno. Below are the guidelines to run Bruno on your computer.
|
||||
|
||||
### Technology Stack
|
||||
|
||||
Bruno is built using Next.js and React. We also use electron to ship a desktop version (that supports local collections)
|
||||
Bruno is built using React and Electron.
|
||||
|
||||
Libraries we use
|
||||
|
||||
@@ -37,38 +37,67 @@ Libraries we use
|
||||
- Filesystem Watcher - chokidar
|
||||
- i18n - i18next
|
||||
|
||||
### Dependencies
|
||||
|
||||
You would need [Node v20.x or the latest LTS version](https://nodejs.org/en/) and npm 8.x. We use npm workspaces in the project
|
||||
> [!IMPORTANT]
|
||||
> You would need [Node v22.x or the latest LTS version](https://nodejs.org/en/). We use npm workspaces in the project
|
||||
|
||||
## Development
|
||||
|
||||
Bruno is being developed as a desktop app. You need to load the app by running the Next.js app in one terminal and then run the electron app in another terminal.
|
||||
Bruno is a desktop app. Below are the instructions to run Bruno.
|
||||
|
||||
### Local Development
|
||||
> Note: We use React for the frontend and rsbuild for build and dev server.
|
||||
|
||||
## Install Dependencies
|
||||
|
||||
```bash
|
||||
# use nodejs 20 version
|
||||
# use nodejs 22 version
|
||||
nvm use
|
||||
|
||||
# install deps
|
||||
npm i --legacy-peer-deps
|
||||
```
|
||||
|
||||
### Local Development
|
||||
|
||||
#### Build packages
|
||||
|
||||
##### Option 1
|
||||
|
||||
```bash
|
||||
# build packages
|
||||
npm run build:graphql-docs
|
||||
npm run build:bruno-query
|
||||
npm run build:bruno-common
|
||||
npm run build:bruno-converters
|
||||
npm run build:bruno-requests
|
||||
|
||||
# bundle js sandbox libraries
|
||||
npm run sandbox:bundle-libraries --workspace=packages/bruno-js
|
||||
```
|
||||
##### Option 2
|
||||
|
||||
# run next app (terminal 1)
|
||||
```bash
|
||||
# install dependencies and setup
|
||||
npm run setup
|
||||
```
|
||||
|
||||
#### Run the app
|
||||
|
||||
##### Option 1
|
||||
|
||||
```bash
|
||||
# run react app (terminal 1)
|
||||
npm run dev:web
|
||||
|
||||
# run electron app (terminal 2)
|
||||
npm run dev:electron
|
||||
```
|
||||
|
||||
##### Option 2
|
||||
```bash
|
||||
# run electron and react app concurrently
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
You might encounter a `Unsupported platform` error when you run `npm install`. To fix this, you will need to delete `node_modules` and `package-lock.json` and run `npm install`. This should install all the necessary packages needed to run the app.
|
||||
@@ -87,7 +116,28 @@ find . -type f -name "package-lock.json" -delete
|
||||
|
||||
```bash
|
||||
# run bruno-schema tests
|
||||
npm test --workspace=packages/bruno-schema
|
||||
npm run test --workspace=packages/bruno-schema
|
||||
|
||||
# run bruno-query tests
|
||||
npm run test --workspace=packages/bruno-query
|
||||
|
||||
# run bruno-common tests
|
||||
npm run test --workspace=packages/bruno-common
|
||||
|
||||
# run bruno-converters tests
|
||||
npm run test --workspace=packages/bruno-converters
|
||||
|
||||
# run bruno-app tests
|
||||
npm run test --workspace=packages/bruno-app
|
||||
|
||||
# run bruno-electron tests
|
||||
npm run test --workspace=packages/bruno-electron
|
||||
|
||||
# run bruno-lang tests
|
||||
npm run test --workspace=packages/bruno-lang
|
||||
|
||||
# run bruno-toml tests
|
||||
npm run test --workspace=packages/bruno-toml
|
||||
|
||||
# run tests over all workspaces
|
||||
npm test --workspaces --if-present
|
||||
|
||||
@@ -21,7 +21,7 @@ Bibliotheken die wir benutzen
|
||||
|
||||
### Abhängigkeiten
|
||||
|
||||
Du benötigst [Node v20.x oder die neuste LTS Version](https://nodejs.org/en/) und npm 8.x. Wir benutzen npm workspaces in dem Projekt.
|
||||
Du benötigst [Node v22.x oder die neuste LTS Version](https://nodejs.org/en/) und npm 8.x. Wir benutzen npm workspaces in dem Projekt.
|
||||
|
||||
### Lass uns coden
|
||||
|
||||
@@ -42,12 +42,12 @@ Bruno wird als Desktop-Anwendung entwickelt. Um die App zu starten, musst Du zue
|
||||
|
||||
### Abhängigkeiten
|
||||
|
||||
- NodeJS v18
|
||||
- NodeJS v22
|
||||
|
||||
### Lokales Entwickeln
|
||||
|
||||
```bash
|
||||
# use nodejs 18 version
|
||||
# use nodejs 22 version
|
||||
nvm use
|
||||
|
||||
# install deps
|
||||
|
||||
@@ -40,6 +40,8 @@ npm i --legacy-peer-deps
|
||||
npm run build:graphql-docs
|
||||
npm run build:bruno-query
|
||||
npm run build:bruno-common
|
||||
npm run build:bruno-converters
|
||||
npm run build:bruno-requests
|
||||
|
||||
# Next.js ऐप चलाएँ (टर्मिनल 1 पर)
|
||||
npm run dev:web
|
||||
|
||||
@@ -40,6 +40,8 @@ npm i --legacy-peer-deps
|
||||
npm run build:graphql-docs
|
||||
npm run build:bruno-query
|
||||
npm run build:bruno-common
|
||||
npm run build:bruno-converters
|
||||
npm run build:bruno-requests
|
||||
|
||||
# run next app (terminal 1)
|
||||
npm run dev:web
|
||||
|
||||
@@ -40,6 +40,8 @@ npm i --legacy-peer-deps
|
||||
npm run build:graphql-docs
|
||||
npm run build:bruno-query
|
||||
npm run build:bruno-common
|
||||
npm run build:bruno-converters
|
||||
npm run build:bruno-requests
|
||||
|
||||
# next 앱 실행 (1번 터미널)
|
||||
npm run dev:web
|
||||
|
||||
@@ -40,6 +40,8 @@ npm i --legacy-peer-deps
|
||||
npm run build:graphql-docs
|
||||
npm run build:bruno-query
|
||||
npm run build:bruno-common
|
||||
npm run build:bruno-converters
|
||||
npm run build:bruno-requests
|
||||
|
||||
# draai next app (terminal 1)
|
||||
npm run dev:web
|
||||
|
||||
@@ -42,6 +42,8 @@ npm i --legacy-peer-deps
|
||||
npm run build:graphql-docs
|
||||
npm run build:bruno-query
|
||||
npm run build:bruno-common
|
||||
npm run build:bruno-converters
|
||||
npm run build:bruno-requests
|
||||
|
||||
# spustite ďalšiu aplikáciu (terminál 1)
|
||||
npm run dev:web
|
||||
|
||||
5
e2e-tests/test-app-start.spec.ts
Normal file
5
e2e-tests/test-app-start.spec.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { test, expect } from '../playwright';
|
||||
|
||||
test('test-app-start', async ({ page }) => {
|
||||
await expect(page.getByRole('button', { name: 'bruno' })).toBeVisible();
|
||||
});
|
||||
41
eslint.config.js
Normal file
41
eslint.config.js
Normal file
@@ -0,0 +1,41 @@
|
||||
// eslint.config.js
|
||||
const { defineConfig } = require("eslint/config");
|
||||
const globals = require("globals");
|
||||
|
||||
module.exports = defineConfig([
|
||||
{
|
||||
files: ["packages/bruno-app/**/*.{js,jsx,ts}"],
|
||||
ignores: ["**/*.config.js"],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.jest,
|
||||
global: false,
|
||||
require: false,
|
||||
Buffer: false,
|
||||
process: false
|
||||
},
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
"no-undef": "error",
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["packages/bruno-electron/**/*.{js}"],
|
||||
ignores: ["**/*.config.js"],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
...globals.jest,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
"no-undef": "error",
|
||||
},
|
||||
}
|
||||
]);
|
||||
2495
package-lock.json
generated
2495
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
31
package.json
31
package.json
@@ -6,24 +6,31 @@
|
||||
"packages/bruno-electron",
|
||||
"packages/bruno-cli",
|
||||
"packages/bruno-common",
|
||||
"packages/bruno-converters",
|
||||
"packages/bruno-schema",
|
||||
"packages/bruno-query",
|
||||
"packages/bruno-js",
|
||||
"packages/bruno-lang",
|
||||
"packages/bruno-tests",
|
||||
"packages/bruno-toml",
|
||||
"packages/bruno-graphql-docs"
|
||||
"packages/bruno-graphql-docs",
|
||||
"packages/bruno-requests"
|
||||
],
|
||||
"homepage": "https://usebruno.com",
|
||||
"devDependencies": {
|
||||
"@faker-js/faker": "^7.6.0",
|
||||
"@jest/globals": "^29.2.0",
|
||||
"@playwright/test": "^1.27.1",
|
||||
"@playwright/test": "^1.51.1",
|
||||
"@types/jest": "^29.5.11",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "^22.14.1",
|
||||
"concurrently": "^8.2.2",
|
||||
"eslint": "^9.26.0",
|
||||
"fs-extra": "^11.1.1",
|
||||
"husky": "^8.0.3",
|
||||
"globals": "^16.1.0",
|
||||
"jest": "^29.2.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"playwright": "^1.51.1",
|
||||
"pretty-quick": "^3.1.3",
|
||||
"randomstring": "^1.2.2",
|
||||
"rimraf": "^6.0.1",
|
||||
@@ -31,6 +38,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"setup": "node ./scripts/setup.js",
|
||||
"watch:converters": "npm run watch --workspace=packages/bruno-converters",
|
||||
"dev": "concurrently --kill-others \"npm run dev:web\" \"npm run dev:electron\"",
|
||||
"dev:web": "npm run dev --workspace=packages/bruno-app",
|
||||
"build:web": "npm run build --workspace=packages/bruno-app",
|
||||
@@ -38,6 +46,8 @@
|
||||
"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-requests": "npm run build --workspace=packages/bruno-requests",
|
||||
"build:bruno-converters": "npm run build --workspace=packages/bruno-converters",
|
||||
"build:bruno-query": "npm run build --workspace=packages/bruno-query",
|
||||
"build:graphql-docs": "npm run build --workspace=packages/bruno-graphql-docs",
|
||||
"build:electron": "node ./scripts/build-electron.js",
|
||||
@@ -47,13 +57,18 @@
|
||||
"build:electron:deb": "./scripts/build-electron.sh deb",
|
||||
"build:electron:rpm": "./scripts/build-electron.sh rpm",
|
||||
"build:electron:snap": "./scripts/build-electron.sh snap",
|
||||
"test:codegen": "npm run dev:web & node ./scripts/playwright-codegen.js",
|
||||
"test:e2e": "npx playwright test",
|
||||
"test:report": "npx playwright show-report",
|
||||
"watch:common": "npm run watch --workspace=packages/bruno-common",
|
||||
"test:codegen": "node playwright/codegen.ts",
|
||||
"test:e2e": "playwright test",
|
||||
"test:prettier:web": "npm run test:prettier --workspace=packages/bruno-app",
|
||||
"prepare": "husky install"
|
||||
"lint": "node --max_old_space_size=4096 $(npx which eslint)"
|
||||
},
|
||||
"overrides": {
|
||||
"rollup": "3.29.5"
|
||||
"rollup": "3.29.5",
|
||||
"electron-store": {
|
||||
"conf": {
|
||||
"json-schema-typed": "8.0.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ export default defineConfig({
|
||||
})
|
||||
],
|
||||
source: {
|
||||
tsconfigPath: './jsconfig.json', // Specifies the path to the JavaScript/TypeScript configuration file
|
||||
tsconfigPath: './jsconfig.json', // Specifies the path to the JavaScript/TypeScript configuration file,
|
||||
},
|
||||
html: {
|
||||
title: 'Bruno'
|
||||
@@ -34,6 +34,16 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
},
|
||||
ignoreWarnings: [
|
||||
(warning) => warning.message.includes('Critical dependency: the request of a dependency is an expression') && warning?.moduleDescriptor?.name?.includes('flow-parser')
|
||||
],
|
||||
// Add externals configuration to exclude Node.js libraries
|
||||
externals: {
|
||||
// List specific Node.js modules you want to exclude
|
||||
// Format: 'module-name': 'commonjs module-name'
|
||||
'worker_threads': 'commonjs worker_threads',
|
||||
// 'path': 'commonjs path'
|
||||
}
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
@@ -102,6 +102,13 @@ const StyledWrapper = styled.div`
|
||||
.cm-s-default span.cm-variable {
|
||||
color: #397d13 !important;
|
||||
}
|
||||
|
||||
//matching bracket fix
|
||||
.CodeMirror-matchingbracket {
|
||||
background: #5cc0b48c !important;
|
||||
text-decoration:unset;
|
||||
}
|
||||
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
|
||||
@@ -35,6 +35,7 @@ if (!SERVER_RENDERED) {
|
||||
'res.getHeader(name)',
|
||||
'res.getHeaders()',
|
||||
'res.getBody()',
|
||||
'res.setBody(data)',
|
||||
'res.getResponseTime()',
|
||||
'req',
|
||||
'req.url',
|
||||
|
||||
@@ -72,7 +72,7 @@ const Info = ({ collection }) => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{showShareCollectionModal && <ShareCollection collection={collection} onClose={handleToggleShowShareCollectionModal(false)} />}
|
||||
{showShareCollectionModal && <ShareCollection collectionUid={collection.uid} onClose={handleToggleShowShareCollectionModal(false)} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useRef, useEffect } from 'react';
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import { IconTrash, IconAlertCircle, IconDeviceFloppy, IconRefresh, IconCircleCheck } from '@tabler/icons';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { selectEnvironment } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import SingleLineEditor from 'components/SingleLineEditor';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
@@ -13,11 +13,18 @@ import { variableNameRegex } from 'utils/common/regex';
|
||||
import { saveEnvironment } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import toast from 'react-hot-toast';
|
||||
import { Tooltip } from 'react-tooltip';
|
||||
import { getGlobalEnvironmentVariables } from 'utils/collections';
|
||||
|
||||
const EnvironmentVariables = ({ environment, collection, setIsModified, originalEnvironmentVariables, onClose }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
const addButtonRef = useRef(null);
|
||||
const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments);
|
||||
|
||||
let _collection = cloneDeep(collection);
|
||||
|
||||
const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid });
|
||||
_collection.globalEnvironmentVariables = globalEnvironmentVariables;
|
||||
|
||||
const formik = useFormik({
|
||||
enableReinitialize: true,
|
||||
@@ -160,7 +167,7 @@ const EnvironmentVariables = ({ environment, collection, setIsModified, original
|
||||
<div className="overflow-hidden grow w-full relative">
|
||||
<SingleLineEditor
|
||||
theme={storedTheme}
|
||||
collection={collection}
|
||||
collection={_collection}
|
||||
name={`${index}.value`}
|
||||
value={variable.value}
|
||||
isSecret={variable.secret}
|
||||
|
||||
@@ -28,7 +28,10 @@ const ImportEnvironment = ({ collection, onClose }) => {
|
||||
.then(() => {
|
||||
toast.success('Environment imported successfully');
|
||||
})
|
||||
.catch(() => toast.error('An error occurred while importing the environment'));
|
||||
.catch((error) => {
|
||||
toast.error('An error occurred while importing the environment');
|
||||
console.error(error);
|
||||
});
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
|
||||
@@ -28,7 +28,7 @@ const FolderSettings = ({ collection, folder }) => {
|
||||
tab = folderLevelSettingsSelectedTab[folder?.uid];
|
||||
}
|
||||
|
||||
const folderRoot = collection?.items.find((item) => item.uid === folder?.uid)?.root;
|
||||
const folderRoot = folder?.root;
|
||||
const hasScripts = folderRoot?.request?.script?.res || folderRoot?.request?.script?.req;
|
||||
const hasTests = folderRoot?.request?.tests;
|
||||
|
||||
|
||||
@@ -34,7 +34,10 @@ const ImportEnvironment = ({ onClose }) => {
|
||||
.then(() => {
|
||||
toast.success('Global Environment imported successfully');
|
||||
})
|
||||
.catch(() => toast.error('An error occurred while importing the environment'));
|
||||
.catch((error) => {
|
||||
toast.error('An error occurred while importing the environment');
|
||||
console.error(error);
|
||||
});
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
|
||||
@@ -8,7 +8,7 @@ const DotIcon = ({ width }) => {
|
||||
className='inline-block'
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
||||
<path d="M12 7a5 5 0 1 1 -4.995 5.217l-.005 -.217l.005 -.217a5 5 0 0 1 4.995 -4.783z" stroke-width="0" fill="currentColor" />
|
||||
<path d="M12 7a5 5 0 1 1 -4.995 5.217l-.005 -.217l.005 -.217a5 5 0 0 1 4.995 -4.783z" strokeWidth="0" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -125,7 +125,7 @@ const General = ({ close }) => {
|
||||
className="mousetrap mr-0"
|
||||
/>
|
||||
<label className="block ml-2 select-none" htmlFor="customCaCertificateEnabled">
|
||||
Use custom CA Certificate
|
||||
Use Custom CA Certificate
|
||||
</label>
|
||||
</div>
|
||||
{formik.values.customCaCertificate.filePath ? (
|
||||
@@ -183,7 +183,7 @@ const General = ({ close }) => {
|
||||
className={`block ml-2 select-none ${formik.values.customCaCertificate.enabled ? '' : 'opacity-25'}`}
|
||||
htmlFor="keepDefaultCaCertificatesEnabled"
|
||||
>
|
||||
Keep default CA Certificates
|
||||
Keep Default CA Certificates
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center mt-2">
|
||||
|
||||
@@ -239,17 +239,6 @@ const OAuth2ClientCredentials = ({ save, item = {}, request, handleRun, updateAu
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 w-full mb-4">
|
||||
<label className="block min-w-[140px]">Auto-refresh token</label>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="cursor-pointer w-4 h-4 accent-indigo-600"
|
||||
checked={get(request, 'auth.oauth2.autoRefreshToken', false)}
|
||||
onChange={(e) => handleChange('autoRefreshToken', e.target.checked)}
|
||||
/>
|
||||
<span className="text-xs text-gray-500">Automatically refresh the token when it expires</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2.5 mt-4">
|
||||
<div className="flex items-center px-2.5 py-1.5 bg-indigo-50/50 dark:bg-indigo-500/10 rounded-md">
|
||||
<IconSettings size={14} className="text-indigo-500 dark:text-indigo-400" />
|
||||
|
||||
@@ -3,8 +3,7 @@ import { useDispatch } from "react-redux";
|
||||
import toast from 'react-hot-toast';
|
||||
import { cloneDeep, find } from 'lodash';
|
||||
import { IconLoader2 } from '@tabler/icons';
|
||||
import brunoCommon from '@usebruno/common';
|
||||
const { interpolate } = brunoCommon;
|
||||
import { interpolate } from '@usebruno/common';
|
||||
import { fetchOauth2Credentials, clearOauth2Cache, refreshOauth2Credentials } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { getAllVariables } from "utils/collections/index";
|
||||
|
||||
@@ -35,14 +34,14 @@ const Oauth2ActionButtons = ({ item, request, collection, url: accessTokenUrl, c
|
||||
toast.success('token fetched successfully!');
|
||||
}
|
||||
else {
|
||||
toast.error('An error occured while fetching token!');
|
||||
toast.error('An error occurred while fetching token!');
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('could not fetch the token!');
|
||||
console.error(error);
|
||||
toggleFetchingToken(false);
|
||||
toast.error('An error occured while fetching token!');
|
||||
toast.error('An error occurred while fetching token!');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,13 +57,13 @@ const Oauth2ActionButtons = ({ item, request, collection, url: accessTokenUrl, c
|
||||
toast.success('token refreshed successfully!');
|
||||
}
|
||||
else {
|
||||
toast.error('An error occured while refreshing token!');
|
||||
toast.error('An error occurred while refreshing token!');
|
||||
}
|
||||
}
|
||||
catch(error) {
|
||||
console.error(error);
|
||||
toggleRefreshingToken(false);
|
||||
toast.error('An error occured while refreshing token!');
|
||||
toast.error('An error occurred while refreshing token!');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { find } from "lodash";
|
||||
import StyledWrapper from "./StyledWrapper";
|
||||
import { useState, useEffect } from "react";
|
||||
import { IconChevronDown, IconChevronRight, IconCopy, IconCheck } from '@tabler/icons';
|
||||
import { getAllVariables } from 'utils/collections/index';
|
||||
import brunoCommon from '@usebruno/common';
|
||||
const { interpolate } = brunoCommon;
|
||||
import { interpolate } from '@usebruno/common';
|
||||
|
||||
const TokenSection = ({ title, token }) => {
|
||||
if (!token) return null;
|
||||
|
||||
@@ -242,17 +242,6 @@ const OAuth2PasswordCredentials = ({ save, item = {}, request, handleRun, update
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 w-full mb-4">
|
||||
<label className="block min-w-[140px]">Auto-refresh token</label>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="cursor-pointer w-4 h-4 accent-indigo-600"
|
||||
checked={get(request, 'auth.oauth2.autoRefreshToken', false)}
|
||||
onChange={(e) => handleChange('autoRefreshToken', e.target.checked)}
|
||||
/>
|
||||
<span className="text-xs text-gray-500">Automatically refresh the token when it expires</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2.5 mt-4">
|
||||
<div className="flex items-center px-2.5 py-1.5 bg-indigo-50/50 dark:bg-indigo-500/10 rounded-md">
|
||||
<IconSettings size={14} className="text-indigo-500 dark:text-indigo-400" />
|
||||
|
||||
@@ -140,7 +140,7 @@ const QueryUrl = ({ item, collection, handleRun }) => {
|
||||
</div>
|
||||
</div>
|
||||
{generateCodeItemModalOpen && (
|
||||
<GenerateCodeItem collection={collection} item={item} onClose={() => setGenerateCodeItemModalOpen(false)} />
|
||||
<GenerateCodeItem collectionUid={collection.uid} item={item} onClose={() => setGenerateCodeItemModalOpen(false)} />
|
||||
)}
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { closeTabs } from 'providers/ReduxStore/slices/tabs';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
const FolderNotFound = ({ folderUid }) => {
|
||||
const dispatch = useDispatch();
|
||||
const [showErrorMessage, setShowErrorMessage] = useState(false);
|
||||
|
||||
const closeTab = useCallback(() => {
|
||||
dispatch(
|
||||
closeTabs({
|
||||
tabUids: [folderUid]
|
||||
})
|
||||
);
|
||||
}, [dispatch, folderUid]);
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
setShowErrorMessage(true);
|
||||
}, 300);
|
||||
}, []);
|
||||
|
||||
if (!showErrorMessage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-6 px-6">
|
||||
<div className="p-4 bg-orange-100 border-l-4 border-yellow-500 text-yellow-700">
|
||||
<div>Folder no longer exists.</div>
|
||||
<div className="mt-2">
|
||||
This can happen when the folder was renamed or deleted on your filesystem.
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn btn-md btn-secondary mt-6" onClick={closeTab}>
|
||||
Close Tab
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FolderNotFound;
|
||||
@@ -25,6 +25,7 @@ import { produce } from 'immer';
|
||||
import CollectionOverview from 'components/CollectionSettings/Overview';
|
||||
import RequestNotLoaded from './RequestNotLoaded';
|
||||
import RequestIsLoading from './RequestIsLoading';
|
||||
import FolderNotFound from './FolderNotFound';
|
||||
|
||||
const MIN_LEFT_PANE_WIDTH = 300;
|
||||
const MIN_RIGHT_PANE_WIDTH = 350;
|
||||
@@ -163,6 +164,10 @@ const RequestTabPanel = () => {
|
||||
|
||||
if (focusedTab.type === 'folder-settings') {
|
||||
const folder = findItemInCollection(collection, focusedTab.folderUid);
|
||||
if (!folder) {
|
||||
return <FolderNotFound folderUid={focusedTab.folderUid} />;
|
||||
}
|
||||
|
||||
return <FolderSettings collection={collection} folder={folder} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -76,7 +76,9 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
|
||||
className={`flex items-center justify-between tab-container px-1 ${tab.preview ? "italic" : ""}`}
|
||||
onMouseUp={handleMouseUp} // Add middle-click behavior here
|
||||
>
|
||||
{tab.type === 'folder-settings' ? (
|
||||
{tab.type === 'folder-settings' && !folder ? (
|
||||
<RequestTabNotFound handleCloseClick={handleCloseClick} />
|
||||
) : tab.type === 'folder-settings' ? (
|
||||
<SpecialTab handleCloseClick={handleCloseClick} handleDoubleClick={() => dispatch(makeTabPermanent({ uid: tab.uid }))} type={tab.type} tabName={folder?.name} />
|
||||
) : (
|
||||
<SpecialTab handleCloseClick={handleCloseClick} handleDoubleClick={() => dispatch(makeTabPermanent({ uid: tab.uid }))} type={tab.type} />
|
||||
@@ -261,13 +263,13 @@ function RequestTabMenu({ onDropdownCreate, collectionRequestTabs, tabIndex, col
|
||||
return (
|
||||
<Fragment>
|
||||
{showAddNewRequestModal && (
|
||||
<NewRequest collection={collection} onClose={() => setShowAddNewRequestModal(false)} />
|
||||
<NewRequest collectionUid={collection.uid} onClose={() => setShowAddNewRequestModal(false)} />
|
||||
)}
|
||||
|
||||
{showCloneRequestModal && (
|
||||
<CloneCollectionItem
|
||||
item={currentTabItem}
|
||||
collection={collection}
|
||||
collectionUid={collection.uid}
|
||||
onClose={() => setShowCloneRequestModal(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -79,7 +79,7 @@ const RequestTabs = () => {
|
||||
return (
|
||||
<StyledWrapper className={getRootClassname()}>
|
||||
{newRequestModalOpen && (
|
||||
<NewRequest collection={activeCollection} onClose={() => setNewRequestModalOpen(false)} />
|
||||
<NewRequest collectionUid={activeCollection?.uid} onClose={() => setNewRequestModalOpen(false)} />
|
||||
)}
|
||||
{collectionRequestTabs && collectionRequestTabs.length ? (
|
||||
<>
|
||||
|
||||
@@ -2,14 +2,20 @@ import React from 'react';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const ResponseSize = ({ size }) => {
|
||||
|
||||
if (!Number.isFinite(size)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let sizeToDisplay = '';
|
||||
|
||||
// If size is greater than 1024 bytes, format as KB
|
||||
if (size > 1024) {
|
||||
// size is greater than 1kb
|
||||
let kb = Math.floor(size / 1024);
|
||||
let decimal = Math.round(((size % 1024) / 1024).toFixed(2) * 100);
|
||||
sizeToDisplay = kb + '.' + decimal + 'KB';
|
||||
} else {
|
||||
// If size is less than or equal to 1024 bytes, display as bytes (B)
|
||||
sizeToDisplay = size + 'B';
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
padding-top: 20%;
|
||||
width: 100%;
|
||||
.send-icon {
|
||||
color: ${(props) => props.theme.requestTabPanel.responseSendIcon};
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,18 @@
|
||||
import React from 'react';
|
||||
import { IconCircleOff } from '@tabler/icons';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const SkippedRequest = () => {
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="send-icon flex justify-center" style={{ fontSize: 200 }}>
|
||||
<IconCircleOff size={150} strokeWidth={1} />
|
||||
</div>
|
||||
<div className="flex mt-4 justify-center" style={{ fontSize: 25 }}>
|
||||
Request skipped
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default SkippedRequest;
|
||||
@@ -12,6 +12,10 @@ const StyledWrapper = styled.div`
|
||||
.error-message {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
.skipped-request {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
|
||||
@@ -2,9 +2,17 @@ const Network = ({ logs }) => {
|
||||
return (
|
||||
<div className="bg-black/5 text-white network-logs rounded overflow-auto h-96">
|
||||
<pre className="whitespace-pre-wrap">
|
||||
{logs.map((entry, index) => (
|
||||
<NetworkLogsEntry key={index} entry={entry} />
|
||||
))}
|
||||
{logs.map((currentLog, index) => {
|
||||
if (index > 0 && currentLog?.type === 'separator') {
|
||||
return <div className="border-t-2 border-gray-500 w-full my-2" key={index} />;
|
||||
}
|
||||
const nextLog = logs[index + 1];
|
||||
const isSameLogType = nextLog?.type === currentLog?.type;
|
||||
return <>
|
||||
<NetworkLogsEntry key={index} entry={currentLog} />
|
||||
{!isSameLogType && <div className="mt-4"/>}
|
||||
</>;
|
||||
})}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -18,6 +18,7 @@ import ScriptErrorIcon from './ScriptErrorIcon';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import ResponseSave from 'src/components/ResponsePane/ResponseSave';
|
||||
import ResponseClear from 'src/components/ResponsePane/ResponseClear';
|
||||
import SkippedRequest from './SkippedRequest';
|
||||
import ClearTimeline from './ClearTimeline/index';
|
||||
|
||||
const ResponsePane = ({ rightPaneWidth, item, collection }) => {
|
||||
@@ -47,6 +48,7 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
|
||||
};
|
||||
|
||||
const response = item.response || {};
|
||||
const responseSize = response.size || 0;
|
||||
|
||||
const getTabPanel = (tab) => {
|
||||
switch (tab) {
|
||||
@@ -80,6 +82,14 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
|
||||
}
|
||||
};
|
||||
|
||||
if (item.response && item.status === 'skipped') {
|
||||
return (
|
||||
<StyledWrapper className="flex h-full relative">
|
||||
<SkippedRequest />
|
||||
</StyledWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading && !item.response) {
|
||||
return (
|
||||
<StyledWrapper className="flex flex-col h-full relative">
|
||||
@@ -141,13 +151,13 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
|
||||
)}
|
||||
{focusedTab?.responsePaneTab === "timeline" ? (
|
||||
<ClearTimeline item={item} collection={collection} />
|
||||
) : item?.response ? (
|
||||
) : (item?.response && !item?.response?.error) ? (
|
||||
<>
|
||||
<ResponseClear item={item} collection={collection} />
|
||||
<ResponseSave item={item} />
|
||||
<StatusCode status={response.status} />
|
||||
<ResponseTime duration={response.duration} />
|
||||
<ResponseSize size={response.size} />
|
||||
<ResponseSize size={responseSize} />
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -33,6 +33,10 @@ const StyledWrapper = styled.div`
|
||||
.all-tests-passed {
|
||||
color: ${(props) => props.theme.colors.text.green} !important;
|
||||
}
|
||||
|
||||
.skipped-request {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
|
||||
@@ -10,6 +10,7 @@ import ResponseSize from 'components/ResponsePane/ResponseSize';
|
||||
import TestResults from 'components/ResponsePane/TestResults';
|
||||
import TestResultsLabel from 'components/ResponsePane/TestResultsLabel';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import SkippedRequest from 'components/ResponsePane/SkippedRequest';
|
||||
import RunnerTimeline from 'components/ResponsePane/RunnerTimeline';
|
||||
|
||||
const ResponsePane = ({ rightPaneWidth, item, collection }) => {
|
||||
@@ -63,6 +64,14 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
|
||||
});
|
||||
};
|
||||
|
||||
if (item.status === 'skipped') {
|
||||
return (
|
||||
<StyledWrapper className="flex h-full relative">
|
||||
<SkippedRequest />
|
||||
</StyledWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledWrapper className="flex flex-col h-full relative">
|
||||
<div className="flex items-center px-3 tabs" role="tablist">
|
||||
|
||||
@@ -39,6 +39,10 @@ const Wrapper = styled.div`
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
}
|
||||
|
||||
.skipped-request {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
`;
|
||||
|
||||
export default Wrapper;
|
||||
|
||||
@@ -5,7 +5,7 @@ import { get, cloneDeep } from 'lodash';
|
||||
import { runCollectionFolder, cancelRunnerExecution } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { resetCollectionRunner } from 'providers/ReduxStore/slices/collections';
|
||||
import { findItemInCollection, getTotalRequestCountInCollection } from 'utils/collections';
|
||||
import { IconRefresh, IconCircleCheck, IconCircleX, IconCheck, IconX, IconRun } from '@tabler/icons';
|
||||
import { IconRefresh, IconCircleCheck, IconCircleX, IconCircleOff, IconCheck, IconX, IconRun } from '@tabler/icons';
|
||||
import ResponsePane from './ResponsePane';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { areItemsLoading } from 'utils/collections';
|
||||
@@ -102,6 +102,9 @@ export default function RunnerResults({ collection }) {
|
||||
return (item.status !== 'error' && item.testStatus === 'fail') || item.assertionStatus === 'fail';
|
||||
});
|
||||
|
||||
const skippedRequests = items.filter((item) => {
|
||||
return item.status === 'skipped';
|
||||
});
|
||||
let isCollectionLoading = areItemsLoading(collection);
|
||||
|
||||
if (!items || !items.length) {
|
||||
@@ -159,7 +162,8 @@ export default function RunnerResults({ collection }) {
|
||||
ref={runnerBodyRef}
|
||||
>
|
||||
<div className="pb-2 font-medium test-summary">
|
||||
Total Requests: {items.length}, Passed: {passedRequests.length}, Failed: {failedRequests.length}
|
||||
Total Requests: {items.length}, Passed: {passedRequests.length}, Failed: {failedRequests.length}, Skipped:{' '}
|
||||
{skippedRequests.length}
|
||||
</div>
|
||||
{runnerInfo?.statusText ?
|
||||
<div className="pb-2 font-medium danger">
|
||||
@@ -172,14 +176,18 @@ export default function RunnerResults({ collection }) {
|
||||
<div className="item-path mt-2">
|
||||
<div className="flex items-center">
|
||||
<span>
|
||||
{item.status !== 'error' && item.testStatus === 'pass' && item.status !== 'skipped' ? (
|
||||
{item.testStatus === 'pass' && item.assertionStatus === 'pass' ?
|
||||
<IconCircleCheck className="test-success" size={20} strokeWidth={1.5} />
|
||||
) : (
|
||||
: null}
|
||||
{item.status === 'skipped' ?
|
||||
<IconCircleOff className="skipped-request" size={20} strokeWidth={1.5} />
|
||||
:null}
|
||||
{item.status === 'error' || item.testStatus === 'fail' || item.assertionStatus === 'fail' ?
|
||||
<IconCircleX className="test-failure" size={20} strokeWidth={1.5} />
|
||||
)}
|
||||
:null}
|
||||
</span>
|
||||
<span
|
||||
className={`mr-1 ml-2 ${item.status == 'error' || item.status == 'skipped' || item.testStatus == 'fail' ? 'danger' : ''}`}
|
||||
className={`mr-1 ml-2 ${item.status == 'skipped' ? 'skipped-request' : item.status === 'error' || item.testStatus === 'fail' || item.assertionStatus === 'fail' ? 'danger' : ''}`}
|
||||
>
|
||||
{item.displayName}
|
||||
</span>
|
||||
@@ -263,11 +271,15 @@ export default function RunnerResults({ collection }) {
|
||||
<div className="flex items-center px-3 mb-4 font-medium">
|
||||
<span className="mr-2">{selectedItem.displayName}</span>
|
||||
<span>
|
||||
{selectedItem.testStatus === 'pass' ? (
|
||||
{selectedItem.testStatus === 'pass' && selectedItem.assertionStatus === 'pass' ?
|
||||
<IconCircleCheck className="test-success" size={20} strokeWidth={1.5} />
|
||||
) : (
|
||||
<IconCircleX className="test-failure" size={20} strokeWidth={1.5} />
|
||||
)}
|
||||
: null}
|
||||
{selectedItem.status === 'error' || selectedItem.testStatus === 'fail' || selectedItem.assertionStatus === 'fail' ?
|
||||
<IconCircleX className="test-failure" size={20} strokeWidth={1.5} />
|
||||
: null}
|
||||
{selectedItem.status === 'skipped' ?
|
||||
<IconCircleOff className="skipped-request" size={20} strokeWidth={1.5} />
|
||||
: null}
|
||||
</span>
|
||||
</div>
|
||||
<ResponsePane item={selectedItem} collection={collection} />
|
||||
|
||||
@@ -7,8 +7,11 @@ import exportBrunoCollection from 'utils/collections/export';
|
||||
import exportPostmanCollection from 'utils/exporters/postman-collection';
|
||||
import { cloneDeep } from 'lodash';
|
||||
import { transformCollectionToSaveToExportAsFile } from 'utils/collections/index';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { findCollectionByUid } from 'utils/collections/index';
|
||||
|
||||
const ShareCollection = ({ onClose, collection }) => {
|
||||
const ShareCollection = ({ onClose, collectionUid }) => {
|
||||
const collection = useSelector(state => findCollectionByUid(state.collections.collections, collectionUid));
|
||||
const handleExportBrunoCollection = () => {
|
||||
const collectionCopy = cloneDeep(collection);
|
||||
exportBrunoCollection(transformCollectionToSaveToExportAsFile(collectionCopy));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import { browseDirectory } from 'providers/ReduxStore/slices/collections/actions';
|
||||
@@ -11,11 +11,13 @@ import Help from 'components/Help';
|
||||
import PathDisplay from 'components/PathDisplay';
|
||||
import { useState } from 'react';
|
||||
import { IconArrowBackUp, IconEdit } from "@tabler/icons";
|
||||
import { findCollectionByUid } from 'utils/collections/index';
|
||||
|
||||
const CloneCollection = ({ onClose, collection }) => {
|
||||
const CloneCollection = ({ onClose, collectionUid }) => {
|
||||
const inputRef = useRef();
|
||||
const dispatch = useDispatch();
|
||||
const [isEditing, toggleEditing] = useState(false);
|
||||
const collection = useSelector(state => findCollectionByUid(state.collections.collections, collectionUid));
|
||||
const { name } = collection;
|
||||
|
||||
const formik = useFormik({
|
||||
@@ -46,7 +48,7 @@ const CloneCollection = ({ onClose, collection }) => {
|
||||
values.collectionName,
|
||||
values.collectionFolderName,
|
||||
values.collectionLocation,
|
||||
collection.pathname
|
||||
collection?.pathname
|
||||
)
|
||||
)
|
||||
.then(() => {
|
||||
|
||||
@@ -15,7 +15,7 @@ import Portal from 'components/Portal';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const CloneCollectionItem = ({ collection, item, onClose }) => {
|
||||
const CloneCollectionItem = ({ collectionUid, item, onClose }) => {
|
||||
const dispatch = useDispatch();
|
||||
const isFolder = isItemAFolder(item);
|
||||
const inputRef = useRef();
|
||||
@@ -49,7 +49,7 @@ const CloneCollectionItem = ({ collection, item, onClose }) => {
|
||||
.test('not-reserved', `The file names "collection" and "folder" are reserved in bruno`, value => !['collection', 'folder'].includes(value))
|
||||
}),
|
||||
onSubmit: (values) => {
|
||||
dispatch(cloneItem(values.name, values.filename, item.uid, collection.uid))
|
||||
dispatch(cloneItem(values.name, values.filename, item.uid, collectionUid))
|
||||
.then(() => {
|
||||
toast.success('Request cloned!');
|
||||
onClose();
|
||||
@@ -172,8 +172,6 @@ const CloneCollectionItem = ({ collection, item, onClose }) => {
|
||||
) : (
|
||||
<div className='relative flex flex-row gap-1 items-center justify-between'>
|
||||
<PathDisplay
|
||||
collection={collection}
|
||||
dirName={path.relative(collection?.pathname, path.dirname(item?.pathname))}
|
||||
baseName={formik.values.filename}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.drag-preview {
|
||||
background-color: ${(props) => props.theme.sidebar.collection.item.hoverBg};
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useDragLayer } from 'react-dnd';
|
||||
import {
|
||||
IconFile,
|
||||
IconFolder,
|
||||
} from '@tabler/icons';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
function getItemStyles({ x, y }) {
|
||||
if (Number.isNaN(x) || Number.isNaN(y)) return { display: 'none' };
|
||||
const transform = `translate(${x}px, ${y}px)`;
|
||||
|
||||
return {
|
||||
position: 'fixed',
|
||||
pointerEvents: 'none',
|
||||
top: 0,
|
||||
transform,
|
||||
WebkitTransform: transform,
|
||||
zIndex: 100,
|
||||
};
|
||||
}
|
||||
|
||||
export const CollectionItemDragPreview = () => {
|
||||
const {
|
||||
item,
|
||||
isDragging,
|
||||
clientOffset
|
||||
} = useDragLayer((monitor) => ({
|
||||
item: monitor.getItem(),
|
||||
isDragging: monitor.isDragging(),
|
||||
clientOffset: monitor.getClientOffset(),
|
||||
}));
|
||||
if (!isDragging) return null;
|
||||
const { x, y } = clientOffset || {};
|
||||
const shouldShowFolderIcon = !item.type || item.type === 'folder';
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div style={getItemStyles({ x, y })} className='p-2'>
|
||||
<div className='flex items-center gap-2 border border-gray-500/10 rounded-md px-2 py-1 drag-preview'>
|
||||
{shouldShowFolderIcon ? (
|
||||
<IconFolder size={16} />
|
||||
) : (
|
||||
<IconFile size={16} />
|
||||
)}
|
||||
{item.name}
|
||||
</div>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
@@ -7,11 +7,11 @@ import { deleteItem } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { recursivelyGetAllItemUids } from 'utils/collections';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const DeleteCollectionItem = ({ onClose, item, collection }) => {
|
||||
const DeleteCollectionItem = ({ onClose, item, collectionUid }) => {
|
||||
const dispatch = useDispatch();
|
||||
const isFolder = isItemAFolder(item);
|
||||
const onConfirm = () => {
|
||||
dispatch(deleteItem(item.uid, collection.uid)).then(() => {
|
||||
dispatch(deleteItem(item.uid, collectionUid)).then(() => {
|
||||
|
||||
if (isFolder) {
|
||||
// close all tabs that belong to the folder
|
||||
|
||||
@@ -62,6 +62,7 @@ const CodeView = ({ language, item }) => {
|
||||
<CodeEditor
|
||||
readOnly
|
||||
collection={collection}
|
||||
item={item}
|
||||
value={snippet}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
|
||||
@@ -10,9 +10,11 @@ import { getLanguages } from 'utils/codegenerator/targets';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { getGlobalEnvironmentVariables } from 'utils/collections/index';
|
||||
|
||||
const GenerateCodeItem = ({ collection, item, onClose }) => {
|
||||
const GenerateCodeItem = ({ collectionUid, item, onClose }) => {
|
||||
const languages = getLanguages();
|
||||
|
||||
const collection = useSelector(state => state.collections.collections?.find(c => c.uid === collectionUid));
|
||||
|
||||
const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments);
|
||||
const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid });
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import Portal from 'components/Portal';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const RenameCollectionItem = ({ collection, item, onClose }) => {
|
||||
const RenameCollectionItem = ({ collectionUid, item, onClose }) => {
|
||||
const dispatch = useDispatch();
|
||||
const isFolder = isItemAFolder(item);
|
||||
const inputRef = useRef();
|
||||
@@ -57,13 +57,13 @@ const RenameCollectionItem = ({ collection, item, onClose }) => {
|
||||
return;
|
||||
}
|
||||
if (!isFolder && item.draft) {
|
||||
await dispatch(saveRequest(item.uid, collection.uid, true));
|
||||
await dispatch(saveRequest(item.uid, collectionUid, true));
|
||||
}
|
||||
const { name: newName, filename: newFilename } = values;
|
||||
try {
|
||||
let renameConfig = {
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
collectionUid,
|
||||
};
|
||||
renameConfig['newName'] = newName;
|
||||
if (itemFilename !== newFilename) {
|
||||
@@ -191,8 +191,6 @@ const RenameCollectionItem = ({ collection, item, onClose }) => {
|
||||
) : (
|
||||
<div className='relative flex flex-row gap-1 items-center justify-between'>
|
||||
<PathDisplay
|
||||
collection={collection}
|
||||
dirName={path.relative(collection?.pathname, path.dirname(item?.pathname))}
|
||||
baseName={formik.values.filename}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -2,16 +2,19 @@ import React from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { uuid } from 'utils/common';
|
||||
import Modal from 'components/Modal';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { addTab } from 'providers/ReduxStore/slices/tabs';
|
||||
import { runCollectionFolder } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { flattenItems } from 'utils/collections';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { areItemsLoading } from 'utils/collections';
|
||||
|
||||
const RunCollectionItem = ({ collection, item, onClose }) => {
|
||||
const RunCollectionItem = ({ collectionUid, item, onClose }) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const collection = useSelector(state => state.collections.collections?.find(c => c.uid === collectionUid));
|
||||
const isCollectionRunInProgress = collection?.runnerResult?.info?.status && (collection?.runnerResult?.info?.status !== 'ended');
|
||||
|
||||
const onSubmit = (recursive) => {
|
||||
dispatch(
|
||||
addTab({
|
||||
@@ -20,10 +23,24 @@ const RunCollectionItem = ({ collection, item, onClose }) => {
|
||||
type: 'collection-runner'
|
||||
})
|
||||
);
|
||||
dispatch(runCollectionFolder(collection.uid, item ? item.uid : null, recursive));
|
||||
if (!isCollectionRunInProgress) {
|
||||
dispatch(runCollectionFolder(collection.uid, item ? item.uid : null, recursive));
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleViewRunner = (e) => {
|
||||
e.preventDefault();
|
||||
dispatch(
|
||||
addTab({
|
||||
uid: uuid(),
|
||||
collectionUid: collection.uid,
|
||||
type: 'collection-runner'
|
||||
})
|
||||
);
|
||||
onClose();
|
||||
}
|
||||
|
||||
const getRequestsCount = (items) => {
|
||||
const requestTypes = ['http-request', 'graphql-request']
|
||||
return items.filter(req => requestTypes.includes(req.type)).length;
|
||||
@@ -34,8 +51,6 @@ const RunCollectionItem = ({ collection, item, onClose }) => {
|
||||
const recursiveRunLength = getRequestsCount(flattenedItems);
|
||||
|
||||
const isFolderLoading = areItemsLoading(item);
|
||||
console.log(item);
|
||||
console.log(isFolderLoading);
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
@@ -55,22 +70,34 @@ const RunCollectionItem = ({ collection, item, onClose }) => {
|
||||
</div>
|
||||
<div className={isFolderLoading ? "mb-2" : "mb-8"}>This will run all the requests in this folder and all its subfolders.</div>
|
||||
{isFolderLoading ? <div className='mb-8 warning'>Requests in this folder are still loading.</div> : null}
|
||||
{isCollectionRunInProgress ? <div className='mb-6 warning'>A Collection Run is already in progress.</div> : null}
|
||||
<div className="flex justify-end bruno-modal-footer">
|
||||
<span className="mr-3">
|
||||
<button type="button" onClick={onClose} className="btn btn-md btn-close">
|
||||
Cancel
|
||||
</button>
|
||||
</span>
|
||||
<span>
|
||||
<button type="submit" disabled={!recursiveRunLength} className="submit btn btn-md btn-secondary mr-3" onClick={() => onSubmit(true)}>
|
||||
Recursive Run
|
||||
</button>
|
||||
</span>
|
||||
<span>
|
||||
<button type="submit" disabled={!runLength} className="submit btn btn-md btn-secondary" onClick={() => onSubmit(false)}>
|
||||
Run
|
||||
</button>
|
||||
</span>
|
||||
{
|
||||
isCollectionRunInProgress ?
|
||||
<span>
|
||||
<button type="submit" className="submit btn btn-md btn-secondary mr-3" onClick={handleViewRunner}>
|
||||
View Run
|
||||
</button>
|
||||
</span>
|
||||
:
|
||||
<>
|
||||
<span>
|
||||
<button type="submit" disabled={!recursiveRunLength} className="submit btn btn-md btn-secondary mr-3" onClick={() => onSubmit(true)}>
|
||||
Recursive Run
|
||||
</button>
|
||||
</span>
|
||||
<span>
|
||||
<button type="submit" disabled={!runLength} className="submit btn btn-md btn-secondary" onClick={() => onSubmit(false)}>
|
||||
Run
|
||||
</button>
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
position: relative;
|
||||
.menu-icon {
|
||||
color: ${(props) => props.theme.sidebar.dropdownIcon.color};
|
||||
|
||||
@@ -22,6 +23,65 @@ const Wrapper = styled.div`
|
||||
height: 1.875rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
position: relative;
|
||||
|
||||
/* Common styles for drop indicators */
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: ${(props) => props.theme.dragAndDrop.border};
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&::before {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
&::after {
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
/* Drop target styles */
|
||||
&.drop-target {
|
||||
background-color: ${(props) => props.theme.dragAndDrop.hoverBg};
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.drop-target-above {
|
||||
&::before {
|
||||
opacity: 1;
|
||||
height: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
&.drop-target-below {
|
||||
&::after {
|
||||
opacity: 1;
|
||||
height: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Inside drop target style */
|
||||
&.drop-target {
|
||||
&::before {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
height: 100%;
|
||||
opacity: 1;
|
||||
background: ${(props) => props.theme.dragAndDrop.hoverBg};
|
||||
border: ${(props) => props.theme.dragAndDrop.borderStyle} ${(props) => props.theme.dragAndDrop.border};
|
||||
// border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.rotate-90 {
|
||||
transform: rotateZ(90deg);
|
||||
@@ -45,6 +105,20 @@ const Wrapper = styled.div`
|
||||
}
|
||||
}
|
||||
|
||||
&.item-target {
|
||||
background: #ccc3;
|
||||
}
|
||||
|
||||
&.item-seperator {
|
||||
.seperator {
|
||||
bottom: 0px;
|
||||
position: absolute;
|
||||
height: 3px;
|
||||
width: 100%;
|
||||
background: #ccc3;
|
||||
}
|
||||
}
|
||||
|
||||
&.item-focused-in-tab {
|
||||
background: ${(props) => props.theme.sidebar.collection.item.bg};
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useRef, forwardRef, useEffect } from 'react';
|
||||
import { getEmptyImage } from 'react-dnd-html5-backend';
|
||||
import range from 'lodash/range';
|
||||
import filter from 'lodash/filter';
|
||||
import classnames from 'classnames';
|
||||
@@ -6,7 +7,7 @@ import { useDrag, useDrop } from 'react-dnd';
|
||||
import { IconChevronRight, IconDots } from '@tabler/icons';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { addTab, focusTab, makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
|
||||
import { moveItem, showInFolder, sendRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { handleCollectionItemDrop, sendRequest, showInFolder } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { collectionFolderClicked } from 'providers/ReduxStore/slices/collections';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import NewRequest from 'components/Sidebar/NewRequest';
|
||||
@@ -16,7 +17,7 @@ import CloneCollectionItem from './CloneCollectionItem';
|
||||
import DeleteCollectionItem from './DeleteCollectionItem';
|
||||
import RunCollectionItem from './RunCollectionItem';
|
||||
import GenerateCodeItem from './GenerateCodeItem';
|
||||
import { isItemARequest, isItemAFolder, itemIsOpenedInTabs } from 'utils/tabs';
|
||||
import { isItemARequest, isItemAFolder } from 'utils/tabs';
|
||||
import { doesRequestMatchSearchText, doesFolderHaveItemsMatchSearchText } from 'utils/collections/search';
|
||||
import { getDefaultRequestPaneTab } from 'utils/collections';
|
||||
import { hideHomePage } from 'providers/ReduxStore/slices/app';
|
||||
@@ -26,13 +27,22 @@ import NetworkError from 'components/ResponsePane/NetworkError/index';
|
||||
import CollectionItemInfo from './CollectionItemInfo/index';
|
||||
import CollectionItemIcon from './CollectionItemIcon';
|
||||
import { scrollToTheActiveTab } from 'utils/tabs';
|
||||
import { isTabForItemActive as isTabForItemActiveSelector, isTabForItemPresent as isTabForItemPresentSelector } from 'src/selectors/tab';
|
||||
import { isEqual } from 'lodash';
|
||||
import { calculateDraggedItemNewPathname } from 'utils/collections/index';
|
||||
|
||||
const CollectionItem = ({ item, collection, searchText }) => {
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
|
||||
const CollectionItem = ({ item, collectionUid, collectionPathname, searchText }) => {
|
||||
const _isTabForItemActiveSelector = isTabForItemActiveSelector({ itemUid: item.uid });
|
||||
const isTabForItemActive = useSelector(_isTabForItemActiveSelector, isEqual);
|
||||
|
||||
const _isTabForItemPresentSelector = isTabForItemPresentSelector({ itemUid: item.uid });
|
||||
const isTabForItemPresent = useSelector(_isTabForItemPresentSelector, isEqual);
|
||||
|
||||
const isSidebarDragging = useSelector((state) => state.app.isDragging);
|
||||
const dispatch = useDispatch();
|
||||
const collectionItemRef = useRef(null);
|
||||
|
||||
// We use a single ref for drag and drop.
|
||||
const ref = useRef(null);
|
||||
|
||||
const [renameItemModalOpen, setRenameItemModalOpen] = useState(false);
|
||||
const [cloneItemModalOpen, setCloneItemModalOpen] = useState(false);
|
||||
@@ -44,10 +54,13 @@ const CollectionItem = ({ item, collection, searchText }) => {
|
||||
const [itemInfoModalOpen, setItemInfoModalOpen] = useState(false);
|
||||
const hasSearchText = searchText && searchText?.trim()?.length;
|
||||
const itemIsCollapsed = hasSearchText ? false : item.collapsed;
|
||||
const isFolder = isItemAFolder(item);
|
||||
|
||||
const [{ isDragging }, drag] = useDrag({
|
||||
type: `collection-item-${collection.uid}`,
|
||||
item: item,
|
||||
const [dropType, setDropType] = useState(null); // 'adjacent' or 'inside'
|
||||
|
||||
const [{ isDragging }, drag, dragPreview] = useDrag({
|
||||
type: `collection-item-${collectionUid}`,
|
||||
item,
|
||||
collect: (monitor) => ({
|
||||
isDragging: monitor.isDragging()
|
||||
}),
|
||||
@@ -56,21 +69,72 @@ const CollectionItem = ({ item, collection, searchText }) => {
|
||||
}
|
||||
});
|
||||
|
||||
const [{ isOver }, drop] = useDrop({
|
||||
accept: `collection-item-${collection.uid}`,
|
||||
drop: (draggedItem) => {
|
||||
dispatch(moveItem(collection.uid, draggedItem.uid, item.uid));
|
||||
useEffect(() => {
|
||||
dragPreview(getEmptyImage(), { captureDraggingState: true });
|
||||
}, []);
|
||||
|
||||
const determineDropType = (monitor) => {
|
||||
const hoverBoundingRect = ref.current?.getBoundingClientRect();
|
||||
const clientOffset = monitor.getClientOffset();
|
||||
if (!hoverBoundingRect || !clientOffset) return null;
|
||||
|
||||
const clientY = clientOffset.y - hoverBoundingRect.top;
|
||||
const folderUpperThreshold = hoverBoundingRect.height * 0.35;
|
||||
const fileUpperThreshold = hoverBoundingRect.height * 0.5;
|
||||
|
||||
if (isItemAFolder(item)) {
|
||||
return clientY < folderUpperThreshold ? 'adjacent' : 'inside';
|
||||
} else {
|
||||
return clientY < fileUpperThreshold ? 'adjacent' : null;
|
||||
}
|
||||
};
|
||||
|
||||
const canItemBeDropped = ({ draggedItem, targetItem, dropType }) => {
|
||||
const { uid: targetItemUid, pathname: targetItemPathname } = targetItem;
|
||||
const { uid: draggedItemUid, pathname: draggedItemPathname } = draggedItem;
|
||||
|
||||
if (draggedItemUid === targetItemUid) return false;
|
||||
|
||||
const newPathname = calculateDraggedItemNewPathname({ draggedItem, targetItem, dropType, collectionPathname });
|
||||
if (!newPathname) return false;
|
||||
|
||||
if (targetItemPathname?.startsWith(draggedItemPathname)) return false;
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const [{ isOver, canDrop }, drop] = useDrop({
|
||||
accept: `collection-item-${collectionUid}`,
|
||||
hover: (draggedItem, monitor) => {
|
||||
const { uid: targetItemUid } = item;
|
||||
const { uid: draggedItemUid } = draggedItem;
|
||||
|
||||
if (draggedItemUid === targetItemUid) return;
|
||||
|
||||
const dropType = determineDropType(monitor);
|
||||
|
||||
const _canItemBeDropped = canItemBeDropped({ draggedItem, targetItem: item, dropType });
|
||||
|
||||
setDropType(_canItemBeDropped ? dropType : null);
|
||||
},
|
||||
canDrop: (draggedItem) => {
|
||||
return draggedItem.uid !== item.uid;
|
||||
drop: async (draggedItem, monitor) => {
|
||||
const { uid: targetItemUid } = item;
|
||||
const { uid: draggedItemUid } = draggedItem;
|
||||
|
||||
if (draggedItemUid === targetItemUid) return;
|
||||
|
||||
const dropType = determineDropType(monitor);
|
||||
if (!dropType) return;
|
||||
|
||||
await dispatch(handleCollectionItemDrop({ targetItem: item, draggedItem, dropType, collectionUid }))
|
||||
setDropType(null);
|
||||
},
|
||||
canDrop: (draggedItem) => draggedItem.uid !== item.uid,
|
||||
collect: (monitor) => ({
|
||||
isOver: monitor.isOver(),
|
||||
isOver: monitor.isOver()
|
||||
}),
|
||||
});
|
||||
|
||||
drag(drop(collectionItemRef));
|
||||
|
||||
const dropdownTippyRef = useRef();
|
||||
const MenuIcon = forwardRef((props, ref) => {
|
||||
return (
|
||||
@@ -84,13 +148,15 @@ const CollectionItem = ({ item, collection, searchText }) => {
|
||||
'rotate-90': !itemIsCollapsed
|
||||
});
|
||||
|
||||
const itemRowClassName = classnames('flex collection-item-name items-center', {
|
||||
'item-focused-in-tab': item.uid == activeTabUid,
|
||||
'item-hovered': isOver
|
||||
const itemRowClassName = classnames('flex collection-item-name relative items-center', {
|
||||
'item-focused-in-tab': isTabForItemActive,
|
||||
'item-hovered': isOver && canDrop,
|
||||
'drop-target': isOver && dropType === 'inside',
|
||||
'drop-target-above': isOver && dropType === 'adjacent'
|
||||
});
|
||||
|
||||
const handleRun = async () => {
|
||||
dispatch(sendRequest(item, collection.uid)).catch((err) =>
|
||||
dispatch(sendRequest(item, collectionUid)).catch((err) =>
|
||||
toast.custom((t) => <NetworkError onClose={() => toast.dismiss(t.id)} />, {
|
||||
duration: 5000
|
||||
})
|
||||
@@ -101,12 +167,10 @@ const CollectionItem = ({ item, collection, searchText }) => {
|
||||
if (event && event.detail != 1) return;
|
||||
//scroll to the active tab
|
||||
setTimeout(scrollToTheActiveTab, 50);
|
||||
|
||||
const isRequest = isItemARequest(item);
|
||||
|
||||
if (isRequest) {
|
||||
dispatch(hideHomePage());
|
||||
if (itemIsOpenedInTabs(item, tabs)) {
|
||||
if (isTabForItemPresent) {
|
||||
dispatch(
|
||||
focusTab({
|
||||
uid: item.uid
|
||||
@@ -114,11 +178,10 @@ const CollectionItem = ({ item, collection, searchText }) => {
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(
|
||||
addTab({
|
||||
uid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
collectionUid: collectionUid,
|
||||
requestPaneTab: getDefaultRequestPaneTab(item),
|
||||
type: 'request',
|
||||
})
|
||||
@@ -127,14 +190,14 @@ const CollectionItem = ({ item, collection, searchText }) => {
|
||||
dispatch(
|
||||
addTab({
|
||||
uid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
collectionUid: collectionUid,
|
||||
type: 'folder-settings',
|
||||
})
|
||||
);
|
||||
dispatch(
|
||||
collectionFolderClicked({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid
|
||||
collectionUid: collectionUid
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -146,10 +209,10 @@ const CollectionItem = ({ item, collection, searchText }) => {
|
||||
dispatch(
|
||||
collectionFolderClicked({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid
|
||||
collectionUid: collectionUid
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRightClick = (event) => {
|
||||
const _menuDropdown = dropdownTippyRef.current;
|
||||
@@ -164,7 +227,6 @@ const CollectionItem = ({ item, collection, searchText }) => {
|
||||
|
||||
let indents = range(item.depth);
|
||||
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
|
||||
const isFolder = isItemAFolder(item);
|
||||
|
||||
const className = classnames('flex flex-col w-full', {
|
||||
'is-sidebar-dragging': isSidebarDragging
|
||||
@@ -183,49 +245,14 @@ const CollectionItem = ({ item, collection, searchText }) => {
|
||||
}
|
||||
|
||||
const handleDoubleClick = (event) => {
|
||||
dispatch(makeTabPermanent({ uid: item.uid }))
|
||||
dispatch(makeTabPermanent({ uid: item.uid }));
|
||||
};
|
||||
|
||||
// we need to sort request items by seq property
|
||||
const sortRequestItems = (items = []) => {
|
||||
// Sort items by their "seq" property.
|
||||
const sortItemsBySequence = (items = []) => {
|
||||
return items.sort((a, b) => a.seq - b.seq);
|
||||
};
|
||||
|
||||
// we need to sort folder items by name alphabetically
|
||||
const sortFolderItems = (items = []) => {
|
||||
return items.sort((a, b) => a.name.localeCompare(b.name));
|
||||
};
|
||||
const handleGenerateCode = (e) => {
|
||||
e.stopPropagation();
|
||||
dropdownTippyRef.current.hide();
|
||||
if (item?.request?.url !== '' || (item?.draft?.request?.url !== undefined && item?.draft?.request?.url !== '')) {
|
||||
setGenerateCodeItemModalOpen(true);
|
||||
} else {
|
||||
toast.error('URL is required');
|
||||
}
|
||||
};
|
||||
|
||||
const viewFolderSettings = () => {
|
||||
if (isItemAFolder(item)) {
|
||||
if (itemIsOpenedInTabs(item, tabs)) {
|
||||
dispatch(
|
||||
focusTab({
|
||||
uid: item.uid
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
dispatch(
|
||||
addTab({
|
||||
uid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
type: 'folder-settings'
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const handleShowInFolder = () => {
|
||||
dispatch(showInFolder(item.pathname)).catch((error) => {
|
||||
console.error('Error opening the folder', error);
|
||||
@@ -233,62 +260,89 @@ const CollectionItem = ({ item, collection, searchText }) => {
|
||||
});
|
||||
};
|
||||
|
||||
const requestItems = sortRequestItems(filter(item.items, (i) => isItemARequest(i)));
|
||||
const folderItems = sortFolderItems(filter(item.items, (i) => isItemAFolder(i)));
|
||||
const folderItems = sortItemsBySequence(filter(item.items, (i) => isItemAFolder(i)));
|
||||
const requestItems = sortItemsBySequence(filter(item.items, (i) => isItemARequest(i)));
|
||||
|
||||
const handleGenerateCode = (e) => {
|
||||
e.stopPropagation();
|
||||
dropdownTippyRef.current.hide();
|
||||
if (
|
||||
(item?.request?.url !== '') ||
|
||||
(item?.draft?.request?.url !== undefined && item?.draft?.request?.url !== '')
|
||||
) {
|
||||
setGenerateCodeItemModalOpen(true);
|
||||
} else {
|
||||
toast.error('URL is required');
|
||||
}
|
||||
};
|
||||
|
||||
const viewFolderSettings = () => {
|
||||
if (isItemAFolder(item)) {
|
||||
if (isTabForItemPresent) {
|
||||
dispatch(focusTab({ uid: item.uid }));
|
||||
return;
|
||||
}
|
||||
dispatch(
|
||||
addTab({
|
||||
uid: item.uid,
|
||||
collectionUid,
|
||||
type: 'folder-settings'
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper className={className}>
|
||||
{renameItemModalOpen && (
|
||||
<RenameCollectionItem item={item} collection={collection} onClose={() => setRenameItemModalOpen(false)} />
|
||||
<RenameCollectionItem item={item} collectionUid={collectionUid} onClose={() => setRenameItemModalOpen(false)} />
|
||||
)}
|
||||
{cloneItemModalOpen && (
|
||||
<CloneCollectionItem item={item} collection={collection} onClose={() => setCloneItemModalOpen(false)} />
|
||||
<CloneCollectionItem item={item} collectionUid={collectionUid} onClose={() => setCloneItemModalOpen(false)} />
|
||||
)}
|
||||
{deleteItemModalOpen && (
|
||||
<DeleteCollectionItem item={item} collection={collection} onClose={() => setDeleteItemModalOpen(false)} />
|
||||
<DeleteCollectionItem item={item} collectionUid={collectionUid} onClose={() => setDeleteItemModalOpen(false)} />
|
||||
)}
|
||||
{newRequestModalOpen && (
|
||||
<NewRequest item={item} collection={collection} onClose={() => setNewRequestModalOpen(false)} />
|
||||
<NewRequest item={item} collectionUid={collectionUid} onClose={() => setNewRequestModalOpen(false)} />
|
||||
)}
|
||||
{newFolderModalOpen && (
|
||||
<NewFolder item={item} collection={collection} onClose={() => setNewFolderModalOpen(false)} />
|
||||
<NewFolder item={item} collectionUid={collectionUid} onClose={() => setNewFolderModalOpen(false)} />
|
||||
)}
|
||||
{runCollectionModalOpen && (
|
||||
<RunCollectionItem collection={collection} item={item} onClose={() => setRunCollectionModalOpen(false)} />
|
||||
<RunCollectionItem collectionUid={collectionUid} item={item} onClose={() => setRunCollectionModalOpen(false)} />
|
||||
)}
|
||||
{generateCodeItemModalOpen && (
|
||||
<GenerateCodeItem collection={collection} item={item} onClose={() => setGenerateCodeItemModalOpen(false)} />
|
||||
<GenerateCodeItem collectionUid={collectionUid} item={item} onClose={() => setGenerateCodeItemModalOpen(false)} />
|
||||
)}
|
||||
{itemInfoModalOpen && (
|
||||
<CollectionItemInfo item={item} collection={collection} onClose={() => setItemInfoModalOpen(false)} />
|
||||
<CollectionItemInfo item={item} onClose={() => setItemInfoModalOpen(false)} />
|
||||
)}
|
||||
<div className={itemRowClassName} ref={collectionItemRef}>
|
||||
<div
|
||||
className={itemRowClassName}
|
||||
ref={(node) => {
|
||||
ref.current = node;
|
||||
drag(drop(node));
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center h-full w-full">
|
||||
{indents && indents.length
|
||||
? indents.map((i) => {
|
||||
return (
|
||||
<div
|
||||
onClick={handleClick}
|
||||
onContextMenu={handleRightClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
className="indent-block"
|
||||
key={i}
|
||||
style={{
|
||||
width: 16,
|
||||
minWidth: 16,
|
||||
height: '100%'
|
||||
}}
|
||||
>
|
||||
{/* Indent */}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
? indents.map((i) => (
|
||||
<div
|
||||
onClick={handleClick}
|
||||
onContextMenu={handleRightClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
className="indent-block"
|
||||
key={i}
|
||||
style={{ width: 16, minWidth: 16, height: '100%' }}
|
||||
>
|
||||
{/* Indent */}
|
||||
</div>
|
||||
))
|
||||
: null}
|
||||
<div
|
||||
className="flex flex-grow items-center h-full overflow-hidden"
|
||||
style={{
|
||||
paddingLeft: 8
|
||||
}}
|
||||
style={{ paddingLeft: 8 }}
|
||||
onClick={handleClick}
|
||||
onContextMenu={handleRightClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
@@ -304,10 +358,7 @@ const CollectionItem = ({ item, collection, searchText }) => {
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="ml-1 flex w-full h-full items-center overflow-hidden"
|
||||
>
|
||||
<div className="ml-1 flex w-full h-full items-center overflow-hidden">
|
||||
<CollectionItemIcon item={item} />
|
||||
<span className="item-name" title={item.name}>
|
||||
{item.name}
|
||||
@@ -429,17 +480,16 @@ const CollectionItem = ({ item, collection, searchText }) => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!itemIsCollapsed ? (
|
||||
<div>
|
||||
{folderItems && folderItems.length
|
||||
? folderItems.map((i) => {
|
||||
return <CollectionItem key={i.uid} item={i} collection={collection} searchText={searchText} />;
|
||||
return <CollectionItem key={i.uid} item={i} collectionUid={collectionUid} collectionPathname={collectionPathname} searchText={searchText} />;
|
||||
})
|
||||
: null}
|
||||
{requestItems && requestItems.length
|
||||
? requestItems.map((i) => {
|
||||
return <CollectionItem key={i.uid} item={i} collection={collection} searchText={searchText} />;
|
||||
return <CollectionItem key={i.uid} item={i} collectionUid={collectionUid} collectionPathname={collectionPathname} searchText={searchText} />;
|
||||
})
|
||||
: null}
|
||||
</div>
|
||||
@@ -448,4 +498,4 @@ const CollectionItem = ({ item, collection, searchText }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default CollectionItem;
|
||||
export default React.memo(CollectionItem);
|
||||
@@ -1,7 +1,6 @@
|
||||
import React from 'react';
|
||||
import exportBrunoCollection from 'utils/collections/export';
|
||||
import exportPostmanCollection from 'utils/exporters/postman-collection';
|
||||
import { toastError } from 'utils/common/error';
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import Modal from 'components/Modal';
|
||||
import { transformCollectionToSaveToExportAsFile } from 'utils/collections/index';
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import React from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import Modal from 'components/Modal';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { IconFiles } from '@tabler/icons';
|
||||
import { removeCollection } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { findCollectionByUid } from 'utils/collections/index';
|
||||
|
||||
const RemoveCollection = ({ onClose, collection }) => {
|
||||
const RemoveCollection = ({ onClose, collectionUid }) => {
|
||||
const dispatch = useDispatch();
|
||||
const collection = useSelector(state => findCollectionByUid(state.collections.collections, collectionUid));
|
||||
|
||||
const onConfirm = () => {
|
||||
dispatch(removeCollection(collection.uid))
|
||||
|
||||
@@ -2,13 +2,15 @@ import React, { useRef, useEffect } from 'react';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import Modal from 'components/Modal';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import toast from 'react-hot-toast';
|
||||
import { renameCollection } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { findCollectionByUid } from 'utils/collections/index';
|
||||
|
||||
const RenameCollection = ({ collection, onClose }) => {
|
||||
const RenameCollection = ({ collectionUid, onClose }) => {
|
||||
const dispatch = useDispatch();
|
||||
const inputRef = useRef();
|
||||
const collection = useSelector(state => findCollectionByUid(state.collections.collections, collectionUid));
|
||||
const formik = useFormik({
|
||||
enableReinitialize: true,
|
||||
initialValues: {
|
||||
|
||||
@@ -13,7 +13,8 @@ const Wrapper = styled.div`
|
||||
}
|
||||
|
||||
&.item-hovered {
|
||||
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
|
||||
border-top: ${(props) => props.theme.dragAndDrop.borderStyle} ${(props) => props.theme.dragAndDrop.border};
|
||||
border-bottom: 2px solid transparent;
|
||||
.collection-actions {
|
||||
.dropdown {
|
||||
div[aria-expanded='false'] {
|
||||
@@ -62,6 +63,36 @@ const Wrapper = styled.div`
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
&.drop-target {
|
||||
background-color: ${(props) => props.theme.dragAndDrop.hoverBg};
|
||||
transition: ${(props) => props.theme.dragAndDrop.transition};
|
||||
}
|
||||
|
||||
&.drop-target-above {
|
||||
border: none;
|
||||
border-top: ${(props) => props.theme.dragAndDrop.borderStyle} ${(props) => props.theme.dragAndDrop.border};
|
||||
margin-top: -2px;
|
||||
background: transparent;
|
||||
transition: ${(props) => props.theme.dragAndDrop.transition};
|
||||
}
|
||||
|
||||
&.drop-target-below {
|
||||
border: none;
|
||||
border-bottom: ${(props) => props.theme.dragAndDrop.borderStyle} ${(props) => props.theme.dragAndDrop.border};
|
||||
margin-bottom: -2px;
|
||||
background: transparent;
|
||||
transition: ${(props) => props.theme.dragAndDrop.transition};
|
||||
}
|
||||
}
|
||||
|
||||
.collection-name.drop-target {
|
||||
border: ${(props) => props.theme.dragAndDrop.borderStyle} ${(props) => props.theme.dragAndDrop.border};
|
||||
border-radius: 4px;
|
||||
background-color: ${(props) => props.theme.dragAndDrop.hoverBg};
|
||||
margin: -2px;
|
||||
transition: ${(props) => props.theme.dragAndDrop.transition};
|
||||
box-shadow: 0 0 0 2px ${(props) => props.theme.dragAndDrop.hoverBg};
|
||||
}
|
||||
|
||||
#sidebar-collection-name {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, forwardRef, useRef, useEffect } from 'react';
|
||||
import { getEmptyImage } from 'react-dnd-html5-backend';
|
||||
import classnames from 'classnames';
|
||||
import { uuid } from 'utils/common';
|
||||
import filter from 'lodash/filter';
|
||||
@@ -6,8 +7,8 @@ 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, moveCollectionAndPersist } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { mountCollection, moveCollectionAndPersist, handleCollectionItemDrop } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { addTab, makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
|
||||
import NewRequest from 'components/Sidebar/NewRequest';
|
||||
import NewFolder from 'components/Sidebar/NewFolder';
|
||||
@@ -19,9 +20,10 @@ import { isItemAFolder, isItemARequest } from 'utils/collections';
|
||||
import RenameCollection from './RenameCollection';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import CloneCollection from './CloneCollection';
|
||||
import { areItemsLoading, findItemInCollection } from 'utils/collections';
|
||||
import { areItemsLoading } from 'utils/collections';
|
||||
import { scrollToTheActiveTab } from 'utils/tabs';
|
||||
import ShareCollection from 'components/ShareCollection/index';
|
||||
import { CollectionItemDragPreview } from './CollectionItem/CollectionItemDragPreview/index';
|
||||
|
||||
const Collection = ({ collection, searchText }) => {
|
||||
const [showNewFolderModal, setShowNewFolderModal] = useState(false);
|
||||
@@ -33,7 +35,7 @@ const Collection = ({ collection, searchText }) => {
|
||||
const dispatch = useDispatch();
|
||||
const isLoading = areItemsLoading(collection);
|
||||
const collectionRef = useRef(null);
|
||||
|
||||
|
||||
const menuDropdownTippyRef = useRef();
|
||||
const onMenuDropdownCreate = (ref) => (menuDropdownTippyRef.current = ref);
|
||||
const MenuIcon = forwardRef((props, ref) => {
|
||||
@@ -127,8 +129,8 @@ const Collection = ({ collection, searchText }) => {
|
||||
const isCollectionItem = (itemType) => {
|
||||
return itemType.startsWith('collection-item');
|
||||
};
|
||||
|
||||
const [{ isDragging }, drag] = useDrag({
|
||||
|
||||
const [{ isDragging }, drag, dragPreview] = useDrag({
|
||||
type: "collection",
|
||||
item: collection,
|
||||
collect: (monitor) => ({
|
||||
@@ -144,7 +146,7 @@ const Collection = ({ collection, searchText }) => {
|
||||
drop: (draggedItem, monitor) => {
|
||||
const itemType = monitor.getItemType();
|
||||
if (isCollectionItem(itemType)) {
|
||||
dispatch(moveItemToRootOfCollection(collection.uid, draggedItem.uid))
|
||||
dispatch(handleCollectionItemDrop({ targetItem: collection, draggedItem, dropType: 'inside', collectionUid: collection.uid }))
|
||||
} else {
|
||||
dispatch(moveCollectionAndPersist({draggedItem, targetItem: collection}));
|
||||
}
|
||||
@@ -157,7 +159,9 @@ const Collection = ({ collection, searchText }) => {
|
||||
}),
|
||||
});
|
||||
|
||||
drag(drop(collectionRef));
|
||||
useEffect(() => {
|
||||
dragPreview(getEmptyImage(), { captureDraggingState: true });
|
||||
}, []);
|
||||
|
||||
if (searchText && searchText.length) {
|
||||
if (!doesCollectionHaveItemsMatchingSearchText(collection, searchText)) {
|
||||
@@ -170,36 +174,35 @@ const Collection = ({ collection, searchText }) => {
|
||||
});
|
||||
|
||||
// we need to sort request items by seq property
|
||||
const sortRequestItems = (items = []) => {
|
||||
const sortItemsBySequence = (items = []) => {
|
||||
return items.sort((a, b) => a.seq - b.seq);
|
||||
};
|
||||
|
||||
// we need to sort folder items by name alphabetically
|
||||
const sortFolderItems = (items = []) => {
|
||||
return items.sort((a, b) => a.name.localeCompare(b.name));
|
||||
};
|
||||
|
||||
const requestItems = sortRequestItems(filter(collection.items, (i) => isItemARequest(i)));
|
||||
const folderItems = sortFolderItems(filter(collection.items, (i) => isItemAFolder(i)));
|
||||
const requestItems = sortItemsBySequence(filter(collection.items, (i) => isItemARequest(i)));
|
||||
const folderItems = sortItemsBySequence(filter(collection.items, (i) => isItemAFolder(i)));
|
||||
|
||||
return (
|
||||
<StyledWrapper className="flex flex-col">
|
||||
{showNewRequestModal && <NewRequest collection={collection} onClose={() => setShowNewRequestModal(false)} />}
|
||||
{showNewFolderModal && <NewFolder collection={collection} onClose={() => setShowNewFolderModal(false)} />}
|
||||
{showNewRequestModal && <NewRequest collectionUid={collection.uid} onClose={() => setShowNewRequestModal(false)} />}
|
||||
{showNewFolderModal && <NewFolder collectionUid={collection.uid} onClose={() => setShowNewFolderModal(false)} />}
|
||||
{showRenameCollectionModal && (
|
||||
<RenameCollection collection={collection} onClose={() => setShowRenameCollectionModal(false)} />
|
||||
<RenameCollection collectionUid={collection.uid} onClose={() => setShowRenameCollectionModal(false)} />
|
||||
)}
|
||||
{showRemoveCollectionModal && (
|
||||
<RemoveCollection collection={collection} onClose={() => setShowRemoveCollectionModal(false)} />
|
||||
<RemoveCollection collectionUid={collection.uid} onClose={() => setShowRemoveCollectionModal(false)} />
|
||||
)}
|
||||
{showShareCollectionModal && (
|
||||
<ShareCollection collection={collection} onClose={() => setShowShareCollectionModal(false)} />
|
||||
<ShareCollection collectionUid={collection.uid} onClose={() => setShowShareCollectionModal(false)} />
|
||||
)}
|
||||
{showCloneCollectionModalOpen && (
|
||||
<CloneCollection collection={collection} onClose={() => setShowCloneCollectionModalOpen(false)} />
|
||||
<CloneCollection collectionUid={collection.uid} onClose={() => setShowCloneCollectionModalOpen(false)} />
|
||||
)}
|
||||
<CollectionItemDragPreview />
|
||||
<div className={collectionRowClassName}
|
||||
ref={collectionRef}
|
||||
ref={(node) => {
|
||||
collectionRef.current = node;
|
||||
drag(drop(node));
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex flex-grow items-center overflow-hidden"
|
||||
@@ -296,20 +299,15 @@ const Collection = ({ collection, searchText }) => {
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{!collectionIsCollapsed ? (
|
||||
<div>
|
||||
{folderItems && folderItems.length
|
||||
? folderItems.map((i) => {
|
||||
return <CollectionItem key={i.uid} item={i} collection={collection} searchText={searchText} />;
|
||||
})
|
||||
: null}
|
||||
{requestItems && requestItems.length
|
||||
? requestItems.map((i) => {
|
||||
return <CollectionItem key={i.uid} item={i} collection={collection} searchText={searchText} />;
|
||||
})
|
||||
: null}
|
||||
{folderItems?.map?.((i) => {
|
||||
return <CollectionItem key={i.uid} item={i} collectionUid={collection.uid} collectionPathname={collection.pathname} searchText={searchText} />;
|
||||
})}
|
||||
{requestItems?.map?.((i) => {
|
||||
return <CollectionItem key={i.uid} item={i} collectionUid={collection.uid} collectionPathname={collection.pathname} searchText={searchText} />;
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,8 @@ import PathDisplay from 'components/PathDisplay/index';
|
||||
import { useState } from 'react';
|
||||
import { IconArrowBackUp, IconEdit } from '@tabler/icons';
|
||||
import Help from 'components/Help';
|
||||
import { multiLineMsg } from "utils/common";
|
||||
import { formatIpcError } from "utils/common/error";
|
||||
|
||||
const CreateCollection = ({ onClose }) => {
|
||||
const inputRef = useRef();
|
||||
@@ -45,7 +47,7 @@ const CreateCollection = ({ onClose }) => {
|
||||
toast.success('Collection created!');
|
||||
onClose();
|
||||
})
|
||||
.catch((e) => toast.error('An error occurred while creating the collection - ' + e));
|
||||
.catch((e) => toast.error(multiLineMsg('An error occurred while creating the collection', formatIpcError(e))));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -113,7 +115,6 @@ const CreateCollection = ({ onClose }) => {
|
||||
id="collection-location"
|
||||
type="text"
|
||||
name="collectionLocation"
|
||||
readOnly={true}
|
||||
className="block textbox mt-2 w-full cursor-pointer"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
@@ -121,6 +122,9 @@ const CreateCollection = ({ onClose }) => {
|
||||
spellCheck="false"
|
||||
value={formik.values.collectionLocation || ''}
|
||||
onClick={browse}
|
||||
onChange={e => {
|
||||
formik.setFieldValue('collectionLocation', e.target.value);
|
||||
}}
|
||||
/>
|
||||
{formik.touched.collectionLocation && formik.errors.collectionLocation ? (
|
||||
<div className="text-red-500">{formik.errors.collectionLocation}</div>
|
||||
|
||||
@@ -1,42 +1,43 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { IconLoader2 } from '@tabler/icons';
|
||||
import importBrunoCollection from 'utils/importers/bruno-collection';
|
||||
import importPostmanCollection from 'utils/importers/postman-collection';
|
||||
import { postmanToBruno, readFile } from 'utils/importers/postman-collection';
|
||||
import importInsomniaCollection from 'utils/importers/insomnia-collection';
|
||||
import importOpenapiCollection from 'utils/importers/openapi-collection';
|
||||
import { toastError } from 'utils/common/error';
|
||||
import Modal from 'components/Modal';
|
||||
import fileDialog from 'file-dialog';
|
||||
|
||||
const ImportCollection = ({ onClose, handleSubmit }) => {
|
||||
const [options, setOptions] = useState({
|
||||
enablePostmanTranslations: {
|
||||
enabled: true,
|
||||
label: 'Auto translate postman scripts',
|
||||
subLabel:
|
||||
"When enabled, Bruno will try as best to translate the scripts from the imported collection to Bruno's format."
|
||||
}
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const handleImportBrunoCollection = () => {
|
||||
importBrunoCollection()
|
||||
.then(({ collection }) => {
|
||||
handleSubmit({ collection });
|
||||
})
|
||||
.catch((err) => toastError(err, 'Import collection failed'));
|
||||
.catch((err) => toastError(err, 'Import collection failed'))
|
||||
};
|
||||
|
||||
|
||||
const handleImportPostmanCollection = () => {
|
||||
importPostmanCollection(options)
|
||||
.then(({ collection, translationLog }) => {
|
||||
handleSubmit({ collection, translationLog });
|
||||
fileDialog({ accept: 'application/json' })
|
||||
.then((...args) => {
|
||||
setIsLoading(true);
|
||||
return readFile(...args);
|
||||
})
|
||||
.catch((err) => toastError(err, 'Postman Import collection failed'));
|
||||
};
|
||||
.then((collection) => postmanToBruno(collection))
|
||||
.then((collection) => handleSubmit({ collection }))
|
||||
.catch((err) => toastError(err, 'Postman Import collection failed'))
|
||||
.finally(() => setIsLoading(false));
|
||||
}
|
||||
|
||||
const handleImportInsomniaCollection = () => {
|
||||
importInsomniaCollection()
|
||||
.then(({ collection }) => {
|
||||
handleSubmit({ collection });
|
||||
})
|
||||
.catch((err) => toastError(err, 'Insomnia Import collection failed'));
|
||||
.catch((err) => toastError(err, 'Insomnia Import collection failed'))
|
||||
};
|
||||
|
||||
const handleImportOpenapiCollection = () => {
|
||||
@@ -44,17 +45,9 @@ const ImportCollection = ({ onClose, handleSubmit }) => {
|
||||
.then(({ collection }) => {
|
||||
handleSubmit({ collection });
|
||||
})
|
||||
.catch((err) => toastError(err, 'OpenAPI v3 Import collection failed'));
|
||||
};
|
||||
const toggleOptions = (event, optionKey) => {
|
||||
setOptions({
|
||||
...options,
|
||||
[optionKey]: {
|
||||
...options[optionKey],
|
||||
enabled: !options[optionKey].enabled
|
||||
}
|
||||
});
|
||||
.catch((err) => toastError(err, 'OpenAPI v3 Import collection failed'))
|
||||
};
|
||||
|
||||
const CollectionButton = ({ children, className, onClick }) => {
|
||||
return (
|
||||
<button
|
||||
@@ -67,43 +60,67 @@ const ImportCollection = ({ onClose, handleSubmit }) => {
|
||||
</button>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<Modal size="sm" title="Import Collection" hideFooter={true} handleCancel={onClose}>
|
||||
<div className="flex flex-col">
|
||||
<h3 className="text-sm">Select the type of your existing collection :</h3>
|
||||
<div className="mt-4 grid grid-rows-2 grid-flow-col gap-2">
|
||||
<CollectionButton onClick={handleImportBrunoCollection}>Bruno Collection</CollectionButton>
|
||||
<CollectionButton onClick={handleImportPostmanCollection}>Postman Collection</CollectionButton>
|
||||
<CollectionButton onClick={handleImportInsomniaCollection}>Insomnia Collection</CollectionButton>
|
||||
<CollectionButton onClick={handleImportOpenapiCollection}>OpenAPI V3 Spec</CollectionButton>
|
||||
</div>
|
||||
<div className="flex justify-start w-full mt-4 max-w-[450px]">
|
||||
{Object.entries(options || {}).map(([key, option]) => (
|
||||
<div key={key} className="relative flex items-start">
|
||||
<div className="flex h-6 items-center">
|
||||
<input
|
||||
id="comments"
|
||||
aria-describedby="comments-description"
|
||||
name="comments"
|
||||
type="checkbox"
|
||||
checked={option.enabled}
|
||||
onChange={(e) => toggleOptions(e, key)}
|
||||
className="h-3.5 w-3.5 rounded border-zinc-300 dark:ring-offset-zinc-800 bg-transparent text-indigo-600 dark:text-indigo-500 focus:ring-indigo-600 dark:focus:ring-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-2 text-sm leading-6">
|
||||
<label htmlFor="comments" className="font-medium text-gray-900 dark:text-zinc-50">
|
||||
{option.label}
|
||||
</label>
|
||||
<p id="comments-description" className="text-zinc-500 text-xs dark:text-zinc-400">
|
||||
{option.subLabel}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
const FullscreenLoader = () => {
|
||||
const [loadingMessage, setLoadingMessage] = useState('');
|
||||
|
||||
// Messages to cycle through while loading
|
||||
const loadingMessages = [
|
||||
'Processing collection...',
|
||||
'Analyzing requests...',
|
||||
'Translating scripts...',
|
||||
'Preparing collection...',
|
||||
'Almost done...'
|
||||
];
|
||||
|
||||
|
||||
// Cycle through loading messages for better UX
|
||||
useEffect(() => {
|
||||
if (!isLoading) return;
|
||||
|
||||
let messageIndex = 0;
|
||||
const interval = setInterval(() => {
|
||||
messageIndex = (messageIndex + 1) % loadingMessages.length;
|
||||
setLoadingMessage(loadingMessages[messageIndex]);
|
||||
}, 2000);
|
||||
|
||||
setLoadingMessage(loadingMessages[0]);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isLoading]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-white/80 dark:bg-zinc-900/80 backdrop-blur-sm transition-all duration-300">
|
||||
<div className="flex flex-col items-center p-8 rounded-lg bg-white dark:bg-zinc-800 shadow-lg max-w-md text-center">
|
||||
<IconLoader2 className="animate-spin h-12 w-12 mb-4" strokeWidth={1.5} />
|
||||
<h3 className="text-lg font-medium text-zinc-900 dark:text-zinc-50 mb-2">
|
||||
{loadingMessage}
|
||||
</h3>
|
||||
<p className="text-sm text-zinc-500 dark:text-zinc-400">
|
||||
This may take a moment depending on the collection size
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLoading && <FullscreenLoader />}
|
||||
{!isLoading && (
|
||||
<Modal size="sm" title="Import Collection" hideFooter={true} handleCancel={onClose}>
|
||||
<div className="flex flex-col">
|
||||
<h3 className="text-sm">Select the type of your existing collection :</h3>
|
||||
<div className="mt-4 grid grid-rows-2 grid-flow-col gap-2">
|
||||
<CollectionButton onClick={handleImportBrunoCollection}>Bruno Collection</CollectionButton>
|
||||
<CollectionButton onClick={handleImportPostmanCollection}>Postman Collection</CollectionButton>
|
||||
<CollectionButton onClick={handleImportInsomniaCollection}>Insomnia Collection</CollectionButton>
|
||||
<CollectionButton onClick={handleImportOpenapiCollection}>OpenAPI V3 Spec</CollectionButton>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -4,105 +4,8 @@ import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import { browseDirectory } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import Modal from 'components/Modal';
|
||||
import { IconAlertTriangle, IconArrowRight, IconCaretDown, IconCaretRight, IconCopy } from '@tabler/icons';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const TranslationLog = ({ translationLog }) => {
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
const preventSetShowDetails = (e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setShowDetails(!showDetails);
|
||||
};
|
||||
const copyClipboard = (e, value) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
navigator.clipboard.writeText(value);
|
||||
toast.success('Copied to clipboard');
|
||||
};
|
||||
return (
|
||||
<div className="flex flex-col mt-2">
|
||||
<div className="border-l-2 border-amber-500 dark:border-amber-300 bg-amber-50 dark:bg-amber-50/10 p-1.5 rounded-r">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<IconAlertTriangle className="h-4 w-4 text-amber-500 dark:text-amber-300" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="ml-2">
|
||||
<p className="text-xs text-amber-700 dark:text-amber-300">
|
||||
<span className="font-semibold">Warning:</span> Some commands were not translated.{' '}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => preventSetShowDetails(e)}
|
||||
className="flex w-fit items-center rounded px-2.5 py-1 mt-2 text-xs font-semibold ring-1 ring-inset bg-slate-50 dark:bg-slate-400/10 text-slate-700 dark:text-slate-300 ring-slate-600/10 dark:ring-slate-400/20"
|
||||
>
|
||||
See details
|
||||
{showDetails ? <IconCaretDown size={16} className="ml-1" /> : <IconCaretRight size={16} className="ml-1" />}
|
||||
</button>
|
||||
{showDetails && (
|
||||
<div className="flex relative flex-col text-xs max-w-[364px] max-h-[300px] overflow-scroll mt-2 p-2 bg-slate-50 dark:bg-slate-400/10 ring-1 ring-inset rounded text-slate-700 dark:text-slate-300 ring-slate-600/20 dark:ring-slate-400/20">
|
||||
<span className="font-semibold flex items-center">
|
||||
Impacted Collections: {Object.keys(translationLog || {}).length}
|
||||
</span>
|
||||
<span className="font-semibold flex items-center">
|
||||
Impacted Lines:{' '}
|
||||
{Object.values(translationLog || {}).reduce(
|
||||
(acc, curr) => acc + (curr.script?.length || 0) + (curr.test?.length || 0),
|
||||
0
|
||||
)}
|
||||
</span>
|
||||
<span className="my-1">
|
||||
The numbers after 'script' and 'test' indicate the line numbers of incomplete translations.
|
||||
</span>
|
||||
<ul>
|
||||
{Object.entries(translationLog || {}).map(([name, value]) => (
|
||||
<li key={name} className="list-none text-xs font-semibold">
|
||||
<div className="font-semibold flex items-center text-xs whitespace-nowrap">
|
||||
<IconCaretRight className="min-w-4 max-w-4 -ml-1" />
|
||||
{name}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
{value.script && (
|
||||
<div className="flex items-center text-xs font-light mb-1 flex-wrap">
|
||||
<span className="mr-2">script :</span>
|
||||
{value.script.map((scriptValue, index) => (
|
||||
<span className="flex items-center" key={`script_${name}_${index}`}>
|
||||
<span className="text-xs font-light">{scriptValue}</span>
|
||||
{index < value.script.length - 1 && <> - </>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{value.test && (
|
||||
<div className="flex items-center text-xs font-light mb-1 flex-wrap">
|
||||
<span className="mr-2">test :</span>
|
||||
{value.test.map((testValue, index) => (
|
||||
<div className="flex items-center" key={`test_${name}_${index}`}>
|
||||
<span className="text-xs font-light">{testValue}</span>
|
||||
{index < value.test.length - 1 && <> - </>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<button
|
||||
className="absolute top-1 right-1 flex w-fit items-center rounded p-2 text-xs font-semibold ring-1 ring-inset bg-slate-50 dark:bg-slate-400/10 text-slate-700 dark:text-slate-300 ring-slate-600/10 dark:ring-slate-400/20"
|
||||
onClick={(e) => copyClipboard(e, JSON.stringify(translationLog))}
|
||||
>
|
||||
<IconCopy size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ImportCollectionLocation = ({ onClose, handleSubmit, collectionName, translationLog }) => {
|
||||
const ImportCollectionLocation = ({ onClose, handleSubmit, collectionName }) => {
|
||||
const inputRef = useRef();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
@@ -150,9 +53,6 @@ const ImportCollectionLocation = ({ onClose, handleSubmit, collectionName, trans
|
||||
Name
|
||||
</label>
|
||||
<div className="mt-2">{collectionName}</div>
|
||||
{translationLog && Object.keys(translationLog).length > 0 && (
|
||||
<TranslationLog translationLog={translationLog} />
|
||||
)}
|
||||
<>
|
||||
<label htmlFor="collectionLocation" className="block font-semibold mt-3">
|
||||
Location
|
||||
@@ -161,7 +61,6 @@ const ImportCollectionLocation = ({ onClose, handleSubmit, collectionName, trans
|
||||
id="collection-location"
|
||||
type="text"
|
||||
name="collectionLocation"
|
||||
readOnly={true}
|
||||
className="block textbox mt-2 w-full cursor-pointer"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
@@ -169,6 +68,9 @@ const ImportCollectionLocation = ({ onClose, handleSubmit, collectionName, trans
|
||||
spellCheck="false"
|
||||
value={formik.values.collectionLocation || ''}
|
||||
onClick={browse}
|
||||
onChange={e => {
|
||||
formik.setFieldValue('collectionLocation', e.target.value);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
{formik.touched.collectionLocation && formik.errors.collectionLocation ? (
|
||||
|
||||
@@ -14,7 +14,7 @@ import Dropdown from "components/Dropdown";
|
||||
import { IconCaretDown } from "@tabler/icons";
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const NewFolder = ({ collection, item, onClose }) => {
|
||||
const NewFolder = ({ collectionUid, item, onClose }) => {
|
||||
const dispatch = useDispatch();
|
||||
const inputRef = useRef();
|
||||
const [isEditing, toggleEditing] = useState(false);
|
||||
@@ -52,7 +52,7 @@ const NewFolder = ({ collection, item, onClose }) => {
|
||||
})
|
||||
}),
|
||||
onSubmit: (values) => {
|
||||
dispatch(newFolder(values.folderName, values.directoryName, collection.uid, item ? item.uid : null))
|
||||
dispatch(newFolder(values.folderName, values.directoryName, collectionUid, item ? item.uid : null))
|
||||
.then(() => {
|
||||
toast.success('New folder created!');
|
||||
onClose();
|
||||
|
||||
@@ -5,7 +5,7 @@ import toast from 'react-hot-toast';
|
||||
import path from 'utils/common/path';
|
||||
import { uuid } from 'utils/common';
|
||||
import Modal from 'components/Modal';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { newEphemeralHttpRequest } from 'providers/ReduxStore/slices/collections';
|
||||
import { newHttpRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { addTab } from 'providers/ReduxStore/slices/tabs';
|
||||
@@ -20,9 +20,11 @@ import Portal from 'components/Portal';
|
||||
import Help from 'components/Help';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
|
||||
const NewRequest = ({ collectionUid, item, isEphemeral, onClose }) => {
|
||||
const dispatch = useDispatch();
|
||||
const inputRef = useRef();
|
||||
|
||||
const collection = useSelector(state => state.collections.collections?.find(c => c.uid === collectionUid));
|
||||
const {
|
||||
brunoConfig: { presets: collectionPresets = {} }
|
||||
} = collection;
|
||||
@@ -135,14 +137,14 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
|
||||
requestType: values.requestType,
|
||||
requestUrl: values.requestUrl,
|
||||
requestMethod: values.requestMethod,
|
||||
collectionUid: collection.uid
|
||||
collectionUid: collectionUid
|
||||
})
|
||||
)
|
||||
.then(() => {
|
||||
dispatch(
|
||||
addTab({
|
||||
uid: uid,
|
||||
collectionUid: collection.uid,
|
||||
collectionUid: collectionUid,
|
||||
requestPaneTab: getDefaultRequestPaneTab({ type: values.requestType })
|
||||
})
|
||||
);
|
||||
@@ -158,7 +160,7 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
|
||||
requestType: curlRequestTypeDetected,
|
||||
requestUrl: request.url,
|
||||
requestMethod: request.method,
|
||||
collectionUid: collection.uid,
|
||||
collectionUid: collectionUid,
|
||||
itemUid: item ? item.uid : null,
|
||||
headers: request.headers,
|
||||
body: request.body,
|
||||
@@ -178,7 +180,7 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
|
||||
requestType: values.requestType,
|
||||
requestUrl: values.requestUrl,
|
||||
requestMethod: values.requestMethod,
|
||||
collectionUid: collection.uid,
|
||||
collectionUid: collectionUid,
|
||||
itemUid: item ? item.uid : null
|
||||
})
|
||||
)
|
||||
@@ -389,8 +391,6 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
|
||||
) : (
|
||||
<div className='relative flex flex-row gap-1 items-center justify-between'>
|
||||
<PathDisplay
|
||||
collection={collection}
|
||||
dirName={path.relative(collection?.pathname, item?.pathname || collection?.pathname)}
|
||||
baseName={formik.values.filename? `${formik.values.filename}.bru` : ''}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -11,21 +11,19 @@ import { useDispatch } from 'react-redux';
|
||||
import { showHomePage } from 'providers/ReduxStore/slices/app';
|
||||
import { openCollection, importCollection } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { multiLineMsg } from "utils/common";
|
||||
import { formatIpcError } from "utils/common/error";
|
||||
|
||||
const TitleBar = () => {
|
||||
const [importedCollection, setImportedCollection] = useState(null);
|
||||
const [importedTranslationLog, setImportedTranslationLog] = useState({});
|
||||
const [createCollectionModalOpen, setCreateCollectionModalOpen] = useState(false);
|
||||
const [importCollectionModalOpen, setImportCollectionModalOpen] = useState(false);
|
||||
const [importCollectionLocationModalOpen, setImportCollectionLocationModalOpen] = useState(false);
|
||||
const dispatch = useDispatch();
|
||||
const { ipcRenderer } = window;
|
||||
|
||||
const handleImportCollection = ({ collection, translationLog }) => {
|
||||
const handleImportCollection = ({ collection }) => {
|
||||
setImportedCollection(collection);
|
||||
if (translationLog) {
|
||||
setImportedTranslationLog(translationLog);
|
||||
}
|
||||
setImportCollectionModalOpen(false);
|
||||
setImportCollectionLocationModalOpen(true);
|
||||
};
|
||||
@@ -38,9 +36,8 @@ const TitleBar = () => {
|
||||
toast.success('Collection imported successfully');
|
||||
})
|
||||
.catch((err) => {
|
||||
setImportCollectionLocationModalOpen(false);
|
||||
console.error(err);
|
||||
toast.error('An error occurred while importing the collection. Check the logs for more information.');
|
||||
toast.error(multiLineMsg('An error occurred while importing the collection.', formatIpcError(err)));
|
||||
});
|
||||
};
|
||||
|
||||
@@ -75,7 +72,6 @@ const TitleBar = () => {
|
||||
{importCollectionLocationModalOpen ? (
|
||||
<ImportCollectionLocation
|
||||
collectionName={importedCollection.name}
|
||||
translationLog={importedTranslationLog}
|
||||
onClose={() => setImportCollectionLocationModalOpen(false)}
|
||||
handleSubmit={handleImportCollectionLocation}
|
||||
/>
|
||||
|
||||
@@ -15,7 +15,6 @@ const Welcome = () => {
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
const [importedCollection, setImportedCollection] = useState(null);
|
||||
const [importedTranslationLog, setImportedTranslationLog] = useState({});
|
||||
const [createCollectionModalOpen, setCreateCollectionModalOpen] = useState(false);
|
||||
const [importCollectionModalOpen, setImportCollectionModalOpen] = useState(false);
|
||||
const [importCollectionLocationModalOpen, setImportCollectionLocationModalOpen] = useState(false);
|
||||
@@ -24,11 +23,8 @@ const Welcome = () => {
|
||||
dispatch(openCollection()).catch((err) => console.log(err) && toast.error(t('WELCOME.COLLECTION_OPEN_ERROR')));
|
||||
};
|
||||
|
||||
const handleImportCollection = ({ collection, translationLog }) => {
|
||||
const handleImportCollection = ({ collection }) => {
|
||||
setImportedCollection(collection);
|
||||
if (translationLog) {
|
||||
setImportedTranslationLog(translationLog);
|
||||
}
|
||||
setImportCollectionModalOpen(false);
|
||||
setImportCollectionLocationModalOpen(true);
|
||||
};
|
||||
@@ -55,7 +51,6 @@ const Welcome = () => {
|
||||
) : null}
|
||||
{importCollectionLocationModalOpen ? (
|
||||
<ImportCollectionLocation
|
||||
translationLog={importedTranslationLog}
|
||||
collectionName={importedCollection.name}
|
||||
onClose={() => setImportCollectionLocationModalOpen(false)}
|
||||
handleSubmit={handleImportCollectionLocation}
|
||||
|
||||
@@ -13,12 +13,9 @@ import {
|
||||
findEnvironmentInCollection,
|
||||
findItemInCollection,
|
||||
findParentItemInCollection,
|
||||
getItemsToResequence,
|
||||
isItemAFolder,
|
||||
refreshUidsInItem,
|
||||
isItemARequest,
|
||||
moveCollectionItem,
|
||||
moveCollectionItemToRootOfCollection,
|
||||
transformRequestToSaveToFilesystem
|
||||
} from 'utils/collections';
|
||||
import { uuid, waitForNextTick } from 'utils/common';
|
||||
@@ -47,8 +44,7 @@ import { closeAllCollectionTabs } from 'providers/ReduxStore/slices/tabs';
|
||||
import { resolveRequestFilename } from 'utils/common/platform';
|
||||
import { parsePathParams, parseQueryParams, splitOnFirst } from 'utils/url/index';
|
||||
import { sendCollectionOauth2Request as _sendCollectionOauth2Request } from 'utils/network/index';
|
||||
import { getGlobalEnvironmentVariables } from 'utils/collections/index';
|
||||
import { findCollectionByPathname, findEnvironmentInCollectionByName } from 'utils/collections/index';
|
||||
import { getGlobalEnvironmentVariables, findCollectionByPathname, findEnvironmentInCollectionByName, getReorderedItemsInTargetDirectory, resetSequencesInFolder, getReorderedItemsInSourceDirectory, calculateDraggedItemNewPathname } from 'utils/collections/index';
|
||||
import { sanitizeName } from 'utils/common/regex';
|
||||
import { safeParseJSON, safeStringifyJSON } from 'utils/common/index';
|
||||
|
||||
@@ -60,7 +56,7 @@ export const renameCollection = (newName, collectionUid) => (dispatch, getState)
|
||||
if (!collection) {
|
||||
return reject(new Error('Collection not found'));
|
||||
}
|
||||
|
||||
const { ipcRenderer } = window;
|
||||
ipcRenderer.invoke('renderer:rename-collection', newName, collection.pathname).then(resolve).catch(reject);
|
||||
});
|
||||
};
|
||||
@@ -337,6 +333,7 @@ export const runCollectionFolder = (collectionUid, folderUid, recursive, delay)
|
||||
})
|
||||
);
|
||||
|
||||
const { ipcRenderer } = window;
|
||||
ipcRenderer
|
||||
.invoke(
|
||||
'renderer:run-collection-folder',
|
||||
@@ -358,6 +355,8 @@ export const runCollectionFolder = (collectionUid, folderUid, recursive, delay)
|
||||
export const newFolder = (folderName, directoryName, collectionUid, itemUid) => (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const collection = findCollectionByUid(state.collections.collections, collectionUid);
|
||||
const parentItem = itemUid ? findItemInCollection(collection, itemUid) : collection;
|
||||
const items = filter(parentItem.items, (i) => isItemAFolder(i) || isItemARequest(i));
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!collection) {
|
||||
@@ -372,10 +371,27 @@ export const newFolder = (folderName, directoryName, collectionUid, itemUid) =>
|
||||
if (!folderWithSameNameExists) {
|
||||
const fullName = path.join(collection.pathname, directoryName);
|
||||
const { ipcRenderer } = window;
|
||||
|
||||
ipcRenderer
|
||||
.invoke('renderer:new-folder', fullName, folderName)
|
||||
.then(() => resolve())
|
||||
.invoke('renderer:new-folder', fullName)
|
||||
.then(async () => {
|
||||
const folderData = {
|
||||
name: folderName,
|
||||
pathname: fullName,
|
||||
root: {
|
||||
meta: {
|
||||
name: folderName,
|
||||
seq: items?.length + 1
|
||||
}
|
||||
}
|
||||
};
|
||||
ipcRenderer
|
||||
.invoke('renderer:save-folder-root', folderData)
|
||||
.then(resolve)
|
||||
.catch((err) => {
|
||||
toast.error('Failed to save folder settings!');
|
||||
reject(err);
|
||||
});
|
||||
})
|
||||
.catch((error) => reject(error));
|
||||
} else {
|
||||
return reject(new Error('Duplicate folder names under same parent folder are not allowed'));
|
||||
@@ -392,8 +408,26 @@ export const newFolder = (folderName, directoryName, collectionUid, itemUid) =>
|
||||
const { ipcRenderer } = window;
|
||||
|
||||
ipcRenderer
|
||||
.invoke('renderer:new-folder', fullName, folderName)
|
||||
.then(() => resolve())
|
||||
.invoke('renderer:new-folder', fullName)
|
||||
.then(async () => {
|
||||
const folderData = {
|
||||
name: folderName,
|
||||
pathname: fullName,
|
||||
root: {
|
||||
meta: {
|
||||
name: folderName,
|
||||
seq: items?.length + 1
|
||||
}
|
||||
}
|
||||
};
|
||||
ipcRenderer
|
||||
.invoke('renderer:save-folder-root', folderData)
|
||||
.then(resolve)
|
||||
.catch((err) => {
|
||||
toast.error('Failed to save folder settings!');
|
||||
reject(err);
|
||||
});
|
||||
})
|
||||
.catch((error) => reject(error));
|
||||
} else {
|
||||
return reject(new Error('Duplicate folder names under same parent folder are not allowed'));
|
||||
@@ -495,8 +529,11 @@ export const cloneItem = (newName, newFilename, itemUid, collectionUid) => (disp
|
||||
set(item, 'name', newName);
|
||||
set(item, 'filename', newFilename);
|
||||
set(item, 'root.meta.name', newName);
|
||||
|
||||
set(item, 'root.meta.seq', parentFolder?.items?.length + 1);
|
||||
|
||||
const collectionPath = path.join(parentFolder.pathname, newFilename);
|
||||
|
||||
const { ipcRenderer } = window;
|
||||
ipcRenderer.invoke('renderer:clone-folder', item, collectionPath).then(resolve).catch(reject);
|
||||
return;
|
||||
}
|
||||
@@ -594,176 +631,114 @@ export const deleteItem = (itemUid, collectionUid) => (dispatch, getState) => {
|
||||
export const sortCollections = (payload) => (dispatch) => {
|
||||
dispatch(_sortCollections(payload));
|
||||
};
|
||||
export const moveItem = (collectionUid, draggedItemUid, targetItemUid) => (dispatch, getState) => {
|
||||
|
||||
export const moveItem = ({ targetDirname, sourcePathname }) => (dispatch, getState) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const { ipcRenderer } = window;
|
||||
|
||||
ipcRenderer.invoke('renderer:move-item', { targetDirname, sourcePathname })
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
});
|
||||
}
|
||||
|
||||
export const handleCollectionItemDrop = ({ targetItem, draggedItem, dropType, collectionUid }) => (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const collection = findCollectionByUid(state.collections.collections, collectionUid);
|
||||
const { uid: draggedItemUid, pathname: draggedItemPathname } = draggedItem;
|
||||
const { uid: targetItemUid, pathname: targetItemPathname } = targetItem;
|
||||
const targetItemDirectory = findParentItemInCollection(collection, targetItemUid) || collection;
|
||||
const targetItemDirectoryItems = cloneDeep(targetItemDirectory.items);
|
||||
const draggedItemDirectory = findParentItemInCollection(collection, draggedItemUid) || collection;
|
||||
const draggedItemDirectoryItems = cloneDeep(draggedItemDirectory.items);
|
||||
|
||||
const handleMoveToNewLocation = async ({ draggedItem, draggedItemDirectoryItems, targetItem, targetItemDirectoryItems, newPathname, dropType }) => {
|
||||
const { uid: targetItemUid } = targetItem;
|
||||
const { pathname: draggedItemPathname, uid: draggedItemUid } = draggedItem;
|
||||
|
||||
const newDirname = path.dirname(newPathname);
|
||||
await dispatch(moveItem({
|
||||
targetDirname: newDirname,
|
||||
sourcePathname: draggedItemPathname
|
||||
}));
|
||||
|
||||
// Update sequences in the source directory
|
||||
if (draggedItemDirectoryItems?.length) {
|
||||
// reorder items in the source directory
|
||||
const draggedItemDirectoryItemsWithoutDraggedItem = draggedItemDirectoryItems.filter(i => i.uid !== draggedItemUid);
|
||||
const reorderedSourceItems = getReorderedItemsInSourceDirectory({ items: draggedItemDirectoryItemsWithoutDraggedItem });
|
||||
if (reorderedSourceItems?.length) {
|
||||
await dispatch(updateItemsSequences({ itemsToResequence: reorderedSourceItems }));
|
||||
}
|
||||
}
|
||||
|
||||
// Update sequences in the target directory (if dropping adjacent)
|
||||
if (dropType === 'adjacent') {
|
||||
const targetItemSequence = targetItemDirectoryItems.findIndex(i => i.uid === targetItemUid)?.seq;
|
||||
|
||||
const draggedItemWithNewPathAndSequence = {
|
||||
...draggedItem,
|
||||
pathname: newPathname,
|
||||
seq: targetItemSequence
|
||||
};
|
||||
|
||||
// draggedItem is added to the targetItem's directory
|
||||
const reorderedTargetItems = getReorderedItemsInTargetDirectory({
|
||||
items: [ ...targetItemDirectoryItems, draggedItemWithNewPathAndSequence ],
|
||||
targetItemUid,
|
||||
draggedItemUid
|
||||
});
|
||||
|
||||
if (reorderedTargetItems?.length) {
|
||||
await dispatch(updateItemsSequences({ itemsToResequence: reorderedTargetItems }));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleReorderInSameLocation = async ({ draggedItem, targetItem, targetItemDirectoryItems }) => {
|
||||
const { uid: targetItemUid } = targetItem;
|
||||
const { uid: draggedItemUid } = draggedItem;
|
||||
|
||||
// reorder items in the targetItem's directory
|
||||
const reorderedItems = getReorderedItemsInTargetDirectory({
|
||||
items: targetItemDirectoryItems,
|
||||
targetItemUid,
|
||||
draggedItemUid
|
||||
});
|
||||
|
||||
if (reorderedItems?.length) {
|
||||
await dispatch(updateItemsSequences({ itemsToResequence: reorderedItems }));
|
||||
}
|
||||
};
|
||||
|
||||
return new Promise(async (resolve, reject) => {
|
||||
try {
|
||||
const newPathname = calculateDraggedItemNewPathname({ draggedItem, targetItem, dropType, collectionPathname: collection.pathname });
|
||||
if (!newPathname) return;
|
||||
if (targetItemPathname?.startsWith(draggedItemPathname)) return;
|
||||
if (newPathname !== draggedItemPathname) {
|
||||
await handleMoveToNewLocation({ targetItem, targetItemDirectoryItems, draggedItem, draggedItemDirectoryItems, newPathname, dropType });
|
||||
} else {
|
||||
await handleReorderInSameLocation({ draggedItem, targetItemDirectoryItems, targetItem });
|
||||
}
|
||||
resolve();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(error?.message);
|
||||
reject(error);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const updateItemsSequences = ({ itemsToResequence }) => (dispatch, getState) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!collection) {
|
||||
return reject(new Error('Collection not found'));
|
||||
}
|
||||
const { ipcRenderer } = window;
|
||||
|
||||
const collectionCopy = cloneDeep(collection);
|
||||
const draggedItem = findItemInCollection(collectionCopy, draggedItemUid);
|
||||
const targetItem = findItemInCollection(collectionCopy, targetItemUid);
|
||||
|
||||
if (!draggedItem) {
|
||||
return reject(new Error('Dragged item not found'));
|
||||
}
|
||||
|
||||
if (!targetItem) {
|
||||
return reject(new Error('Target item not found'));
|
||||
}
|
||||
|
||||
const draggedItemParent = findParentItemInCollection(collectionCopy, draggedItemUid);
|
||||
const targetItemParent = findParentItemInCollection(collectionCopy, targetItemUid);
|
||||
const sameParent = draggedItemParent === targetItemParent;
|
||||
|
||||
// file item dragged onto another file item and both are in the same folder
|
||||
// this is also true when both items are at the root level
|
||||
if (isItemARequest(draggedItem) && isItemARequest(targetItem) && sameParent) {
|
||||
moveCollectionItem(collectionCopy, draggedItem, targetItem);
|
||||
const itemsToResequence = getItemsToResequence(draggedItemParent, collectionCopy);
|
||||
|
||||
return ipcRenderer
|
||||
.invoke('renderer:resequence-items', itemsToResequence)
|
||||
.then(resolve)
|
||||
.catch((error) => reject(error));
|
||||
}
|
||||
|
||||
// file item dragged onto another file item which is at the root level
|
||||
if (isItemARequest(draggedItem) && isItemARequest(targetItem) && !targetItemParent) {
|
||||
const draggedItemPathname = draggedItem.pathname;
|
||||
moveCollectionItem(collectionCopy, draggedItem, targetItem);
|
||||
const itemsToResequence = getItemsToResequence(draggedItemParent, collectionCopy);
|
||||
const itemsToResequence2 = getItemsToResequence(targetItemParent, collectionCopy);
|
||||
|
||||
return ipcRenderer
|
||||
.invoke('renderer:move-file-item', draggedItemPathname, collectionCopy.pathname)
|
||||
.then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence))
|
||||
.then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence2))
|
||||
.then(resolve)
|
||||
.catch((error) => reject(error));
|
||||
}
|
||||
|
||||
// file item dragged onto another file item and both are in different folders
|
||||
if (isItemARequest(draggedItem) && isItemARequest(targetItem) && !sameParent) {
|
||||
const draggedItemPathname = draggedItem.pathname;
|
||||
moveCollectionItem(collectionCopy, draggedItem, targetItem);
|
||||
const itemsToResequence = getItemsToResequence(draggedItemParent, collectionCopy);
|
||||
const itemsToResequence2 = getItemsToResequence(targetItemParent, collectionCopy);
|
||||
|
||||
return ipcRenderer
|
||||
.invoke('renderer:move-file-item', draggedItemPathname, targetItemParent.pathname)
|
||||
.then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence))
|
||||
.then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence2))
|
||||
.then(resolve)
|
||||
.catch((error) => reject(error));
|
||||
}
|
||||
|
||||
// file item dragged into its own folder
|
||||
if (isItemARequest(draggedItem) && isItemAFolder(targetItem) && draggedItemParent === targetItem) {
|
||||
return resolve();
|
||||
}
|
||||
|
||||
// file item dragged into another folder
|
||||
if (isItemARequest(draggedItem) && isItemAFolder(targetItem) && draggedItemParent !== targetItem) {
|
||||
const draggedItemPathname = draggedItem.pathname;
|
||||
moveCollectionItem(collectionCopy, draggedItem, targetItem);
|
||||
const itemsToResequence = getItemsToResequence(draggedItemParent, collectionCopy);
|
||||
const itemsToResequence2 = getItemsToResequence(targetItem, collectionCopy);
|
||||
|
||||
return ipcRenderer
|
||||
.invoke('renderer:move-file-item', draggedItemPathname, targetItem.pathname)
|
||||
.then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence))
|
||||
.then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence2))
|
||||
.then(resolve)
|
||||
.catch((error) => reject(error));
|
||||
}
|
||||
|
||||
// end of the file drags, now let's handle folder drags
|
||||
// folder drags are simpler since we don't allow ordering of folders
|
||||
|
||||
// folder dragged into its own folder
|
||||
if (isItemAFolder(draggedItem) && isItemAFolder(targetItem) && draggedItemParent === targetItem) {
|
||||
return resolve();
|
||||
}
|
||||
|
||||
// folder dragged into a file which is at the same level
|
||||
// this is also true when both items are at the root level
|
||||
if (isItemAFolder(draggedItem) && isItemARequest(targetItem) && sameParent) {
|
||||
return resolve();
|
||||
}
|
||||
|
||||
// folder dragged into a file which is a child of the folder
|
||||
if (isItemAFolder(draggedItem) && isItemARequest(targetItem) && draggedItem === targetItemParent) {
|
||||
return resolve();
|
||||
}
|
||||
|
||||
// folder dragged into a file which is at the root level
|
||||
if (isItemAFolder(draggedItem) && isItemARequest(targetItem) && !targetItemParent) {
|
||||
const draggedItemPathname = draggedItem.pathname;
|
||||
|
||||
return ipcRenderer
|
||||
.invoke('renderer:move-folder-item', draggedItemPathname, collectionCopy.pathname)
|
||||
.then(resolve)
|
||||
.catch((error) => reject(error));
|
||||
}
|
||||
|
||||
// folder dragged into another folder
|
||||
if (isItemAFolder(draggedItem) && isItemAFolder(targetItem) && draggedItemParent !== targetItem) {
|
||||
const draggedItemPathname = draggedItem.pathname;
|
||||
|
||||
return ipcRenderer
|
||||
.invoke('renderer:move-folder-item', draggedItemPathname, targetItem.pathname)
|
||||
.then(resolve)
|
||||
.catch((error) => reject(error));
|
||||
}
|
||||
ipcRenderer.invoke('renderer:resequence-items', itemsToResequence)
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
});
|
||||
};
|
||||
|
||||
export const moveItemToRootOfCollection = (collectionUid, draggedItemUid) => (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const collection = findCollectionByUid(state.collections.collections, collectionUid);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!collection) {
|
||||
return reject(new Error('Collection not found'));
|
||||
}
|
||||
|
||||
const collectionCopy = cloneDeep(collection);
|
||||
const draggedItem = findItemInCollection(collectionCopy, draggedItemUid);
|
||||
if (!draggedItem) {
|
||||
return reject(new Error('Dragged item not found'));
|
||||
}
|
||||
|
||||
const draggedItemParent = findParentItemInCollection(collectionCopy, draggedItemUid);
|
||||
// file item is already at the root level
|
||||
if (!draggedItemParent) {
|
||||
return resolve();
|
||||
}
|
||||
|
||||
const draggedItemPathname = draggedItem.pathname;
|
||||
moveCollectionItemToRootOfCollection(collectionCopy, draggedItem);
|
||||
|
||||
if (isItemAFolder(draggedItem)) {
|
||||
return ipcRenderer
|
||||
.invoke('renderer:move-folder-item', draggedItemPathname, collectionCopy.pathname)
|
||||
.then(resolve)
|
||||
.catch((error) => reject(error));
|
||||
} else {
|
||||
const itemsToResequence = getItemsToResequence(draggedItemParent, collectionCopy);
|
||||
const itemsToResequence2 = getItemsToResequence(collectionCopy, collectionCopy);
|
||||
|
||||
return ipcRenderer
|
||||
.invoke('renderer:move-file-item', draggedItemPathname, collectionCopy.pathname)
|
||||
.then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence))
|
||||
.then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence2))
|
||||
.then(resolve)
|
||||
.catch((error) => reject(error));
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export const newHttpRequest = (params) => (dispatch, getState) => {
|
||||
const { requestName, filename, requestType, requestUrl, requestMethod, collectionUid, itemUid, headers, body, auth } = params;
|
||||
@@ -823,8 +798,8 @@ export const newHttpRequest = (params) => (dispatch, getState) => {
|
||||
collection.items,
|
||||
(i) => i.type !== 'folder' && trim(i.filename) === trim(resolvedFilename)
|
||||
);
|
||||
const requestItems = filter(collection.items, (i) => i.type !== 'folder');
|
||||
item.seq = requestItems.length + 1;
|
||||
const items = filter(collection.items, (i) => isItemAFolder(i) || isItemARequest(i));
|
||||
item.seq = items.length + 1;
|
||||
|
||||
if (!reqWithSameNameExists) {
|
||||
const fullName = path.join(collection.pathname, resolvedFilename);
|
||||
@@ -852,8 +827,8 @@ export const newHttpRequest = (params) => (dispatch, getState) => {
|
||||
currentItem.items,
|
||||
(i) => i.type !== 'folder' && trim(i.filename) === trim(resolvedFilename)
|
||||
);
|
||||
const requestItems = filter(currentItem.items, (i) => i.type !== 'folder');
|
||||
item.seq = requestItems.length + 1;
|
||||
const items = filter(currentItem.items, (i) => isItemAFolder(i) || isItemARequest(i));
|
||||
item.seq = items.length + 1;
|
||||
if (!reqWithSameNameExists) {
|
||||
const fullName = path.join(currentItem.pathname, resolvedFilename);
|
||||
const { ipcRenderer } = window;
|
||||
@@ -885,6 +860,7 @@ export const addEnvironment = (name, collectionUid) => (dispatch, getState) => {
|
||||
return reject(new Error('Collection not found'));
|
||||
}
|
||||
|
||||
const { ipcRenderer } = window;
|
||||
ipcRenderer
|
||||
.invoke('renderer:create-environment', collection.pathname, name)
|
||||
.then(
|
||||
@@ -913,6 +889,7 @@ export const importEnvironment = (name, variables, collectionUid) => (dispatch,
|
||||
|
||||
const sanitizedName = sanitizeName(name);
|
||||
|
||||
const { ipcRenderer } = window;
|
||||
ipcRenderer
|
||||
.invoke('renderer:create-environment', collection.pathname, sanitizedName, variables)
|
||||
.then(
|
||||
@@ -946,6 +923,7 @@ export const copyEnvironment = (name, baseEnvUid, collectionUid) => (dispatch, g
|
||||
|
||||
const sanitizedName = sanitizeName(name);
|
||||
|
||||
const { ipcRenderer } = window;
|
||||
ipcRenderer
|
||||
.invoke('renderer:create-environment', collection.pathname, sanitizedName, baseEnv.variables)
|
||||
.then(
|
||||
@@ -982,6 +960,7 @@ export const renameEnvironment = (newName, environmentUid, collectionUid) => (di
|
||||
const oldName = environment.name;
|
||||
environment.name = sanitizedName;
|
||||
|
||||
const { ipcRenderer } = window;
|
||||
environmentSchema
|
||||
.validate(environment)
|
||||
.then(() => ipcRenderer.invoke('renderer:rename-environment', collection.pathname, oldName, sanitizedName))
|
||||
@@ -1005,6 +984,7 @@ export const deleteEnvironment = (environmentUid, collectionUid) => (dispatch, g
|
||||
return reject(new Error('Environment not found'));
|
||||
}
|
||||
|
||||
const { ipcRenderer } = window;
|
||||
ipcRenderer
|
||||
.invoke('renderer:delete-environment', collection.pathname, environment.name)
|
||||
.then(resolve)
|
||||
@@ -1028,6 +1008,7 @@ export const saveEnvironment = (variables, environmentUid, collectionUid) => (di
|
||||
|
||||
environment.variables = variables;
|
||||
|
||||
const { ipcRenderer } = window;
|
||||
environmentSchema
|
||||
.validate(environment)
|
||||
.then(() => ipcRenderer.invoke('renderer:save-environment', collection.pathname, environment))
|
||||
@@ -1053,7 +1034,8 @@ export const selectEnvironment = (environmentUid, collectionUid) => (dispatch, g
|
||||
if (environmentUid && !environmentName) {
|
||||
return reject(new Error('Environment not found'));
|
||||
}
|
||||
|
||||
|
||||
const { ipcRenderer } = window;
|
||||
ipcRenderer.invoke('renderer:update-ui-state-snapshot', { type: 'COLLECTION_ENVIRONMENT', data: { collectionPath: collection?.pathname, environmentName }});
|
||||
|
||||
dispatch(_selectEnvironment({ environmentUid, collectionUid }));
|
||||
@@ -1112,11 +1094,13 @@ export const updateBrunoConfig = (brunoConfig, collectionUid) => (dispatch, getS
|
||||
const state = getState();
|
||||
|
||||
const collection = findCollectionByUid(state.collections.collections, collectionUid);
|
||||
if (!collection) {
|
||||
return reject(new Error('Collection not found'));
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!collection) {
|
||||
return reject(new Error('Collection not found'));
|
||||
}
|
||||
|
||||
const { ipcRenderer } = window;
|
||||
ipcRenderer
|
||||
.invoke('renderer:update-bruno-config', brunoConfig, collection.pathname, collectionUid)
|
||||
.then(resolve)
|
||||
@@ -1135,6 +1119,8 @@ export const openCollectionEvent = (uid, pathname, brunoConfig) => (dispatch, ge
|
||||
brunoConfig: brunoConfig
|
||||
};
|
||||
|
||||
const { ipcRenderer } = window;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
ipcRenderer.invoke('renderer:get-collection-security-config', pathname).then((securityConfig) => {
|
||||
collectionSchema
|
||||
@@ -1272,6 +1258,10 @@ export const hydrateCollectionWithUiStateSnapshot = (payload) => (dispatch, getS
|
||||
|
||||
export const fetchOauth2Credentials = (payload) => async (dispatch, getState) => {
|
||||
const { request, collection, itemUid, folderUid } = payload;
|
||||
const state = getState();
|
||||
const { globalEnvironments, activeGlobalEnvironmentUid } = state.globalEnvironments;
|
||||
const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid });
|
||||
request.globalEnvironmentVariables = globalEnvironmentVariables;
|
||||
return new Promise((resolve, reject) => {
|
||||
window.ipcRenderer
|
||||
.invoke('renderer:fetch-oauth2-credentials', { itemUid, request, collection })
|
||||
@@ -1295,6 +1285,10 @@ export const fetchOauth2Credentials = (payload) => async (dispatch, getState) =>
|
||||
|
||||
export const refreshOauth2Credentials = (payload) => async (dispatch, getState) => {
|
||||
const { request, collection, folderUid, itemUid } = payload;
|
||||
const state = getState();
|
||||
const { globalEnvironments, activeGlobalEnvironmentUid } = state.globalEnvironments;
|
||||
const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid });
|
||||
request.globalEnvironmentVariables = globalEnvironmentVariables;
|
||||
return new Promise((resolve, reject) => {
|
||||
window.ipcRenderer
|
||||
.invoke('renderer:refresh-oauth2-credentials', { request, collection })
|
||||
|
||||
@@ -1719,6 +1719,9 @@ export const collectionsSlice = createSlice({
|
||||
folderItem.name = file?.data?.meta?.name;
|
||||
}
|
||||
folderItem.root = file.data;
|
||||
if (file?.data?.meta?.seq) {
|
||||
folderItem.seq = file.data?.meta?.seq;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1795,9 +1798,10 @@ export const collectionsSlice = createSlice({
|
||||
currentPath = path.join(currentPath, directoryName);
|
||||
if (!childItem) {
|
||||
childItem = {
|
||||
uid: uuid(),
|
||||
uid: dir?.meta?.uid || uuid(),
|
||||
pathname: currentPath,
|
||||
name: dir?.meta?.name || directoryName,
|
||||
seq: dir?.meta?.seq || 1,
|
||||
filename: directoryName,
|
||||
collapsed: true,
|
||||
type: 'folder',
|
||||
@@ -1829,6 +1833,9 @@ export const collectionsSlice = createSlice({
|
||||
if (file?.data?.meta?.name) {
|
||||
folderItem.name = file?.data?.meta?.name;
|
||||
}
|
||||
if (file?.data?.meta?.seq) {
|
||||
folderItem.seq = file?.data?.meta?.seq;
|
||||
}
|
||||
folderItem.root = file.data;
|
||||
}
|
||||
return;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
import { stringifyIfNot, uuid } from 'utils/common/index';
|
||||
import { uuid } from 'utils/common/index';
|
||||
import { environmentSchema } from '@usebruno/schema';
|
||||
import { cloneDeep } from 'lodash';
|
||||
|
||||
@@ -90,6 +90,7 @@ export const {
|
||||
export const addGlobalEnvironment = ({ name, variables = [] }) => (dispatch, getState) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const uid = uuid();
|
||||
const { ipcRenderer } = window;
|
||||
ipcRenderer
|
||||
.invoke('renderer:create-global-environment', { name, uid, variables })
|
||||
.then(() => dispatch(_addGlobalEnvironment({ name, uid, variables })))
|
||||
@@ -104,6 +105,7 @@ export const copyGlobalEnvironment = ({ name, environmentUid: baseEnvUid }) => (
|
||||
const globalEnvironments = state.globalEnvironments.globalEnvironments;
|
||||
const baseEnv = globalEnvironments?.find(env => env?.uid == baseEnvUid)
|
||||
const uid = uuid();
|
||||
const { ipcRenderer } = window;
|
||||
ipcRenderer
|
||||
.invoke('renderer:create-global-environment', { uid, name, variables: baseEnv.variables })
|
||||
.then(() => dispatch(_copyGlobalEnvironment({ name, uid, variables: baseEnv.variables })))
|
||||
@@ -114,6 +116,7 @@ export const copyGlobalEnvironment = ({ name, environmentUid: baseEnvUid }) => (
|
||||
|
||||
export const renameGlobalEnvironment = ({ name: newName, environmentUid }) => (dispatch, getState) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const { ipcRenderer } = window;
|
||||
const state = getState();
|
||||
const globalEnvironments = state.globalEnvironments.globalEnvironments;
|
||||
const environment = globalEnvironments?.find(env => env?.uid == environmentUid)
|
||||
@@ -139,6 +142,7 @@ export const saveGlobalEnvironment = ({ variables, environmentUid }) => (dispatc
|
||||
return reject(new Error('Environment not found'));
|
||||
}
|
||||
|
||||
const { ipcRenderer } = window;
|
||||
environmentSchema
|
||||
.validate(environment)
|
||||
.then(() => ipcRenderer.invoke('renderer:save-global-environment', {
|
||||
@@ -155,6 +159,7 @@ export const saveGlobalEnvironment = ({ variables, environmentUid }) => (dispatc
|
||||
|
||||
export const selectGlobalEnvironment = ({ environmentUid }) => (dispatch, getState) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const { ipcRenderer } = window;
|
||||
ipcRenderer
|
||||
.invoke('renderer:select-global-environment', { environmentUid })
|
||||
.then(() => dispatch(_selectGlobalEnvironment({ environmentUid })))
|
||||
@@ -165,6 +170,7 @@ export const selectGlobalEnvironment = ({ environmentUid }) => (dispatch, getSta
|
||||
|
||||
export const deleteGlobalEnvironment = ({ environmentUid }) => (dispatch, getState) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const { ipcRenderer } = window;
|
||||
ipcRenderer
|
||||
.invoke('renderer:delete-global-environment', { environmentUid })
|
||||
.then(() => dispatch(_deleteGlobalEnvironment({ environmentUid })))
|
||||
@@ -175,6 +181,7 @@ export const deleteGlobalEnvironment = ({ environmentUid }) => (dispatch, getSta
|
||||
|
||||
export const globalEnvironmentsUpdateEvent = ({ globalEnvironmentVariables }) => (dispatch, getState) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const { ipcRenderer } = window;
|
||||
if (!globalEnvironmentVariables) resolve();
|
||||
|
||||
const state = getState();
|
||||
|
||||
@@ -7,9 +7,17 @@ export const ToastContext = React.createContext();
|
||||
export const ToastProvider = (props) => {
|
||||
const { storedTheme } = useTheme();
|
||||
|
||||
const toastOptions = { duration: 2000 };
|
||||
const toastOptions = {
|
||||
duration: 2000,
|
||||
style: {
|
||||
// Break long word like file-path, URL etc. to prevent overflow
|
||||
overflowWrap: 'anywhere'
|
||||
}
|
||||
};
|
||||
|
||||
if (storedTheme === 'dark') {
|
||||
toastOptions.style = {
|
||||
...toastOptions.style,
|
||||
borderRadius: '10px',
|
||||
background: '#3d3d3d',
|
||||
color: '#fff'
|
||||
|
||||
9
packages/bruno-app/src/selectors/tab.js
Normal file
9
packages/bruno-app/src/selectors/tab.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
|
||||
export const isTabForItemActive = ({ itemUid }) => createSelector([
|
||||
(state) => state.tabs?.activeTabUid
|
||||
], (activeTabUid) => activeTabUid === itemUid);
|
||||
|
||||
export const isTabForItemPresent = ({ itemUid }) => createSelector([
|
||||
(state) => state.tabs.tabs,
|
||||
], (tabs) => tabs.some((tab) => tab.uid === itemUid));
|
||||
@@ -281,6 +281,12 @@ const darkTheme = {
|
||||
color: 'rgb(52 51 49)'
|
||||
},
|
||||
|
||||
dragAndDrop: {
|
||||
border: '#666666',
|
||||
borderStyle: '2px solid',
|
||||
hoverBg: 'rgba(102, 102, 102, 0.08)',
|
||||
transition: 'all 0.1s ease'
|
||||
},
|
||||
infoTip: {
|
||||
bg: '#1f1f1f',
|
||||
border: '#333333',
|
||||
|
||||
@@ -282,6 +282,12 @@ const lightTheme = {
|
||||
color: 'rgb(152 151 149)'
|
||||
},
|
||||
|
||||
dragAndDrop: {
|
||||
border: '#8b8b8b', // Using the same gray as focusBorder from input
|
||||
borderStyle: '2px solid',
|
||||
hoverBg: 'rgba(139, 139, 139, 0.05)', // Matching the border color with reduced opacity
|
||||
transition: 'all 0.1s ease'
|
||||
},
|
||||
infoTip: {
|
||||
bg: 'white',
|
||||
border: '#e0e0e0',
|
||||
|
||||
@@ -6,10 +6,7 @@
|
||||
* LICENSE file at https://github.com/graphql/codemirror-graphql/tree/v0.8.3
|
||||
*/
|
||||
|
||||
// Todo: Fix this
|
||||
// import { interpolate } from '@usebruno/common';
|
||||
import brunoCommon from '@usebruno/common';
|
||||
const { interpolate } = brunoCommon;
|
||||
import { interpolate } from '@usebruno/common';
|
||||
|
||||
let CodeMirror;
|
||||
const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
* Copyright (C) 2017 by Marijn Haverbeke <marijnh@gmail.com> and others
|
||||
*/
|
||||
|
||||
import { JSHINT } from 'jshint';
|
||||
|
||||
let CodeMirror;
|
||||
const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
|
||||
|
||||
@@ -76,6 +78,18 @@ if (!SERVER_RENDERED) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/*
|
||||
* Filter out errors due to atob/btoa redefinition
|
||||
*
|
||||
* - W079: Redefinition of '{a}'
|
||||
* This JSHint warning triggers when a variable name conflicts with a built-in global.
|
||||
* We filter this for atob/btoa to allow explicit requires in Node.js environments
|
||||
* where these browser functions might not be available.
|
||||
*/
|
||||
if (error.code === 'W079' && (error.a === 'atob' || error.a === 'btoa')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import {cloneDeep, isEqual, sortBy, filter, map, isString, findIndex, find, each, get } from 'lodash';
|
||||
import { uuid } from 'utils/common';
|
||||
import path from 'utils/common/path';
|
||||
import brunoCommon from '@usebruno/common';
|
||||
const { interpolate } = brunoCommon;
|
||||
|
||||
const replaceTabsWithSpaces = (str, numSpaces = 2) => {
|
||||
if (!str || !str.length || !isString(str)) {
|
||||
@@ -100,6 +98,14 @@ export const findItemInCollectionByPathname = (collection, pathname) => {
|
||||
return findItemByPathname(flattenedItems, pathname);
|
||||
};
|
||||
|
||||
export const findParentItemInCollectionByPathname = (collection, pathname) => {
|
||||
let flattenedItems = flattenItems(collection.items);
|
||||
|
||||
return find(flattenedItems, (item) => {
|
||||
return item.items && find(item.items, (i) => i.pathname === pathname);
|
||||
});
|
||||
};
|
||||
|
||||
export const findItemInCollection = (collection, itemUid) => {
|
||||
let flattenedItems = flattenItems(collection.items);
|
||||
|
||||
@@ -152,90 +158,6 @@ export const getItemsLoadStats = (folder) => {
|
||||
};
|
||||
}
|
||||
|
||||
export const moveCollectionItem = (collection, draggedItem, targetItem) => {
|
||||
let draggedItemParent = findParentItemInCollection(collection, draggedItem.uid);
|
||||
|
||||
if (draggedItemParent) {
|
||||
draggedItemParent.items = sortBy(draggedItemParent.items, (item) => item.seq);
|
||||
draggedItemParent.items = filter(draggedItemParent.items, (i) => i.uid !== draggedItem.uid);
|
||||
draggedItem.pathname = path.join(draggedItemParent.pathname, draggedItem.filename);
|
||||
} else {
|
||||
collection.items = sortBy(collection.items, (item) => item.seq);
|
||||
collection.items = filter(collection.items, (i) => i.uid !== draggedItem.uid);
|
||||
}
|
||||
|
||||
if (targetItem.type === 'folder') {
|
||||
targetItem.items = sortBy(targetItem.items || [], (item) => item.seq);
|
||||
targetItem.items.push(draggedItem);
|
||||
draggedItem.pathname = path.join(targetItem.pathname, draggedItem.filename);
|
||||
} else {
|
||||
let targetItemParent = findParentItemInCollection(collection, targetItem.uid);
|
||||
|
||||
if (targetItemParent) {
|
||||
targetItemParent.items = sortBy(targetItemParent.items, (item) => item.seq);
|
||||
let targetItemIndex = findIndex(targetItemParent.items, (i) => i.uid === targetItem.uid);
|
||||
targetItemParent.items.splice(targetItemIndex + 1, 0, draggedItem);
|
||||
draggedItem.pathname = path.join(targetItemParent.pathname, draggedItem.filename);
|
||||
} else {
|
||||
collection.items = sortBy(collection.items, (item) => item.seq);
|
||||
let targetItemIndex = findIndex(collection.items, (i) => i.uid === targetItem.uid);
|
||||
collection.items.splice(targetItemIndex + 1, 0, draggedItem);
|
||||
draggedItem.pathname = path.join(collection.pathname, draggedItem.filename);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const moveCollectionItemToRootOfCollection = (collection, draggedItem) => {
|
||||
let draggedItemParent = findParentItemInCollection(collection, draggedItem.uid);
|
||||
|
||||
// If the dragged item is already at the root of the collection, do nothing
|
||||
if (!draggedItemParent) {
|
||||
return;
|
||||
}
|
||||
|
||||
draggedItemParent.items = sortBy(draggedItemParent.items, (item) => item.seq);
|
||||
draggedItemParent.items = filter(draggedItemParent.items, (i) => i.uid !== draggedItem.uid);
|
||||
collection.items = sortBy(collection.items, (item) => item.seq);
|
||||
collection.items.push(draggedItem);
|
||||
if (draggedItem.type == 'folder') {
|
||||
draggedItem.pathname = path.join(collection.pathname, draggedItem.name);
|
||||
} else {
|
||||
draggedItem.pathname = path.join(collection.pathname, draggedItem.filename);
|
||||
}
|
||||
};
|
||||
|
||||
export const getItemsToResequence = (parent, collection) => {
|
||||
let itemsToResequence = [];
|
||||
|
||||
if (!parent) {
|
||||
let index = 1;
|
||||
each(collection.items, (item) => {
|
||||
if (isItemARequest(item)) {
|
||||
itemsToResequence.push({
|
||||
pathname: item.pathname,
|
||||
seq: index++
|
||||
});
|
||||
}
|
||||
});
|
||||
return itemsToResequence;
|
||||
}
|
||||
|
||||
if (parent.items && parent.items.length) {
|
||||
let index = 1;
|
||||
each(parent.items, (item) => {
|
||||
if (isItemARequest(item)) {
|
||||
itemsToResequence.push({
|
||||
pathname: item.pathname,
|
||||
seq: index++
|
||||
});
|
||||
}
|
||||
});
|
||||
return itemsToResequence;
|
||||
}
|
||||
|
||||
return itemsToResequence;
|
||||
};
|
||||
|
||||
export const transformCollectionToSaveToExportAsFile = (collection, options = {}) => {
|
||||
const copyHeaders = (headers) => {
|
||||
return map(headers, (header) => {
|
||||
@@ -504,6 +426,7 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
|
||||
if (meta?.name) {
|
||||
di.root.meta = {};
|
||||
di.root.meta.name = meta?.name;
|
||||
di.root.meta.seq = meta?.seq;
|
||||
}
|
||||
if (!Object.keys(di.root.request)?.length) {
|
||||
delete di.root.request;
|
||||
@@ -1088,3 +1011,77 @@ export const getFormattedCollectionOauth2Credentials = ({ oauth2Credentials = []
|
||||
});
|
||||
return credentialsVariables;
|
||||
};
|
||||
|
||||
|
||||
// item sequence utils - START
|
||||
|
||||
export const resetSequencesInFolder = (folderItems) => {
|
||||
const items = folderItems;
|
||||
const sortedItems = items.sort((a, b) => a.seq - b.seq);
|
||||
return sortedItems.map((item, index) => {
|
||||
item.seq = index + 1;
|
||||
return item;
|
||||
});
|
||||
};
|
||||
|
||||
export const isItemBetweenSequences = (itemSequence, sourceItemSequence, targetItemSequence) => {
|
||||
if (targetItemSequence > sourceItemSequence) {
|
||||
return itemSequence > sourceItemSequence && itemSequence < targetItemSequence;
|
||||
}
|
||||
return itemSequence < sourceItemSequence && itemSequence >= targetItemSequence;
|
||||
};
|
||||
|
||||
export const calculateNewSequence = (isDraggedItem, targetSequence, draggedSequence) => {
|
||||
if (!isDraggedItem) {
|
||||
return null;
|
||||
}
|
||||
return targetSequence > draggedSequence ? targetSequence - 1 : targetSequence;
|
||||
};
|
||||
|
||||
export const getReorderedItemsInTargetDirectory = ({ items, targetItemUid, draggedItemUid }) => {
|
||||
const itemsWithFixedSequences = resetSequencesInFolder(cloneDeep(items));
|
||||
const targetItem = findItem(itemsWithFixedSequences, targetItemUid);
|
||||
const draggedItem = findItem(itemsWithFixedSequences, draggedItemUid);
|
||||
const targetSequence = targetItem?.seq;
|
||||
const draggedSequence = draggedItem?.seq;
|
||||
itemsWithFixedSequences?.forEach(item => {
|
||||
const isDraggedItem = item?.uid === draggedItemUid;
|
||||
const isBetween = isItemBetweenSequences(item?.seq, draggedSequence, targetSequence);
|
||||
if (isBetween) {
|
||||
item.seq += targetSequence > draggedSequence ? -1 : 1;
|
||||
}
|
||||
const newSequence = calculateNewSequence(isDraggedItem, targetSequence, draggedSequence);
|
||||
if (newSequence !== null) {
|
||||
item.seq = newSequence;
|
||||
}
|
||||
});
|
||||
// only return items that have been reordered
|
||||
return itemsWithFixedSequences.filter(item =>
|
||||
items?.find(originalItem => originalItem?.uid === item?.uid)?.seq !== item?.seq
|
||||
);
|
||||
};
|
||||
|
||||
export const getReorderedItemsInSourceDirectory = ({ items }) => {
|
||||
const itemsWithFixedSequences = resetSequencesInFolder(cloneDeep(items));
|
||||
return itemsWithFixedSequences.filter(item =>
|
||||
items?.find(originalItem => originalItem?.uid === item?.uid)?.seq !== item?.seq
|
||||
);
|
||||
};
|
||||
|
||||
export const calculateDraggedItemNewPathname = ({ draggedItem, targetItem, dropType, collectionPathname }) => {
|
||||
const { pathname: targetItemPathname } = targetItem;
|
||||
const { filename: draggedItemFilename } = draggedItem;
|
||||
const targetItemDirname = path.dirname(targetItemPathname);
|
||||
const isTargetTheCollection = targetItemPathname === collectionPathname;
|
||||
const isTargetItemAFolder = isItemAFolder(targetItem);
|
||||
|
||||
if (dropType === 'inside' && (isTargetItemAFolder || isTargetTheCollection)) {
|
||||
return path.join(targetItemPathname, draggedItemFilename)
|
||||
} else if (dropType === 'adjacent') {
|
||||
return path.join(targetItemDirname, draggedItemFilename)
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// item sequence utils - END
|
||||
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
class Cache {
|
||||
get(key) {
|
||||
return window.localStorage.getItem(key);
|
||||
}
|
||||
set(key, val) {
|
||||
window.localStorage.setItem(key, val);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new Cache();
|
||||
@@ -34,3 +34,11 @@ export const toastError = (error, defaultErrorMsg = 'An error occurred') => {
|
||||
|
||||
return toast.error(errorMsg);
|
||||
};
|
||||
|
||||
export function formatIpcError(error) {
|
||||
if (!(error instanceof Error)) return error;
|
||||
if (!error?.message) return ''; // Avoid returning `null` or `undefined`
|
||||
// https://github.com/electron/electron/blob/659e79fc08c6ffc2f7506dd1358918d97d240147/lib/renderer/api/ipc-renderer.ts#L24-L30
|
||||
// There is no other way to get rid of this error prefix as of now.
|
||||
return error.message.replace(/^Error invoking remote method '.+?': (Error: )?/, '');
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ export const safeStringifyJSON = (obj, indent = false) => {
|
||||
|
||||
export const convertToCodeMirrorJson = (obj) => {
|
||||
try {
|
||||
return JSON5.stringify(obj).slice(1, -1);
|
||||
return JSON.stringify(obj, null, 2).slice(1, -1);
|
||||
} catch (e) {
|
||||
return obj;
|
||||
}
|
||||
@@ -181,4 +181,8 @@ 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];
|
||||
}
|
||||
|
||||
export const multiLineMsg = (...messages) => {
|
||||
return messages.filter(m => m !== undefined && m !== null && m !== '').join('\n');
|
||||
}
|
||||
@@ -1,16 +1,16 @@
|
||||
const invalidCharacters = /[<>:"/\\|?*\x00-\x1F]/g; // replace invalid characters with hyphens
|
||||
const reservedDeviceNames = /^(CON|PRN|AUX|NUL|COM[0-9]|LPT[0-9])$/i;
|
||||
const firstCharacter = /^[^.\s\-\<>:"/\\|?*\x00-\x1F]/; // no dot, space, or hyphen at start
|
||||
const middleCharacters = /^[^<>:"/\\|?*\x00-\x1F]*$/; // no invalid characters
|
||||
const lastCharacter = /^[^.\s\-\<>:"/\\|?*\x00-\x1F]/; // no dot or space at end, hyphen allowed
|
||||
const firstCharacter = /^[^\s\-<>:"/\\|?*\x00-\x1F]/; // no space, hyphen and `invalidCharacters`
|
||||
const middleCharacters = /^[^<>:"/\\|?*\x00-\x1F]*$/; // no `invalidCharacters`
|
||||
const lastCharacter = /[^.\s<>:"/\\|?*\x00-\x1F]$/; // no dot, space and `invalidCharacters`
|
||||
|
||||
export const variableNameRegex = /^[\w-.]*$/;
|
||||
|
||||
export const sanitizeName = (name) => {
|
||||
name = name
|
||||
.replace(invalidCharacters, '-') // replace invalid characters with hyphens
|
||||
.replace(/^[.\s-]+/, '') // remove leading dots, hyphens and spaces
|
||||
.replace(/[.\s]+$/, ''); // remove trailing dots and spaces (keep trailing hyphens)
|
||||
.replace(invalidCharacters, '-') // replace invalid characters with hyphens
|
||||
.replace(/^[\s\-]+/, '') // remove leading spaces and hyphens
|
||||
.replace(/[.\s]+$/, ''); // remove trailing dots and spaces
|
||||
return name;
|
||||
};
|
||||
|
||||
|
||||
@@ -23,8 +23,8 @@ describe('regex validators', () => {
|
||||
});
|
||||
|
||||
it('should remove trailing periods', () => {
|
||||
expect(sanitizeName('.file')).toBe('file');
|
||||
expect(sanitizeName('.file.')).toBe('file');
|
||||
expect(sanitizeName('.file')).toBe('.file');
|
||||
expect(sanitizeName('.file.')).toBe('.file');
|
||||
expect(sanitizeName('file.')).toBe('file');
|
||||
expect(sanitizeName('file.name.')).toBe('file.name');
|
||||
expect(sanitizeName('hello world.')).toBe('hello world');
|
||||
@@ -83,11 +83,11 @@ describe('regex validators', () => {
|
||||
|
||||
it('should handle filenames with multiple consecutive periods (only remove trailing)', () => {
|
||||
expect(sanitizeName('file.name...')).toBe('file.name');
|
||||
expect(sanitizeName('...file')).toBe('file');
|
||||
expect(sanitizeName('...file')).toBe('...file');
|
||||
expect(sanitizeName('file.name... ')).toBe('file.name');
|
||||
expect(sanitizeName(' ...file')).toBe('file');
|
||||
expect(sanitizeName(' ...file ')).toBe('file');
|
||||
expect(sanitizeName(' ...file.... ')).toBe('file');
|
||||
expect(sanitizeName(' ...file')).toBe('...file');
|
||||
expect(sanitizeName(' ...file ')).toBe('...file');
|
||||
expect(sanitizeName(' ...file.... ')).toBe('...file');
|
||||
});
|
||||
|
||||
it('should handle very long filenames', () => {
|
||||
|
||||
@@ -1,340 +1,9 @@
|
||||
import map from 'lodash/map';
|
||||
import * as FileSaver from 'file-saver';
|
||||
import { deleteSecretsInEnvs, deleteUidsInEnvs, deleteUidsInItems } from '../collections/export';
|
||||
|
||||
/**
|
||||
* Transforms a given URL string into an object representing the protocol, host, path, query, and variables.
|
||||
*
|
||||
* @param {string} url - The raw URL to be transformed.
|
||||
* @param {Object} params - The params object.
|
||||
* @returns {Object|null} An object containing the URL's protocol, host, path, query, and variables, or {} if an error occurs.
|
||||
*/
|
||||
export const transformUrl = (url, params) => {
|
||||
if (typeof url !== 'string' || !url.trim()) {
|
||||
url = "";
|
||||
console.error("Invalid URL input:", url);
|
||||
}
|
||||
|
||||
const urlRegexPatterns = {
|
||||
protocolAndRestSeparator: /:\/\//,
|
||||
hostAndPathSeparator: /\/(.+)/,
|
||||
domainSegmentSeparator: /\./,
|
||||
pathSegmentSeparator: /\//,
|
||||
queryStringSeparator: /\?/
|
||||
};
|
||||
|
||||
const postmanUrl = { raw: url };
|
||||
|
||||
/**
|
||||
* Splits a URL into its protocol, host and path.
|
||||
*
|
||||
* @param {string} url - The URL to be split.
|
||||
* @returns {Object} An object containing the protocol and the raw host/path string.
|
||||
*/
|
||||
const splitUrl = (url) => {
|
||||
const urlParts = url.split(urlRegexPatterns.protocolAndRestSeparator);
|
||||
if (urlParts.length === 1) {
|
||||
return { protocol: '', rawHostAndPath: urlParts[0] };
|
||||
} else if (urlParts.length === 2) {
|
||||
const [hostAndPath, _] = urlParts[1].split(urlRegexPatterns.queryStringSeparator);
|
||||
return { protocol: urlParts[0], rawHostAndPath: hostAndPath };
|
||||
} else {
|
||||
throw new Error(`Invalid URL format: ${url}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Splits the host and path from a raw host/path string.
|
||||
*
|
||||
* @param {string} rawHostAndPath - The raw host and path string to be split.
|
||||
* @returns {Object} An object containing the host and path.
|
||||
*/
|
||||
const splitHostAndPath = (rawHostAndPath) => {
|
||||
const [host, path = ''] = rawHostAndPath.split(urlRegexPatterns.hostAndPathSeparator);
|
||||
return { host, path };
|
||||
};
|
||||
|
||||
try {
|
||||
const { protocol, rawHostAndPath } = splitUrl(url);
|
||||
postmanUrl.protocol = protocol;
|
||||
|
||||
const { host, path } = splitHostAndPath(rawHostAndPath);
|
||||
postmanUrl.host = host ? host.split(urlRegexPatterns.domainSegmentSeparator) : [];
|
||||
postmanUrl.path = path ? path.split(urlRegexPatterns.pathSegmentSeparator) : [];
|
||||
} catch (error) {
|
||||
console.error(error.message);
|
||||
return {};
|
||||
}
|
||||
|
||||
// Construct query params.
|
||||
postmanUrl.query = params
|
||||
.filter((param) => param.type === 'query')
|
||||
.map(({ name, value, description }) => ({ key: name, value, description }));
|
||||
|
||||
// Construct path params.
|
||||
postmanUrl.variable = params
|
||||
.filter((param) => param.type === 'path')
|
||||
.map(({ name, value, description }) => ({ key: name, value, description }));
|
||||
|
||||
return postmanUrl;
|
||||
};
|
||||
|
||||
/**
|
||||
* Collapses multiple consecutive slashes (`//`) into a single slash, while skipping the protocol (e.g., `http://` or `https://`).
|
||||
*
|
||||
* @param {String} url - A URL string
|
||||
* @returns {String} The sanitized URL
|
||||
*
|
||||
*/
|
||||
const collapseDuplicateSlashes = (url) => {
|
||||
return url.replace(/(?<!:)\/{2,}/g, '/');
|
||||
};
|
||||
|
||||
/**
|
||||
* Replaces all `\\` (backslashes) with `//` (forward slashes) and collapses multiple slashes into one.
|
||||
*
|
||||
* @param {string} url - The URL to sanitize.
|
||||
* @returns {string} The sanitized URL.
|
||||
*
|
||||
*/
|
||||
export const sanitizeUrl = (url) => {
|
||||
let sanitizedUrl = collapseDuplicateSlashes(url.replace(/\\/g, '//'));
|
||||
return sanitizedUrl;
|
||||
};
|
||||
import { brunoToPostman } from '@usebruno/converters';
|
||||
|
||||
export const exportCollection = (collection) => {
|
||||
delete collection.uid;
|
||||
delete collection.processEnvVariables;
|
||||
deleteUidsInItems(collection.items);
|
||||
deleteUidsInEnvs(collection.environments);
|
||||
deleteSecretsInEnvs(collection.environments);
|
||||
|
||||
const generateInfoSection = () => {
|
||||
return {
|
||||
name: collection.name,
|
||||
description: collection.root?.docs,
|
||||
schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json'
|
||||
};
|
||||
};
|
||||
|
||||
const generateCollectionVars = (collection) => {
|
||||
const pattern = /{{[^{}]+}}/g;
|
||||
let listOfVars = [];
|
||||
|
||||
const findOccurrences = (obj, results) => {
|
||||
if (typeof obj === 'object') {
|
||||
if (Array.isArray(obj)) {
|
||||
obj.forEach((item) => findOccurrences(item, results));
|
||||
} else {
|
||||
for (const key in obj) {
|
||||
findOccurrences(obj[key], results);
|
||||
}
|
||||
}
|
||||
} else if (typeof obj === 'string') {
|
||||
obj.replace(pattern, (match) => {
|
||||
results.push(match.replace(/{{|}}/g, ''));
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
findOccurrences(collection, listOfVars);
|
||||
|
||||
const finalArrayOfVars = [...new Set(listOfVars)];
|
||||
|
||||
return finalArrayOfVars.map((variable) => ({
|
||||
key: variable,
|
||||
value: '',
|
||||
type: 'default'
|
||||
}));
|
||||
};
|
||||
|
||||
const generateEventSection = (item) => {
|
||||
const eventArray = [];
|
||||
if (item?.request?.tests?.length) {
|
||||
eventArray.push({
|
||||
listen: 'test',
|
||||
script: {
|
||||
exec: item.request.tests.split('\n')
|
||||
// type: 'text/javascript'
|
||||
}
|
||||
});
|
||||
}
|
||||
if (item?.request?.script?.req) {
|
||||
eventArray.push({
|
||||
listen: 'prerequest',
|
||||
script: {
|
||||
exec: item.request.script.req.split('\n')
|
||||
// type: 'text/javascript'
|
||||
}
|
||||
});
|
||||
}
|
||||
return eventArray;
|
||||
};
|
||||
|
||||
const generateHeaders = (headersArray) => {
|
||||
return map(headersArray, (item) => {
|
||||
return {
|
||||
key: item.name,
|
||||
value: item.value,
|
||||
disabled: !item.enabled,
|
||||
type: 'default'
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const generateBody = (body) => {
|
||||
switch (body.mode) {
|
||||
case 'formUrlEncoded':
|
||||
return {
|
||||
mode: 'urlencoded',
|
||||
urlencoded: map(body.formUrlEncoded, (bodyItem) => {
|
||||
return {
|
||||
key: bodyItem.name,
|
||||
value: bodyItem.value,
|
||||
disabled: !bodyItem.enabled,
|
||||
type: 'default'
|
||||
};
|
||||
})
|
||||
};
|
||||
case 'multipartForm':
|
||||
return {
|
||||
mode: 'formdata',
|
||||
formdata: map(body.multipartForm, (bodyItem) => {
|
||||
return {
|
||||
key: bodyItem.name,
|
||||
value: bodyItem.value,
|
||||
disabled: !bodyItem.enabled,
|
||||
type: 'default'
|
||||
};
|
||||
})
|
||||
};
|
||||
case 'json':
|
||||
return {
|
||||
mode: 'raw',
|
||||
raw: body.json,
|
||||
options: {
|
||||
raw: {
|
||||
language: 'json'
|
||||
}
|
||||
}
|
||||
};
|
||||
case 'xml':
|
||||
return {
|
||||
mode: 'raw',
|
||||
raw: body.xml,
|
||||
options: {
|
||||
raw: {
|
||||
language: 'xml'
|
||||
}
|
||||
}
|
||||
};
|
||||
case 'text':
|
||||
return {
|
||||
mode: 'raw',
|
||||
raw: body.text,
|
||||
options: {
|
||||
raw: {
|
||||
language: 'text'
|
||||
}
|
||||
}
|
||||
};
|
||||
case 'graphql':
|
||||
return {
|
||||
mode: 'graphql',
|
||||
graphql: body.graphql
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const generateAuth = (itemAuth) => {
|
||||
switch (itemAuth?.mode) {
|
||||
case 'bearer':
|
||||
return {
|
||||
type: 'bearer',
|
||||
bearer: {
|
||||
key: 'token',
|
||||
value: itemAuth.bearer.token,
|
||||
type: 'string'
|
||||
}
|
||||
};
|
||||
case 'basic': {
|
||||
return {
|
||||
type: 'basic',
|
||||
basic: [
|
||||
{
|
||||
key: 'password',
|
||||
value: itemAuth.basic.password,
|
||||
type: 'string'
|
||||
},
|
||||
{
|
||||
key: 'username',
|
||||
value: itemAuth.basic.username,
|
||||
type: 'string'
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
case 'apikey': {
|
||||
return {
|
||||
type: 'apikey',
|
||||
apikey: [
|
||||
{
|
||||
key: 'key',
|
||||
value: itemAuth.apikey.key,
|
||||
type: 'string'
|
||||
},
|
||||
{
|
||||
key: 'value',
|
||||
value: itemAuth.apikey.value,
|
||||
type: 'string'
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
default: {
|
||||
return {
|
||||
type: 'noauth'
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const generateRequestSection = (itemRequest) => {
|
||||
const requestObject = {
|
||||
method: itemRequest.method,
|
||||
header: generateHeaders(itemRequest.headers),
|
||||
auth: generateAuth(itemRequest.auth),
|
||||
description: itemRequest.docs,
|
||||
// We sanitize the URL to make sure it's in the right format before passing it to the transformUrl func. This means changing backslashes to forward slashes and reducing multiple slashes to a single one, except in the protocol part.
|
||||
url: transformUrl(sanitizeUrl(itemRequest.url), itemRequest.params)
|
||||
};
|
||||
|
||||
if (itemRequest.body.mode !== 'none') {
|
||||
requestObject.body = generateBody(itemRequest.body);
|
||||
}
|
||||
return requestObject;
|
||||
};
|
||||
|
||||
const generateItemSection = (itemsArray) => {
|
||||
return map(itemsArray, (item) => {
|
||||
if (item.type === 'folder') {
|
||||
return {
|
||||
name: item.name,
|
||||
item: item.items.length ? generateItemSection(item.items) : []
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
name: item.name,
|
||||
event: generateEventSection(item),
|
||||
request: generateRequestSection(item.request)
|
||||
};
|
||||
}
|
||||
});
|
||||
};
|
||||
const collectionToExport = {};
|
||||
collectionToExport.info = generateInfoSection();
|
||||
collectionToExport.item = generateItemSection(collection.items);
|
||||
collectionToExport.variable = generateCollectionVars(collection);
|
||||
const collectionToExport = brunoToPostman(collection);
|
||||
|
||||
const fileName = `${collection.name}.json`;
|
||||
const fileBlob = new Blob([JSON.stringify(collectionToExport, null, 2)], { type: 'application/json' });
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
const { sanitizeUrl, transformUrl } = require('./postman-collection');
|
||||
|
||||
describe('transformUrl', () => {
|
||||
it('should handle basic URL with path variables', () => {
|
||||
const url = 'https://example.com/{{username}}/api/resource/:id';
|
||||
const params = [
|
||||
{ name: 'id', value: '123', type: 'path' },
|
||||
];
|
||||
|
||||
const result = transformUrl(url, params);
|
||||
|
||||
expect(result).toEqual({
|
||||
raw: 'https://example.com/{{username}}/api/resource/:id',
|
||||
protocol: 'https',
|
||||
host: ['example', 'com'],
|
||||
path: ['{{username}}', 'api', 'resource', ':id'],
|
||||
query: [],
|
||||
variable: [
|
||||
{ key: 'id', value: '123' },
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle URL with query parameters', () => {
|
||||
const url = 'https://example.com/api/resource?limit=10&offset=20';
|
||||
const params = [
|
||||
{ name: 'limit', value: '10', type: 'query' },
|
||||
{ name: 'offset', value: '20', type: 'query' }
|
||||
];
|
||||
|
||||
const result = transformUrl(url, params);
|
||||
|
||||
expect(result).toEqual({
|
||||
raw: 'https://example.com/api/resource?limit=10&offset=20',
|
||||
protocol: 'https',
|
||||
host: ['example', 'com'],
|
||||
path: ['api', 'resource'],
|
||||
query: [
|
||||
{ key: 'limit', value: '10' },
|
||||
{ key: 'offset', value: '20' }
|
||||
],
|
||||
variable: []
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle URL without protocol', () => {
|
||||
const url = 'example.com/api/resource';
|
||||
const params = [];
|
||||
|
||||
const result = transformUrl(url, params);
|
||||
|
||||
expect(result).toEqual({
|
||||
raw: 'example.com/api/resource',
|
||||
protocol: '',
|
||||
host: ['example', 'com'],
|
||||
path: ['api', 'resource'],
|
||||
query: [],
|
||||
variable: []
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeUrl', () => {
|
||||
it('should replace backslashes with slashes', () => {
|
||||
const input = 'http:\\\\example.com\\path\\to\\file';
|
||||
const expected = 'http://example.com/path/to/file';
|
||||
expect(sanitizeUrl(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it('should collapse multiple slashes into a single slash', () => {
|
||||
const input = 'http://example.com//path///to////file';
|
||||
const expected = 'http://example.com/path/to/file';
|
||||
expect(sanitizeUrl(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it('should handle URLs with mixed slashes', () => {
|
||||
const input = 'http:\\example.com//path\\to//file';
|
||||
const expected = 'http://example.com/path/to/file';
|
||||
expect(sanitizeUrl(input)).toBe(expected);
|
||||
});
|
||||
})
|
||||
@@ -2,7 +2,7 @@ import each from 'lodash/each';
|
||||
import get from 'lodash/get';
|
||||
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import { uuid, normalizeFileName } from 'utils/common';
|
||||
import { uuid } from 'utils/common';
|
||||
import { isItemARequest } from 'utils/collections';
|
||||
import { collectionSchema } from '@usebruno/schema';
|
||||
import { BrunoError } from 'utils/common/error';
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import jsyaml from 'js-yaml';
|
||||
import each from 'lodash/each';
|
||||
import get from 'lodash/get';
|
||||
import fileDialog from 'file-dialog';
|
||||
import { uuid } from 'utils/common';
|
||||
import { BrunoError } from 'utils/common/error';
|
||||
import { validateSchema, transformItemsInCollection, hydrateSeqInCollection } from './common';
|
||||
import { insomniaToBruno } from '@usebruno/converters';
|
||||
|
||||
const readFile = (files) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -30,226 +27,11 @@ const readFile = (files) => {
|
||||
});
|
||||
};
|
||||
|
||||
const parseGraphQL = (text) => {
|
||||
try {
|
||||
const graphql = JSON.parse(text);
|
||||
|
||||
return {
|
||||
query: graphql.query,
|
||||
variables: JSON.stringify(graphql.variables, null, 2)
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
query: '',
|
||||
variables: ''
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const addSuffixToDuplicateName = (item, index, allItems) => {
|
||||
// Check if the request name already exist and if so add a number suffix
|
||||
const nameSuffix = allItems.reduce((nameSuffix, otherItem, otherIndex) => {
|
||||
if (otherItem.name === item.name && otherIndex < index) {
|
||||
nameSuffix++;
|
||||
}
|
||||
return nameSuffix;
|
||||
}, 0);
|
||||
return nameSuffix !== 0 ? `${item.name}_${nameSuffix}` : item.name;
|
||||
};
|
||||
|
||||
const regexVariable = new RegExp('{{.*?}}', 'g');
|
||||
|
||||
const normalizeVariables = (value) => {
|
||||
value = value || '';
|
||||
const variables = value.match(regexVariable) || [];
|
||||
each(variables, (variable) => {
|
||||
value = value.replace(variable, variable.replace('_.', '').replaceAll(' ', ''));
|
||||
});
|
||||
return value;
|
||||
};
|
||||
|
||||
const transformInsomniaRequestItem = (request, index, allRequests) => {
|
||||
const name = addSuffixToDuplicateName(request, index, allRequests);
|
||||
|
||||
const brunoRequestItem = {
|
||||
uid: uuid(),
|
||||
name,
|
||||
type: 'http-request',
|
||||
request: {
|
||||
url: request.url,
|
||||
method: request.method,
|
||||
auth: {
|
||||
mode: 'none',
|
||||
basic: null,
|
||||
bearer: null,
|
||||
digest: null
|
||||
},
|
||||
headers: [],
|
||||
params: [],
|
||||
body: {
|
||||
mode: 'none',
|
||||
json: null,
|
||||
text: null,
|
||||
xml: null,
|
||||
formUrlEncoded: [],
|
||||
multipartForm: []
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
each(request.headers, (header) => {
|
||||
brunoRequestItem.request.headers.push({
|
||||
uid: uuid(),
|
||||
name: header.name,
|
||||
value: header.value,
|
||||
description: header.description,
|
||||
enabled: !header.disabled
|
||||
});
|
||||
});
|
||||
|
||||
each(request.parameters, (param) => {
|
||||
brunoRequestItem.request.params.push({
|
||||
uid: uuid(),
|
||||
name: param.name,
|
||||
value: param.value,
|
||||
description: param.description,
|
||||
type: 'query',
|
||||
enabled: !param.disabled
|
||||
});
|
||||
});
|
||||
|
||||
each(request.pathParameters, (param) => {
|
||||
brunoRequestItem.request.params.push({
|
||||
uid: uuid(),
|
||||
name: param.name,
|
||||
value: param.value,
|
||||
description: '',
|
||||
type: 'path',
|
||||
enabled: true
|
||||
});
|
||||
});
|
||||
|
||||
const authType = get(request, 'authentication.type', '');
|
||||
|
||||
if (authType === 'basic') {
|
||||
brunoRequestItem.request.auth.mode = 'basic';
|
||||
brunoRequestItem.request.auth.basic = {
|
||||
username: normalizeVariables(get(request, 'authentication.username', '')),
|
||||
password: normalizeVariables(get(request, 'authentication.password', ''))
|
||||
};
|
||||
} else if (authType === 'bearer') {
|
||||
brunoRequestItem.request.auth.mode = 'bearer';
|
||||
brunoRequestItem.request.auth.bearer = {
|
||||
token: normalizeVariables(get(request, 'authentication.token', ''))
|
||||
};
|
||||
}
|
||||
|
||||
const mimeType = get(request, 'body.mimeType', '').split(';')[0];
|
||||
|
||||
if (mimeType === 'application/json') {
|
||||
brunoRequestItem.request.body.mode = 'json';
|
||||
brunoRequestItem.request.body.json = request.body.text;
|
||||
} else if (mimeType === 'application/x-www-form-urlencoded') {
|
||||
brunoRequestItem.request.body.mode = 'formUrlEncoded';
|
||||
each(request.body.params, (param) => {
|
||||
brunoRequestItem.request.body.formUrlEncoded.push({
|
||||
uid: uuid(),
|
||||
name: param.name,
|
||||
value: param.value,
|
||||
description: param.description,
|
||||
enabled: !param.disabled
|
||||
});
|
||||
});
|
||||
} else if (mimeType === 'multipart/form-data') {
|
||||
brunoRequestItem.request.body.mode = 'multipartForm';
|
||||
each(request.body.params, (param) => {
|
||||
brunoRequestItem.request.body.multipartForm.push({
|
||||
uid: uuid(),
|
||||
type: 'text',
|
||||
name: param.name,
|
||||
value: param.value,
|
||||
description: param.description,
|
||||
enabled: !param.disabled
|
||||
});
|
||||
});
|
||||
} else if (mimeType === 'text/plain') {
|
||||
brunoRequestItem.request.body.mode = 'text';
|
||||
brunoRequestItem.request.body.text = request.body.text;
|
||||
} else if (mimeType === 'text/xml' || mimeType === 'application/xml') {
|
||||
brunoRequestItem.request.body.mode = 'xml';
|
||||
brunoRequestItem.request.body.xml = request.body.text;
|
||||
} else if (mimeType === 'application/graphql') {
|
||||
brunoRequestItem.type = 'graphql-request';
|
||||
brunoRequestItem.request.body.mode = 'graphql';
|
||||
brunoRequestItem.request.body.graphql = parseGraphQL(request.body.text);
|
||||
}
|
||||
|
||||
return brunoRequestItem;
|
||||
};
|
||||
|
||||
const parseInsomniaCollection = (data) => {
|
||||
const brunoCollection = {
|
||||
name: '',
|
||||
uid: uuid(),
|
||||
version: '1',
|
||||
items: [],
|
||||
environments: []
|
||||
};
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const insomniaExport = data;
|
||||
const insomniaResources = get(insomniaExport, 'resources', []);
|
||||
const insomniaCollection = insomniaResources.find((resource) => resource._type === 'workspace');
|
||||
|
||||
if (!insomniaCollection) {
|
||||
reject(new BrunoError('Collection not found inside Insomnia export'));
|
||||
}
|
||||
|
||||
brunoCollection.name = insomniaCollection.name;
|
||||
|
||||
const requestsAndFolders =
|
||||
insomniaResources.filter((resource) => resource._type === 'request' || resource._type === 'request_group') ||
|
||||
[];
|
||||
|
||||
function createFolderStructure(resources, parentId = null) {
|
||||
const requestGroups =
|
||||
resources.filter((resource) => resource._type === 'request_group' && resource.parentId === parentId) || [];
|
||||
const requests = resources.filter((resource) => resource._type === 'request' && resource.parentId === parentId);
|
||||
|
||||
const folders = requestGroups.map((folder, index, allFolder) => {
|
||||
const name = addSuffixToDuplicateName(folder, index, allFolder);
|
||||
const requests = resources.filter(
|
||||
(resource) => resource._type === 'request' && resource.parentId === folder._id
|
||||
);
|
||||
|
||||
return {
|
||||
uid: uuid(),
|
||||
name,
|
||||
type: 'folder',
|
||||
items: createFolderStructure(resources, folder._id).concat(requests.map(transformInsomniaRequestItem))
|
||||
};
|
||||
});
|
||||
|
||||
return folders.concat(requests.map(transformInsomniaRequestItem));
|
||||
}
|
||||
|
||||
(brunoCollection.items = createFolderStructure(requestsAndFolders, insomniaCollection._id)),
|
||||
resolve(brunoCollection);
|
||||
} catch (err) {
|
||||
reject(new BrunoError('An error occurred while parsing the Insomnia collection'));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const importCollection = () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
fileDialog({ accept: '.json, .yaml, .yml, application/json, application/yaml, application/x-yaml' })
|
||||
.then(readFile)
|
||||
.then(parseInsomniaCollection)
|
||||
.then(transformItemsInCollection)
|
||||
.then(hydrateSeqInCollection)
|
||||
.then(validateSchema)
|
||||
.then((collection) => insomniaToBruno(collection))
|
||||
.then((collection) => resolve({ collection }))
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import jsyaml from 'js-yaml';
|
||||
import each from 'lodash/each';
|
||||
import get from 'lodash/get';
|
||||
import fileDialog from 'file-dialog';
|
||||
import { uuid } from 'utils/common';
|
||||
import { BrunoError } from 'utils/common/error';
|
||||
import { validateSchema, transformItemsInCollection, hydrateSeqInCollection } from './common';
|
||||
import { openApiToBruno } from '@usebruno/converters';
|
||||
|
||||
const readFile = (files) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -30,435 +27,11 @@ const readFile = (files) => {
|
||||
});
|
||||
};
|
||||
|
||||
const ensureUrl = (url) => {
|
||||
// removing multiple slashes after the protocol if it exists, or after the beginning of the string otherwise
|
||||
return url.replace(/([^:])\/{2,}/g, '$1/');
|
||||
};
|
||||
|
||||
const buildEmptyJsonBody = (bodySchema) => {
|
||||
let _jsonBody = {};
|
||||
each(bodySchema.properties || {}, (prop, name) => {
|
||||
if (prop.type === 'object') {
|
||||
_jsonBody[name] = buildEmptyJsonBody(prop);
|
||||
} else if (prop.type === 'array') {
|
||||
if (prop.items && prop.items.type === 'object') {
|
||||
_jsonBody[name] = [buildEmptyJsonBody(prop.items)];
|
||||
} else {
|
||||
_jsonBody[name] = [];
|
||||
}
|
||||
} else {
|
||||
_jsonBody[name] = '';
|
||||
}
|
||||
});
|
||||
return _jsonBody;
|
||||
};
|
||||
|
||||
const transformOpenapiRequestItem = (request) => {
|
||||
let _operationObject = request.operationObject;
|
||||
|
||||
let operationName = _operationObject.summary || _operationObject.operationId || _operationObject.description;
|
||||
if (!operationName) {
|
||||
operationName = `${request.method} ${request.path}`;
|
||||
}
|
||||
|
||||
// replace OpenAPI links in path by Bruno variables
|
||||
let path = request.path.replace(/{([a-zA-Z]+)}/g, `{{${_operationObject.operationId}_$1}}`);
|
||||
|
||||
const brunoRequestItem = {
|
||||
uid: uuid(),
|
||||
name: operationName,
|
||||
type: 'http-request',
|
||||
request: {
|
||||
url: ensureUrl(request.global.server + path),
|
||||
method: request.method.toUpperCase(),
|
||||
auth: {
|
||||
mode: 'none',
|
||||
basic: null,
|
||||
bearer: null,
|
||||
digest: null
|
||||
},
|
||||
headers: [],
|
||||
params: [],
|
||||
body: {
|
||||
mode: 'none',
|
||||
json: null,
|
||||
text: null,
|
||||
xml: null,
|
||||
formUrlEncoded: [],
|
||||
multipartForm: []
|
||||
},
|
||||
script: {
|
||||
res: null
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
each(_operationObject.parameters || [], (param) => {
|
||||
if (param.in === 'query') {
|
||||
brunoRequestItem.request.params.push({
|
||||
uid: uuid(),
|
||||
name: param.name,
|
||||
value: '',
|
||||
description: param.description || '',
|
||||
enabled: param.required,
|
||||
type: 'query'
|
||||
});
|
||||
} else if (param.in === 'path') {
|
||||
brunoRequestItem.request.params.push({
|
||||
uid: uuid(),
|
||||
name: param.name,
|
||||
value: '',
|
||||
description: param.description || '',
|
||||
enabled: param.required,
|
||||
type: 'path'
|
||||
});
|
||||
} else if (param.in === 'header') {
|
||||
brunoRequestItem.request.headers.push({
|
||||
uid: uuid(),
|
||||
name: param.name,
|
||||
value: '',
|
||||
description: param.description || '',
|
||||
enabled: param.required
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
let auth;
|
||||
// allow operation override
|
||||
if (_operationObject.security && _operationObject.security.length > 0) {
|
||||
let schemeName = Object.keys(_operationObject.security[0])[0];
|
||||
auth = request.global.security.getScheme(schemeName);
|
||||
} else if (request.global.security.supported.length > 0) {
|
||||
auth = request.global.security.supported[0];
|
||||
}
|
||||
|
||||
if (auth) {
|
||||
if (auth.type === 'http' && auth.scheme === 'basic') {
|
||||
brunoRequestItem.request.auth.mode = 'basic';
|
||||
brunoRequestItem.request.auth.basic = {
|
||||
username: '{{username}}',
|
||||
password: '{{password}}'
|
||||
};
|
||||
} else if (auth.type === 'http' && auth.scheme === 'bearer') {
|
||||
brunoRequestItem.request.auth.mode = 'bearer';
|
||||
brunoRequestItem.request.auth.bearer = {
|
||||
token: '{{token}}'
|
||||
};
|
||||
} else if (auth.type === 'apiKey' && auth.in === 'header') {
|
||||
brunoRequestItem.request.headers.push({
|
||||
uid: uuid(),
|
||||
name: auth.name,
|
||||
value: '{{apiKey}}',
|
||||
description: 'Authentication header',
|
||||
enabled: true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: handle allOf/anyOf/oneOf
|
||||
if (_operationObject.requestBody) {
|
||||
let content = get(_operationObject, 'requestBody.content', {});
|
||||
let mimeType = Object.keys(content)[0];
|
||||
let body = content[mimeType] || {};
|
||||
let bodySchema = body.schema;
|
||||
if (mimeType === 'application/json') {
|
||||
brunoRequestItem.request.body.mode = 'json';
|
||||
if (bodySchema && bodySchema.type === 'object') {
|
||||
let _jsonBody = buildEmptyJsonBody(bodySchema);
|
||||
brunoRequestItem.request.body.json = JSON.stringify(_jsonBody, null, 2);
|
||||
}
|
||||
if (bodySchema && bodySchema.type === 'array') {
|
||||
brunoRequestItem.request.body.json = JSON.stringify([buildEmptyJsonBody(bodySchema.items)], null, 2);
|
||||
}
|
||||
} else if (mimeType === 'application/x-www-form-urlencoded') {
|
||||
brunoRequestItem.request.body.mode = 'formUrlEncoded';
|
||||
if (bodySchema && bodySchema.type === 'object') {
|
||||
each(bodySchema.properties || {}, (prop, name) => {
|
||||
brunoRequestItem.request.body.formUrlEncoded.push({
|
||||
uid: uuid(),
|
||||
name: name,
|
||||
value: '',
|
||||
description: prop.description || '',
|
||||
enabled: true
|
||||
});
|
||||
});
|
||||
}
|
||||
} else if (mimeType === 'multipart/form-data') {
|
||||
brunoRequestItem.request.body.mode = 'multipartForm';
|
||||
if (bodySchema && bodySchema.type === 'object') {
|
||||
each(bodySchema.properties || {}, (prop, name) => {
|
||||
brunoRequestItem.request.body.multipartForm.push({
|
||||
uid: uuid(),
|
||||
type: 'text',
|
||||
name: name,
|
||||
value: '',
|
||||
description: prop.description || '',
|
||||
enabled: true
|
||||
});
|
||||
});
|
||||
}
|
||||
} else if (mimeType === 'text/plain') {
|
||||
brunoRequestItem.request.body.mode = 'text';
|
||||
brunoRequestItem.request.body.text = '';
|
||||
} else if (mimeType === 'text/xml') {
|
||||
brunoRequestItem.request.body.mode = 'xml';
|
||||
brunoRequestItem.request.body.xml = '';
|
||||
}
|
||||
}
|
||||
|
||||
// build the extraction scripts from responses that have links
|
||||
// https://swagger.io/docs/specification/links/
|
||||
let script = [];
|
||||
each(_operationObject.responses || [], (response, responseStatus) => {
|
||||
if (Object.hasOwn(response, 'links')) {
|
||||
// only extract if the status code matches the response
|
||||
script.push(`if (res.status === ${responseStatus}) {`);
|
||||
each(response.links, (link) => {
|
||||
each(link.parameters || [], (expression, parameter) => {
|
||||
let value = openAPIRuntimeExpressionToScript(expression);
|
||||
script.push(` bru.setVar('${link.operationId}_${parameter}', ${value});`);
|
||||
});
|
||||
});
|
||||
script.push(`}`);
|
||||
}
|
||||
});
|
||||
if (script.length > 0) {
|
||||
brunoRequestItem.request.script.res = script.join('\n');
|
||||
}
|
||||
|
||||
return brunoRequestItem;
|
||||
};
|
||||
|
||||
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, cache));
|
||||
}
|
||||
|
||||
if ('$ref' in spec) {
|
||||
const refPath = spec.$ref;
|
||||
|
||||
if (cache.has(refPath)) {
|
||||
return cache.get(refPath);
|
||||
}
|
||||
|
||||
if (refPath.startsWith('#/components/')) {
|
||||
const refKeys = refPath.replace('#/components/', '').split('/');
|
||||
let ref = components;
|
||||
|
||||
for (const key of refKeys) {
|
||||
if (ref && ref[key]) {
|
||||
ref = ref[key];
|
||||
} else {
|
||||
return spec;
|
||||
}
|
||||
}
|
||||
|
||||
cache.set(refPath, {});
|
||||
const resolved = resolveRefs(ref, components, cache);
|
||||
cache.set(refPath, resolved);
|
||||
return resolved;
|
||||
}
|
||||
return spec;
|
||||
}
|
||||
|
||||
const resolved = {};
|
||||
cache.set(spec, resolved);
|
||||
|
||||
for (const [key, value] of Object.entries(spec)) {
|
||||
resolved[key] = resolveRefs(value, components, cache);
|
||||
}
|
||||
|
||||
return resolved;
|
||||
};
|
||||
|
||||
const groupRequestsByTags = (requests) => {
|
||||
let _groups = {};
|
||||
let ungrouped = [];
|
||||
each(requests, (request) => {
|
||||
let tags = request.operationObject.tags || [];
|
||||
if (tags.length > 0) {
|
||||
let tag = tags[0].trim(); // take first tag and trim whitespace
|
||||
|
||||
if (tag) {
|
||||
if (!_groups[tag]) {
|
||||
_groups[tag] = [];
|
||||
}
|
||||
_groups[tag].push(request);
|
||||
} else {
|
||||
ungrouped.push(request);
|
||||
}
|
||||
} else {
|
||||
ungrouped.push(request);
|
||||
}
|
||||
});
|
||||
|
||||
let groups = Object.keys(_groups).map((groupName) => {
|
||||
return {
|
||||
name: groupName,
|
||||
requests: _groups[groupName]
|
||||
};
|
||||
});
|
||||
|
||||
return [groups, ungrouped];
|
||||
};
|
||||
|
||||
const getDefaultUrl = (serverObject) => {
|
||||
let url = serverObject.url;
|
||||
if (serverObject.variables) {
|
||||
each(serverObject.variables, (variable, variableName) => {
|
||||
let sub = variable.default || (variable.enum ? variable.enum[0] : `{{${variableName}}}`);
|
||||
url = url.replace(`{${variableName}}`, sub);
|
||||
});
|
||||
}
|
||||
return url.endsWith('/') ? url.slice(0, -1) : url;
|
||||
};
|
||||
|
||||
const getSecurity = (apiSpec) => {
|
||||
let defaultSchemes = apiSpec.security || [];
|
||||
|
||||
let securitySchemes = get(apiSpec, 'components.securitySchemes', {});
|
||||
if (Object.keys(securitySchemes) === 0) {
|
||||
return {
|
||||
supported: []
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
supported: defaultSchemes.map((scheme) => {
|
||||
var schemeName = Object.keys(scheme)[0];
|
||||
return securitySchemes[schemeName];
|
||||
}),
|
||||
schemes: securitySchemes,
|
||||
getScheme: (schemeName) => {
|
||||
return securitySchemes[schemeName];
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const openAPIRuntimeExpressionToScript = (expression) => {
|
||||
// see https://swagger.io/docs/specification/links/#runtime-expressions
|
||||
if (expression === '$response.body') {
|
||||
return 'res.body';
|
||||
} else if (expression.startsWith('$response.body#')) {
|
||||
let pointer = expression.substring(15);
|
||||
// could use https://www.npmjs.com/package/json-pointer for better support
|
||||
return `res.body${pointer.replace('/', '.')}`;
|
||||
}
|
||||
return expression;
|
||||
};
|
||||
|
||||
export const parseOpenApiCollection = (data) => {
|
||||
const brunoCollection = {
|
||||
name: '',
|
||||
uid: uuid(),
|
||||
version: '1',
|
||||
items: [],
|
||||
environments: []
|
||||
};
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const collectionData = resolveRefs(data);
|
||||
if (!collectionData) {
|
||||
reject(new BrunoError('Invalid OpenAPI collection. Failed to resolve refs.'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Currently parsing of openapi spec is "do your best", that is
|
||||
// allows "invalid" openapi spec
|
||||
|
||||
// Assumes v3 if not defined. v2 is not supported yet
|
||||
if (collectionData.openapi && !collectionData.openapi.startsWith('3')) {
|
||||
reject(new BrunoError('Only OpenAPI v3 is supported currently.'));
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO what if info.title not defined?
|
||||
brunoCollection.name = collectionData.info.title;
|
||||
let servers = collectionData.servers || [];
|
||||
|
||||
// Create environments based on the servers
|
||||
servers.forEach((server, index) => {
|
||||
let baseUrl = getDefaultUrl(server);
|
||||
let environmentName = server.description ? server.description : `Environment ${index + 1}`;
|
||||
|
||||
brunoCollection.environments.push({
|
||||
uid: uuid(),
|
||||
name: environmentName,
|
||||
variables: [
|
||||
{
|
||||
uid: uuid(),
|
||||
name: 'baseUrl',
|
||||
value: baseUrl,
|
||||
type: 'text',
|
||||
enabled: true,
|
||||
secret: false
|
||||
},
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
let securityConfig = getSecurity(collectionData);
|
||||
|
||||
let allRequests = Object.entries(collectionData.paths)
|
||||
.map(([path, methods]) => {
|
||||
return Object.entries(methods)
|
||||
.filter(([method, op]) => {
|
||||
return ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'].includes(
|
||||
method.toLowerCase()
|
||||
);
|
||||
})
|
||||
.map(([method, operationObject]) => {
|
||||
return {
|
||||
method: method,
|
||||
path: path.replace(/{([^}]+)}/g, ':$1'), // Replace placeholders enclosed in curly braces with colons
|
||||
operationObject: operationObject,
|
||||
global: {
|
||||
server: '{{baseUrl}}',
|
||||
security: securityConfig
|
||||
}
|
||||
};
|
||||
});
|
||||
})
|
||||
.reduce((acc, val) => acc.concat(val), []); // flatten
|
||||
|
||||
let [groups, ungroupedRequests] = groupRequestsByTags(allRequests);
|
||||
let brunoFolders = groups.map((group) => {
|
||||
return {
|
||||
uid: uuid(),
|
||||
name: group.name,
|
||||
type: 'folder',
|
||||
items: group.requests.map(transformOpenapiRequestItem)
|
||||
};
|
||||
});
|
||||
|
||||
let ungroupedItems = ungroupedRequests.map(transformOpenapiRequestItem);
|
||||
let brunoCollectionItems = brunoFolders.concat(ungroupedItems);
|
||||
brunoCollection.items = brunoCollectionItems;
|
||||
resolve(brunoCollection);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
reject(new BrunoError('An error occurred while parsing the OpenAPI collection'));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const importCollection = () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
fileDialog({ accept: '.json, .yaml, .yml, application/json, application/yaml, application/x-yaml' })
|
||||
.then(readFile)
|
||||
.then(parseOpenApiCollection)
|
||||
.then(transformItemsInCollection)
|
||||
.then(hydrateSeqInCollection)
|
||||
.then(validateSchema)
|
||||
.then((collection) => openApiToBruno(collection))
|
||||
.then((collection) => resolve({ collection }))
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import { parseOpenApiCollection } from './openapi-collection';
|
||||
import { uuid } from 'utils/common';
|
||||
|
||||
jest.mock('utils/common');
|
||||
|
||||
describe('openapi importer util functions', () => {
|
||||
afterEach(jest.clearAllMocks);
|
||||
|
||||
it('should convert openapi object to bruno collection correctly', async () => {
|
||||
const input = {
|
||||
openapi: '3.0.3',
|
||||
info: {
|
||||
title: 'Sample API with Multiple Servers',
|
||||
description: 'API spec with multiple servers.',
|
||||
version: '1.0.0'
|
||||
},
|
||||
servers: [
|
||||
{ url: 'https://api.example.com/v1', description: 'Production Server' },
|
||||
{ url: 'https://staging-api.example.com/v1', description: 'Staging Server' },
|
||||
{ url: 'http://localhost:3000/v1', description: 'Local Server' }
|
||||
],
|
||||
paths: {
|
||||
'/users': {
|
||||
get: {
|
||||
summary: 'Get a list of users',
|
||||
parameters: [
|
||||
{ name: 'page', in: 'query', required: false, schema: { type: 'integer' } },
|
||||
{ name: 'limit', in: 'query', required: false, schema: { type: 'integer' } }
|
||||
],
|
||||
responses: {
|
||||
'200': { description: 'A list of users' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const expectedOutput = {
|
||||
name: 'Sample API with Multiple Servers',
|
||||
version: '1',
|
||||
items: [
|
||||
{
|
||||
name: 'Get a list of users',
|
||||
type: 'http-request',
|
||||
request: {
|
||||
url: '{{baseUrl}}/users',
|
||||
method: 'GET',
|
||||
params: [
|
||||
{ name: 'page', value: '', enabled: false, type: 'query' },
|
||||
{ name: 'limit', value: '', enabled: false, type: 'query' }
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
environments: [
|
||||
{ name: 'Production Server', variables: [{ name: 'baseUrl', value: 'https://api.example.com/v1' }] },
|
||||
{ name: 'Staging Server', variables: [{ name: 'baseUrl', value: 'https://staging-api.example.com/v1' }] },
|
||||
{ name: 'Local Server', variables: [{ name: 'baseUrl', value: 'http://localhost:3000/v1' }] }
|
||||
]
|
||||
};
|
||||
|
||||
const result = await parseOpenApiCollection(input);
|
||||
|
||||
expect(result).toMatchObject(expectedOutput);
|
||||
expect(uuid).toHaveBeenCalledTimes(10);
|
||||
});
|
||||
});
|
||||
@@ -1,651 +1,25 @@
|
||||
import get from 'lodash/get';
|
||||
import fileDialog from 'file-dialog';
|
||||
import { uuid } from 'utils/common';
|
||||
import { BrunoError } from 'utils/common/error';
|
||||
import { validateSchema, transformItemsInCollection, hydrateSeqInCollection } from './common';
|
||||
import { postmanTranslation } from 'utils/importers/translators/postman_translation';
|
||||
import each from 'lodash/each';
|
||||
import { safeParseJSON } from 'utils/common/index';
|
||||
|
||||
const readFile = (files) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const fileReader = new FileReader();
|
||||
fileReader.onload = (e) => resolve(e.target.result);
|
||||
fileReader.onload = (e) => resolve(safeParseJSON(e.target.result));
|
||||
fileReader.onerror = (err) => reject(err);
|
||||
fileReader.readAsText(files[0]);
|
||||
});
|
||||
};
|
||||
|
||||
const parseGraphQLRequest = (graphqlSource) => {
|
||||
try {
|
||||
let queryResultObject = {
|
||||
query: '',
|
||||
variables: ''
|
||||
};
|
||||
|
||||
if (typeof graphqlSource === 'string') {
|
||||
graphqlSource = JSON.parse(graphqlSource);
|
||||
}
|
||||
|
||||
if (graphqlSource.hasOwnProperty('variables') && graphqlSource.variables !== '') {
|
||||
queryResultObject.variables = graphqlSource.variables;
|
||||
}
|
||||
|
||||
if (graphqlSource.hasOwnProperty('query') && graphqlSource.query !== '') {
|
||||
queryResultObject.query = graphqlSource.query;
|
||||
}
|
||||
|
||||
return queryResultObject;
|
||||
} catch (e) {
|
||||
return {
|
||||
query: '',
|
||||
variables: ''
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const isItemAFolder = (item) => {
|
||||
return !item.request;
|
||||
};
|
||||
|
||||
const convertV21Auth = (array) => {
|
||||
return array.reduce((accumulator, currentValue) => {
|
||||
accumulator[currentValue.key] = currentValue.value;
|
||||
return accumulator;
|
||||
}, {});
|
||||
};
|
||||
|
||||
const constructUrlFromParts = (url) => {
|
||||
const { protocol = 'http', host, path, port, query, hash } = url || {};
|
||||
const hostStr = Array.isArray(host) ? host.filter(Boolean).join('.') : host || '';
|
||||
const pathStr = Array.isArray(path) ? path.filter(Boolean).join('/') : path || '';
|
||||
const portStr = port ? `:${port}` : '';
|
||||
const queryStr =
|
||||
query && Array.isArray(query) && query.length > 0
|
||||
? `?${query
|
||||
.filter((q) => q.key)
|
||||
.map((q) => `${q.key}=${q.value || ''}`)
|
||||
.join('&')}`
|
||||
: '';
|
||||
const urlStr = `${protocol}://${hostStr}${portStr}${pathStr ? `/${pathStr}` : ''}${queryStr}`;
|
||||
return urlStr;
|
||||
};
|
||||
|
||||
const constructUrl = (url) => {
|
||||
if (!url) return '';
|
||||
|
||||
if (typeof url === 'string') {
|
||||
return url;
|
||||
}
|
||||
|
||||
if (typeof url === 'object') {
|
||||
const { raw } = url;
|
||||
|
||||
if (raw && typeof raw === 'string') {
|
||||
// If the raw URL contains url-fragments remove it
|
||||
if (raw.includes('#')) {
|
||||
return raw.split('#')[0]; // Returns the part of raw URL without the url-fragment part.
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
// If no raw value exists, construct the URL from parts
|
||||
return constructUrlFromParts(url);
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
let translationLog = {};
|
||||
|
||||
/* struct of translation log
|
||||
{
|
||||
[collectionName]: {
|
||||
script: [index1, index2],
|
||||
test: [index1, index2]
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
const pushTranslationLog = (type, index) => {
|
||||
if (!translationLog[i.name]) {
|
||||
translationLog[i.name] = {};
|
||||
}
|
||||
if (!translationLog[i.name][type]) {
|
||||
translationLog[i.name][type] = [];
|
||||
}
|
||||
translationLog[i.name][type].push(index + 1);
|
||||
};
|
||||
|
||||
const importScriptsFromEvents = (events, requestObject, options, pushTranslationLog) => {
|
||||
events.forEach((event) => {
|
||||
if (event.script && event.script.exec) {
|
||||
if (event.listen === 'prerequest') {
|
||||
if (!requestObject.script) {
|
||||
requestObject.script = {};
|
||||
}
|
||||
|
||||
if (Array.isArray(event.script.exec) && event.script.exec.length > 0) {
|
||||
requestObject.script.req = event.script.exec
|
||||
.map((line, index) =>
|
||||
options.enablePostmanTranslations.enabled
|
||||
? postmanTranslation(line, () => pushTranslationLog('script', index))
|
||||
: `// ${line}`
|
||||
)
|
||||
.join('\n');
|
||||
} else if (typeof event.script.exec === 'string') {
|
||||
requestObject.script.req = options.enablePostmanTranslations.enabled
|
||||
? postmanTranslation(event.script.exec, () => pushTranslationLog('script', 0))
|
||||
: `// ${event.script.exec}`;
|
||||
} else {
|
||||
console.warn('Unexpected event.script.exec type', typeof event.script.exec);
|
||||
}
|
||||
}
|
||||
|
||||
if (event.listen === 'test') {
|
||||
if (!requestObject.tests) {
|
||||
requestObject.tests = {};
|
||||
}
|
||||
|
||||
if (Array.isArray(event.script.exec) && event.script.exec.length > 0) {
|
||||
requestObject.tests = event.script.exec
|
||||
.map((line, index) =>
|
||||
options.enablePostmanTranslations.enabled
|
||||
? postmanTranslation(line, () => pushTranslationLog('test', index))
|
||||
: `// ${line}`
|
||||
)
|
||||
.join('\n');
|
||||
} else if (typeof event.script.exec === 'string') {
|
||||
requestObject.tests = options.enablePostmanTranslations.enabled
|
||||
? postmanTranslation(event.script.exec, () => pushTranslationLog('test', 0))
|
||||
: `// ${event.script.exec}`;
|
||||
} else {
|
||||
console.warn('Unexpected event.script.exec type', typeof event.script.exec);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const importCollectionLevelVariables = (variables, requestObject) => {
|
||||
const vars = variables.map((v) => ({
|
||||
uid: uuid(),
|
||||
name: v.key,
|
||||
value: v.value,
|
||||
enabled: true
|
||||
}));
|
||||
|
||||
requestObject.vars.req = vars;
|
||||
};
|
||||
|
||||
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)) {
|
||||
const baseFolderName = i.name;
|
||||
let folderName = baseFolderName;
|
||||
let count = 1;
|
||||
|
||||
while (folderMap[folderName]) {
|
||||
folderName = `${baseFolderName}_${count}`;
|
||||
count++;
|
||||
}
|
||||
|
||||
const brunoFolderItem = {
|
||||
uid: uuid(),
|
||||
name: folderName,
|
||||
type: 'folder',
|
||||
items: [],
|
||||
root: {
|
||||
docs: i.description || '',
|
||||
meta: {
|
||||
name: folderName
|
||||
},
|
||||
request: {
|
||||
auth: {
|
||||
mode: 'none',
|
||||
basic: null,
|
||||
bearer: null,
|
||||
awsv4: null
|
||||
},
|
||||
headers: [],
|
||||
script: {},
|
||||
tests: '',
|
||||
vars: {}
|
||||
}
|
||||
}
|
||||
};
|
||||
if (i.item && i.item.length) {
|
||||
importPostmanV2CollectionItem(brunoFolderItem, i.item, i.auth ?? parentAuth, options);
|
||||
}
|
||||
|
||||
if (i.event) {
|
||||
importScriptsFromEvents(i.event, brunoFolderItem.root.request, options, pushTranslationLog);
|
||||
}
|
||||
|
||||
brunoParent.items.push(brunoFolderItem);
|
||||
folderMap[folderName] = brunoFolderItem;
|
||||
|
||||
} else {
|
||||
if (i.request) {
|
||||
if(!requestMethods.includes(i?.request?.method.toUpperCase())){
|
||||
console.warn("Unexpected request.method")
|
||||
return;
|
||||
}
|
||||
|
||||
const baseRequestName = i.name;
|
||||
let requestName = baseRequestName;
|
||||
let count = 1;
|
||||
|
||||
while (requestMap[requestName]) {
|
||||
requestName = `${baseRequestName}_${count}`;
|
||||
count++;
|
||||
}
|
||||
|
||||
const url = constructUrl(i.request.url);
|
||||
|
||||
const brunoRequestItem = {
|
||||
uid: uuid(),
|
||||
name: requestName,
|
||||
type: 'http-request',
|
||||
request: {
|
||||
url: url,
|
||||
method: i?.request?.method?.toUpperCase(),
|
||||
auth: {
|
||||
mode: 'none',
|
||||
basic: null,
|
||||
bearer: null,
|
||||
awsv4: null
|
||||
},
|
||||
headers: [],
|
||||
params: [],
|
||||
body: {
|
||||
mode: 'none',
|
||||
json: null,
|
||||
text: null,
|
||||
xml: null,
|
||||
formUrlEncoded: [],
|
||||
multipartForm: []
|
||||
},
|
||||
docs: i.request.description
|
||||
}
|
||||
};
|
||||
|
||||
if (i.event) {
|
||||
i.event.forEach((event) => {
|
||||
if (event.listen === 'prerequest' && event.script && event.script.exec) {
|
||||
if (!brunoRequestItem.request.script) {
|
||||
brunoRequestItem.request.script = {};
|
||||
}
|
||||
if (Array.isArray(event.script.exec) && event.script.exec.length > 0) {
|
||||
brunoRequestItem.request.script.req = event.script.exec
|
||||
.map((line, index) =>
|
||||
options.enablePostmanTranslations.enabled
|
||||
? postmanTranslation(line, () => pushTranslationLog('script', index))
|
||||
: `// ${line}`
|
||||
)
|
||||
.join('\n');
|
||||
} else if (typeof event.script.exec === 'string') {
|
||||
brunoRequestItem.request.script.req = options.enablePostmanTranslations.enabled
|
||||
? postmanTranslation(event.script.exec, () => pushTranslationLog('script', 0))
|
||||
: `// ${event.script.exec}`;
|
||||
} else {
|
||||
console.warn('Unexpected event.script.exec type', typeof event.script.exec);
|
||||
}
|
||||
}
|
||||
if (event.listen === 'test' && event.script && event.script.exec) {
|
||||
if (!brunoRequestItem.request.tests) {
|
||||
brunoRequestItem.request.tests = {};
|
||||
}
|
||||
if (Array.isArray(event.script.exec) && event.script.exec.length > 0) {
|
||||
brunoRequestItem.request.tests = event.script.exec
|
||||
.map((line, index) =>
|
||||
options.enablePostmanTranslations.enabled
|
||||
? postmanTranslation(line, () => pushTranslationLog('test', index))
|
||||
: `// ${line}`
|
||||
)
|
||||
.join('\n');
|
||||
} else if (typeof event.script.exec === 'string') {
|
||||
brunoRequestItem.request.tests = options.enablePostmanTranslations.enabled
|
||||
? postmanTranslation(event.script.exec, () => pushTranslationLog('test', 0))
|
||||
: `// ${event.script.exec}`;
|
||||
} else {
|
||||
console.warn('Unexpected event.script.exec type', typeof event.script.exec);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const bodyMode = get(i, 'request.body.mode');
|
||||
if (bodyMode) {
|
||||
if (bodyMode === 'formdata') {
|
||||
brunoRequestItem.request.body.mode = 'multipartForm';
|
||||
|
||||
each(i.request.body.formdata, (param) => {
|
||||
const isFile = param.type === 'file';
|
||||
let value;
|
||||
let type;
|
||||
|
||||
if (isFile) {
|
||||
// If param.src is an array, keep it as it is.
|
||||
// If param.src is a string, convert it into an array with a single element.
|
||||
value = Array.isArray(param.src) ? param.src : typeof param.src === 'string' ? [param.src] : null;
|
||||
type = 'file';
|
||||
} else {
|
||||
value = param.value;
|
||||
type = 'text';
|
||||
}
|
||||
|
||||
brunoRequestItem.request.body.multipartForm.push({
|
||||
uid: uuid(),
|
||||
type: type,
|
||||
name: param.key,
|
||||
value: value,
|
||||
description: param.description,
|
||||
enabled: !param.disabled
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (bodyMode === 'urlencoded') {
|
||||
brunoRequestItem.request.body.mode = 'formUrlEncoded';
|
||||
each(i.request.body.urlencoded, (param) => {
|
||||
brunoRequestItem.request.body.formUrlEncoded.push({
|
||||
uid: uuid(),
|
||||
name: param.key,
|
||||
value: param.value,
|
||||
description: param.description,
|
||||
enabled: !param.disabled
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (bodyMode === 'raw') {
|
||||
let language = get(i, 'request.body.options.raw.language');
|
||||
if (!language) {
|
||||
language = searchLanguageByHeader(i.request.header);
|
||||
}
|
||||
if (language === 'json') {
|
||||
brunoRequestItem.request.body.mode = 'json';
|
||||
brunoRequestItem.request.body.json = i.request.body.raw;
|
||||
} else if (language === 'xml') {
|
||||
brunoRequestItem.request.body.mode = 'xml';
|
||||
brunoRequestItem.request.body.xml = i.request.body.raw;
|
||||
} else {
|
||||
brunoRequestItem.request.body.mode = 'text';
|
||||
brunoRequestItem.request.body.text = i.request.body.raw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bodyMode === 'graphql') {
|
||||
brunoRequestItem.type = 'graphql-request';
|
||||
brunoRequestItem.request.body.mode = 'graphql';
|
||||
brunoRequestItem.request.body.graphql = parseGraphQLRequest(i.request.body.graphql);
|
||||
}
|
||||
|
||||
each(i.request.header, (header) => {
|
||||
brunoRequestItem.request.headers.push({
|
||||
uid: uuid(),
|
||||
name: header.key,
|
||||
value: header.value,
|
||||
description: header.description,
|
||||
enabled: !header.disabled
|
||||
});
|
||||
});
|
||||
|
||||
const auth = i.request.auth ?? parentAuth;
|
||||
if (auth?.[auth.type] && auth.type !== 'noauth') {
|
||||
let authValues = auth[auth.type];
|
||||
if (Array.isArray(authValues)) {
|
||||
authValues = convertV21Auth(authValues);
|
||||
}
|
||||
if (auth.type === 'basic') {
|
||||
brunoRequestItem.request.auth.mode = 'basic';
|
||||
brunoRequestItem.request.auth.basic = {
|
||||
username: authValues.username,
|
||||
password: authValues.password
|
||||
};
|
||||
} else if (auth.type === 'bearer') {
|
||||
brunoRequestItem.request.auth.mode = 'bearer';
|
||||
brunoRequestItem.request.auth.bearer = {
|
||||
token: authValues.token
|
||||
};
|
||||
} else if (auth.type === 'awsv4') {
|
||||
brunoRequestItem.request.auth.mode = 'awsv4';
|
||||
brunoRequestItem.request.auth.awsv4 = {
|
||||
accessKeyId: authValues.accessKey,
|
||||
secretAccessKey: authValues.secretKey,
|
||||
sessionToken: authValues.sessionToken,
|
||||
service: authValues.service,
|
||||
region: authValues.region,
|
||||
profileName: ''
|
||||
};
|
||||
} else if (auth.type === 'apikey'){
|
||||
brunoRequestItem.request.auth.mode = 'apikey';
|
||||
brunoRequestItem.request.auth.apikey = {
|
||||
key: authValues.key,
|
||||
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!
|
||||
}
|
||||
} else if (auth.type === 'oauth2'){
|
||||
const findValueUsingKey = (key) => {
|
||||
return auth?.oauth2?.find(v => v?.key == key)?.value || ''
|
||||
}
|
||||
const oauth2GrantTypeMaps = {
|
||||
'authorization_code_with_pkce': 'authorization_code',
|
||||
'authorization_code': 'authorization_code',
|
||||
'client_credentials': 'client_credentials',
|
||||
'password_credentials': 'password_credentials'
|
||||
}
|
||||
const grantType = oauth2GrantTypeMaps[findValueUsingKey('grant_type')] || 'authorization_code';
|
||||
if (grantType) {
|
||||
brunoRequestItem.request.auth.mode = 'oauth2';
|
||||
switch(grantType) {
|
||||
case 'authorization_code':
|
||||
brunoRequestItem.request.auth.oauth2 = {
|
||||
grantType: 'authorization_code',
|
||||
authorizationUrl: findValueUsingKey('authUrl'),
|
||||
callbackUrl: findValueUsingKey('redirect_uri'),
|
||||
accessTokenUrl: findValueUsingKey('accessTokenUrl'),
|
||||
refreshTokenUrl: findValueUsingKey('refreshTokenUrl'),
|
||||
clientId: findValueUsingKey('clientId'),
|
||||
clientSecret: findValueUsingKey('clientSecret'),
|
||||
scope: findValueUsingKey('scope'),
|
||||
state: findValueUsingKey('state'),
|
||||
pkce: Boolean(findValueUsingKey('grant_type') == 'authorization_code_with_pkce'),
|
||||
tokenPlacement: findValueUsingKey('addTokenTo') == 'header' ? 'header' : 'url',
|
||||
credentialsPlacement: findValueUsingKey('client_authentication') == 'body' ? 'body' : 'basic_auth_header'
|
||||
};
|
||||
break;
|
||||
case 'password_credentials':
|
||||
brunoRequestItem.request.auth.oauth2 = {
|
||||
grantType: 'password',
|
||||
accessTokenUrl: findValueUsingKey('accessTokenUrl'),
|
||||
refreshTokenUrl: findValueUsingKey('refreshTokenUrl'),
|
||||
username: findValueUsingKey('username'),
|
||||
password: findValueUsingKey('password'),
|
||||
clientId: findValueUsingKey('clientId'),
|
||||
clientSecret: findValueUsingKey('clientSecret'),
|
||||
scope: findValueUsingKey('scope'),
|
||||
state: findValueUsingKey('state'),
|
||||
tokenPlacement: findValueUsingKey('addTokenTo') == 'header' ? 'header' : 'url',
|
||||
credentialsPlacement: findValueUsingKey('client_authentication') == 'body' ? 'body' : 'basic_auth_header'
|
||||
};
|
||||
break;
|
||||
case 'client_credentials':
|
||||
brunoRequestItem.request.auth.oauth2 = {
|
||||
grantType: 'client_credentials',
|
||||
accessTokenUrl: findValueUsingKey('accessTokenUrl'),
|
||||
refreshTokenUrl: findValueUsingKey('refreshTokenUrl'),
|
||||
clientId: findValueUsingKey('clientId'),
|
||||
clientSecret: findValueUsingKey('clientSecret'),
|
||||
scope: findValueUsingKey('scope'),
|
||||
state: findValueUsingKey('state'),
|
||||
tokenPlacement: findValueUsingKey('addTokenTo') == 'header' ? 'header' : 'url',
|
||||
credentialsPlacement: findValueUsingKey('client_authentication') == 'body' ? 'body' : 'basic_auth_header'
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
each(get(i, 'request.url.query'), (param) => {
|
||||
brunoRequestItem.request.params.push({
|
||||
uid: uuid(),
|
||||
name: param.key,
|
||||
value: param.value,
|
||||
description: param.description,
|
||||
type: 'query',
|
||||
enabled: !param.disabled
|
||||
});
|
||||
});
|
||||
|
||||
each(get(i, 'request.url.variable', []), (param) => {
|
||||
if (!param.key) {
|
||||
// If no key, skip this iteration and discard the param
|
||||
return;
|
||||
}
|
||||
|
||||
brunoRequestItem.request.params.push({
|
||||
uid: uuid(),
|
||||
name: param.key,
|
||||
value: param.value ?? '',
|
||||
description: param.description ?? '',
|
||||
type: 'path',
|
||||
enabled: true
|
||||
});
|
||||
});
|
||||
|
||||
brunoParent.items.push(brunoRequestItem);
|
||||
requestMap[requestName] = brunoRequestItem;
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const searchLanguageByHeader = (headers) => {
|
||||
let contentType;
|
||||
each(headers, (header) => {
|
||||
if (header.key.toLowerCase() === 'content-type' && !header.disabled) {
|
||||
if (typeof header.value == 'string' && /^[\w\-]+\/([\w\-]+\+)?json/.test(header.value)) {
|
||||
contentType = 'json';
|
||||
} else if (typeof header.value == 'string' && /^[\w\-]+\/([\w\-]+\+)?xml/.test(header.value)) {
|
||||
contentType = 'xml';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
});
|
||||
return contentType;
|
||||
};
|
||||
|
||||
const importPostmanV2Collection = (collection, options) => {
|
||||
const brunoCollection = {
|
||||
name: collection.info.name,
|
||||
uid: uuid(),
|
||||
version: '1',
|
||||
items: [],
|
||||
environments: [],
|
||||
root: {
|
||||
docs: collection.info.description || '',
|
||||
meta: {
|
||||
name: collection.info.name
|
||||
},
|
||||
request: {
|
||||
auth: {
|
||||
mode: 'none',
|
||||
basic: null,
|
||||
bearer: null,
|
||||
awsv4: null
|
||||
},
|
||||
headers: [],
|
||||
script: {},
|
||||
tests: '',
|
||||
vars: {}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (collection.event) {
|
||||
importScriptsFromEvents(collection.event, brunoCollection.root.request, options, pushTranslationLog);
|
||||
}
|
||||
|
||||
if (collection?.variable){
|
||||
importCollectionLevelVariables(collection.variable, brunoCollection.root.request);
|
||||
}
|
||||
|
||||
importPostmanV2CollectionItem(brunoCollection, collection.item, collection.auth, options);
|
||||
|
||||
return brunoCollection;
|
||||
};
|
||||
|
||||
const parsePostmanCollection = (str, options) => {
|
||||
const postmanToBruno = (collection) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
let collection = JSON.parse(str);
|
||||
let schema = get(collection, 'info.schema');
|
||||
|
||||
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.postman.com/json/collection/v2.0.0/collection.json',
|
||||
'https://schema.postman.com/json/collection/v2.1.0/collection.json'
|
||||
];
|
||||
|
||||
if (v2Schemas.includes(schema)) {
|
||||
return resolve(importPostmanV2Collection(collection, options));
|
||||
}
|
||||
|
||||
throw new BrunoError('Unknown postman schema');
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
if (err instanceof BrunoError) {
|
||||
return reject(err);
|
||||
}
|
||||
|
||||
return reject(new BrunoError('Unable to parse the postman collection json file'));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const logTranslationDetails = (translationLog) => {
|
||||
if (Object.keys(translationLog || {}).length > 0) {
|
||||
console.warn(
|
||||
`[Postman Translation Logs]
|
||||
Collections incomplete : ${Object.keys(translationLog || {}).length}` +
|
||||
`\nTotal lines incomplete : ${Object.values(translationLog || {}).reduce(
|
||||
(acc, curr) => acc + (curr.script?.length || 0) + (curr.test?.length || 0),
|
||||
0
|
||||
)}` +
|
||||
`\nSee details below :`,
|
||||
translationLog
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const importCollection = (options) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
fileDialog({ accept: 'application/json' })
|
||||
.then(readFile)
|
||||
.then((str) => parsePostmanCollection(str, options))
|
||||
.then(transformItemsInCollection)
|
||||
.then(hydrateSeqInCollection)
|
||||
.then(validateSchema)
|
||||
.then((collection) => resolve({ collection, translationLog }))
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
translationLog = {};
|
||||
reject(new BrunoError('Import collection failed'));
|
||||
})
|
||||
.then(() => {
|
||||
logTranslationDetails(translationLog);
|
||||
translationLog = {};
|
||||
window.ipcRenderer.invoke('renderer:convert-postman-to-bruno', collection)
|
||||
.then(result => resolve(result))
|
||||
.catch(err => {
|
||||
console.error('Error converting Postman to Bruno via Electron:', err);
|
||||
reject(new BrunoError('Conversion failed'));
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export default importCollection;
|
||||
export { postmanToBruno, readFile };
|
||||
|
||||
@@ -1,60 +1,24 @@
|
||||
import each from 'lodash/each';
|
||||
import fileDialog from 'file-dialog';
|
||||
import { BrunoError } from 'utils/common/error';
|
||||
import { postmanToBrunoEnvironment } from '@usebruno/converters';
|
||||
|
||||
const readFile = (files) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const fileReader = new FileReader();
|
||||
fileReader.onload = (e) => resolve(e.target.result);
|
||||
fileReader.onload = (e) => {
|
||||
try {
|
||||
let parsedPostmanEnvironment = JSON.parse(e.target.result);
|
||||
resolve(parsedPostmanEnvironment);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
reject(new BrunoError('Unable to parse the postman environment json file'));
|
||||
}
|
||||
}
|
||||
fileReader.onerror = (err) => reject(err);
|
||||
fileReader.readAsText(files[0]);
|
||||
});
|
||||
};
|
||||
|
||||
const isSecret = (type) => {
|
||||
return type === 'secret';
|
||||
};
|
||||
|
||||
const importPostmanEnvironmentVariables = (brunoEnvironment, values) => {
|
||||
brunoEnvironment.variables = brunoEnvironment.variables || [];
|
||||
|
||||
each(values, (i) => {
|
||||
const brunoEnvironmentVariable = {
|
||||
name: i.key,
|
||||
value: i.value,
|
||||
enabled: i.enabled,
|
||||
secret: isSecret(i.type)
|
||||
};
|
||||
|
||||
brunoEnvironment.variables.push(brunoEnvironmentVariable);
|
||||
});
|
||||
};
|
||||
|
||||
const importPostmanEnvironment = (environment) => {
|
||||
const brunoEnvironment = {
|
||||
name: environment.name,
|
||||
variables: []
|
||||
};
|
||||
|
||||
importPostmanEnvironmentVariables(brunoEnvironment, environment.values);
|
||||
return brunoEnvironment;
|
||||
};
|
||||
|
||||
const parsePostmanEnvironment = (str) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
let environment = JSON.parse(str);
|
||||
return resolve(importPostmanEnvironment(environment));
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
if (err instanceof BrunoError) {
|
||||
return reject(err);
|
||||
}
|
||||
return reject(new BrunoError('Unable to parse the postman environment json file'));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const importEnvironment = () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
fileDialog({ multiple: true, accept: 'application/json' })
|
||||
@@ -62,7 +26,7 @@ const importEnvironment = () => {
|
||||
return Promise.all(
|
||||
Object.values(files ?? {}).map((file) =>
|
||||
readFile([file])
|
||||
.then(parsePostmanEnvironment)
|
||||
.then((environment) => postmanToBrunoEnvironment(environment))
|
||||
.catch((err) => {
|
||||
console.error(`Error processing file: ${file.name || 'undefined'}`, err);
|
||||
throw err;
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
const { postmanTranslation } = require('./postman_translation'); // Adjust path as needed
|
||||
|
||||
describe('postmanTranslation function', () => {
|
||||
test('should translate pm commands correctly', () => {
|
||||
const inputScript = `
|
||||
pm.environment.get('key');
|
||||
pm.environment.set('key', 'value');
|
||||
pm.variables.get('key');
|
||||
pm.variables.set('key', 'value');
|
||||
pm.collectionVariables.get('key');
|
||||
pm.collectionVariables.set('key', 'value');
|
||||
const data = pm.response.json();
|
||||
pm.expect(pm.environment.has('key')).to.be.true;
|
||||
postman.setEnvironmentVariable('key', 'value');
|
||||
postman.getEnvironmentVariable('key');
|
||||
postman.clearEnvironmentVariable('key');
|
||||
`;
|
||||
const expectedOutput = `
|
||||
bru.getEnvVar('key');
|
||||
bru.setEnvVar('key', 'value');
|
||||
bru.getVar('key');
|
||||
bru.setVar('key', 'value');
|
||||
bru.getVar('key');
|
||||
bru.setVar('key', 'value');
|
||||
const data = res.getBody();
|
||||
expect(bru.getEnvVar('key') !== undefined && bru.getEnvVar('key') !== null).to.be.true;
|
||||
bru.setEnvVar('key', 'value');
|
||||
bru.getEnvVar('key');
|
||||
bru.deleteEnvVar('key');
|
||||
`;
|
||||
expect(postmanTranslation(inputScript)).toBe(expectedOutput);
|
||||
});
|
||||
|
||||
test('should not translate non-pm commands', () => {
|
||||
const inputScript = `
|
||||
console.log('This script does not contain pm commands.');
|
||||
const data = pm.environment.get('key');
|
||||
pm.collectionVariables.set('key', data);
|
||||
`;
|
||||
const expectedOutput = `
|
||||
console.log('This script does not contain pm commands.');
|
||||
const data = bru.getEnvVar('key');
|
||||
bru.setVar('key', data);
|
||||
`;
|
||||
expect(postmanTranslation(inputScript)).toBe(expectedOutput);
|
||||
});
|
||||
|
||||
test('should comment non-translated pm commands', () => {
|
||||
const inputScript = "pm.test('random test', () => postman.variables.replaceIn('{{$guid}}'));";
|
||||
const expectedOutput = "// test('random test', () => postman.variables.replaceIn('{{$guid}}'));";
|
||||
expect(postmanTranslation(inputScript)).toBe(expectedOutput);
|
||||
});
|
||||
test('should handle multiple pm commands on the same line', () => {
|
||||
const inputScript = "pm.environment.get('key'); pm.environment.set('key', 'value');";
|
||||
const expectedOutput = "bru.getEnvVar('key'); bru.setEnvVar('key', 'value');";
|
||||
expect(postmanTranslation(inputScript)).toBe(expectedOutput);
|
||||
});
|
||||
test('should handle comments and other JavaScript code', () => {
|
||||
const inputScript = `
|
||||
// This is a comment
|
||||
const value = 'test';
|
||||
pm.environment.set('key', value);
|
||||
/*
|
||||
Multi-line comment
|
||||
*/
|
||||
const result = pm.environment.get('key');
|
||||
console.log('Result:', result);
|
||||
`;
|
||||
const expectedOutput = `
|
||||
// This is a comment
|
||||
const value = 'test';
|
||||
bru.setEnvVar('key', value);
|
||||
/*
|
||||
Multi-line comment
|
||||
*/
|
||||
const result = bru.getEnvVar('key');
|
||||
console.log('Result:', result);
|
||||
`;
|
||||
expect(postmanTranslation(inputScript)).toBe(expectedOutput);
|
||||
});
|
||||
|
||||
test('should handle nested commands and edge cases', () => {
|
||||
const inputScript = `
|
||||
const sampleObjects = [
|
||||
{
|
||||
key: pm.environment.get('key'),
|
||||
value: pm.variables.get('value')
|
||||
},
|
||||
{
|
||||
key: pm.collectionVariables.get('key'),
|
||||
value: pm.collectionVariables.get('value')
|
||||
}
|
||||
];
|
||||
const dataTesting = Object.entries(sampleObjects || {}).reduce((acc, [key, value]) => {
|
||||
// this is a comment
|
||||
acc[key] = pm.collectionVariables.get(pm.environment.get(value));
|
||||
return acc; // Return the accumulator
|
||||
}, {});
|
||||
Object.values(dataTesting).forEach((data) => {
|
||||
pm.environment.set(data.key, pm.variables.get(data.value));
|
||||
});
|
||||
`;
|
||||
const expectedOutput = `
|
||||
const sampleObjects = [
|
||||
{
|
||||
key: bru.getEnvVar('key'),
|
||||
value: bru.getVar('value')
|
||||
},
|
||||
{
|
||||
key: bru.getVar('key'),
|
||||
value: bru.getVar('value')
|
||||
}
|
||||
];
|
||||
const dataTesting = Object.entries(sampleObjects || {}).reduce((acc, [key, value]) => {
|
||||
// this is a comment
|
||||
acc[key] = bru.getVar(bru.getEnvVar(value));
|
||||
return acc; // Return the accumulator
|
||||
}, {});
|
||||
Object.values(dataTesting).forEach((data) => {
|
||||
bru.setEnvVar(data.key, bru.getVar(data.value));
|
||||
});
|
||||
`;
|
||||
expect(postmanTranslation(inputScript)).toBe(expectedOutput);
|
||||
});
|
||||
|
||||
test('should handle test commands', () => {
|
||||
const inputScript = `
|
||||
pm.test('Status code is 200', () => {
|
||||
pm.response.to.have.status(200);
|
||||
});
|
||||
pm.test('this test will fail', () => {
|
||||
return false
|
||||
});
|
||||
`;
|
||||
const expectedOutput = `
|
||||
test('Status code is 200', () => {
|
||||
expect(res.getStatus()).to.equal(200);
|
||||
});
|
||||
test('this test will fail', () => {
|
||||
return false
|
||||
});
|
||||
`;
|
||||
expect(postmanTranslation(inputScript)).toBe(expectedOutput);
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle response commands', () => {
|
||||
const inputScript = `
|
||||
const responseTime = pm.response.responseTime;
|
||||
const responseCode = pm.response.code;
|
||||
const responseText = pm.response.text();
|
||||
`;
|
||||
const expectedOutput = `
|
||||
const responseTime = res.getResponseTime();
|
||||
const responseCode = res.getStatus();
|
||||
const responseText = res.getBody()?.toString();
|
||||
`;
|
||||
expect(postmanTranslation(inputScript)).toBe(expectedOutput);
|
||||
});
|
||||
|
||||
test('should handle tests object', () => {
|
||||
const inputScript = `
|
||||
tests['Status code is 200'] = responseCode.code === 200;
|
||||
`;
|
||||
const expectedOutput = `
|
||||
test("Status code is 200", function() { expect(Boolean(responseCode.code === 200)).to.be.true; });
|
||||
`;
|
||||
expect(postmanTranslation(inputScript)).toBe(expectedOutput);
|
||||
});
|
||||
@@ -5,6 +5,10 @@ export const sendNetworkRequest = async (item, collection, environment, runtimeV
|
||||
if (['http-request', 'graphql-request'].includes(item.type)) {
|
||||
sendHttpRequest(item, collection, environment, runtimeVariables)
|
||||
.then((response) => {
|
||||
// if there is an error, we return the response object as is
|
||||
if (response?.error) {
|
||||
resolve(response)
|
||||
}
|
||||
resolve({
|
||||
state: 'success',
|
||||
data: response.data,
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { resetSequencesInFolder, isItemBetweenSequences } from 'utils/collections/index';
|
||||
|
||||
describe('resetSequencesInFolder', () => {
|
||||
it('should fix the sequences in the folder 1', () => {
|
||||
const folder = {
|
||||
items: [
|
||||
{ uid: '1', seq: 1 },
|
||||
{ uid: '2', seq: 3 },
|
||||
{ uid: '3', seq: 6 },
|
||||
],
|
||||
};
|
||||
|
||||
const fixedFolder = resetSequencesInFolder(folder.items);
|
||||
expect(fixedFolder).toEqual([
|
||||
{ uid: '1', seq: 1 },
|
||||
{ uid: '2', seq: 2 },
|
||||
{ uid: '3', seq: 3 },
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
it('should fix the sequences in the folder 2', () => {
|
||||
const folder = {
|
||||
items: [
|
||||
{ uid: '1', seq: 3 },
|
||||
{ uid: '2', seq: 1 },
|
||||
{ uid: '3', seq: 2 },
|
||||
],
|
||||
};
|
||||
|
||||
const fixedFolder = resetSequencesInFolder(folder.items);
|
||||
expect(fixedFolder).toEqual([
|
||||
{ uid: '2', seq: 1 },
|
||||
{ uid: '3', seq: 2 },
|
||||
{ uid: '1', seq: 3 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should fix the sequences in the folder with missing sequences', () => {
|
||||
const folder = {
|
||||
items: [
|
||||
{ uid: '1', seq: 1 },
|
||||
{ uid: '2', type: 'folder' },
|
||||
{ uid: '3', type: 'folder' },
|
||||
{ uid: '4', seq: 7 },
|
||||
]
|
||||
};
|
||||
|
||||
const fixedFolder = resetSequencesInFolder(folder.items);
|
||||
expect(fixedFolder).toEqual([
|
||||
{ uid: '1', seq: 1 },
|
||||
{ uid: '2', seq: 2, type: 'folder' },
|
||||
{ uid: '3', seq: 3, type: 'folder' },
|
||||
{ uid: '4', seq: 4 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should fix the sequences in the folder with same sequences', () => {
|
||||
const folder = {
|
||||
items: [
|
||||
{ uid: '1', seq: 2 },
|
||||
{ uid: '2', seq: 2 },
|
||||
{ uid: '3', seq: 3 },
|
||||
{ uid: '4', seq: 1 },
|
||||
],
|
||||
};
|
||||
|
||||
const fixedFolder = resetSequencesInFolder(folder.items);
|
||||
expect(fixedFolder).toEqual([
|
||||
{ uid: '4', seq: 1 },
|
||||
{ uid: '1', seq: 2 },
|
||||
{ uid: '2', seq: 3 },
|
||||
{ uid: '3', seq: 4 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isItemBetweenSequences', () => {
|
||||
it('should return true if the item is between the sequences 1', () => {
|
||||
const item = { uid: '1', seq: 2 };
|
||||
const draggedSequence = 1;
|
||||
const targetSequence = 5;
|
||||
const result = isItemBetweenSequences(item?.seq, draggedSequence, targetSequence);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true if the item is between the sequences 2', () => {
|
||||
const item = { uid: '1', seq: 2 };
|
||||
const draggedSequence = 1;
|
||||
const targetSequence = 5;
|
||||
const result = isItemBetweenSequences(item?.seq, draggedSequence, targetSequence);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true if the item is between the sequences 3', () => {
|
||||
const item = { uid: '1', seq: 4 };
|
||||
const draggedSequence = 1;
|
||||
const targetSequence = 5;
|
||||
const result = isItemBetweenSequences(item?.seq, draggedSequence, targetSequence);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true if the item is between the sequences 4', () => {
|
||||
const item = { uid: '1', seq: 1 };
|
||||
const draggedSequence = 5;
|
||||
const targetSequence = 1;
|
||||
const result = isItemBetweenSequences(item?.seq, draggedSequence, targetSequence);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if the item is between the sequences 1', () => {
|
||||
const item = { uid: '1', seq: 1 };
|
||||
const draggedSequence = 1;
|
||||
const targetSequence = 5;
|
||||
const result = isItemBetweenSequences(item?.seq, draggedSequence, targetSequence);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false if the item is between the sequences 2', () => {
|
||||
const item = { uid: '1', seq: 5 };
|
||||
const draggedSequence = 1;
|
||||
const targetSequence = 5;
|
||||
const result = isItemBetweenSequences(item?.seq, draggedSequence, targetSequence);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,9 @@
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import trim from 'lodash/trim';
|
||||
import each from 'lodash/each';
|
||||
import filter from 'lodash/filter';
|
||||
import find from 'lodash/find';
|
||||
|
||||
import brunoCommon from '@usebruno/common';
|
||||
const { interpolate } = brunoCommon;
|
||||
import { interpolate } from '@usebruno/common';
|
||||
|
||||
const hasLength = (str) => {
|
||||
if (!str || !str.length) {
|
||||
|
||||
@@ -51,6 +51,8 @@
|
||||
"@usebruno/js": "0.12.0",
|
||||
"@usebruno/lang": "0.12.0",
|
||||
"@usebruno/vm2": "^3.9.13",
|
||||
"@usebruno/requests": "^0.1.0",
|
||||
"@usebruno/converters": "^0.1.0",
|
||||
"aws4-axios": "^3.3.0",
|
||||
"axios": "^1.8.3",
|
||||
"axios-ntlm": "^1.4.2",
|
||||
@@ -62,6 +64,7 @@
|
||||
"http-proxy-agent": "^7.0.0",
|
||||
"https-proxy-agent": "^7.0.2",
|
||||
"iconv-lite": "^0.6.3",
|
||||
"js-yaml": "^4.1.0",
|
||||
"lodash": "^4.17.21",
|
||||
"qs": "^6.11.0",
|
||||
"socks-proxy-agent": "^8.0.2",
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
# bruno-cli
|
||||
# Bruno CLI
|
||||
|
||||
With Bruno CLI, you can now run your API collections with ease using simple command line commands.
|
||||
|
||||
This makes it easier to test your APIs in different environments, automate your testing process, and integrate your API tests with your continuous integration and deployment workflows.
|
||||
|
||||
For detailed documentation, visit [Bruno CLI Documentation](https://docs.usebruno.com/bru-cli/overview).
|
||||
|
||||
## Installation
|
||||
|
||||
To install the Bruno CLI, use the node package manager of your choice, such as NPM:
|
||||
@@ -56,6 +58,68 @@ If you need to limit the trusted CA to a specified set when validating the reque
|
||||
bru run request.bru --cacert myCustomCA.pem --ignore-truststore
|
||||
```
|
||||
|
||||
## Importing Collections
|
||||
|
||||
You can import collections from other formats, such as OpenAPI, using the import command:
|
||||
|
||||
```bash
|
||||
bru import openapi --source api.yml --output ~/Desktop/my-collection --collection-name "My API"
|
||||
```
|
||||
|
||||
You can also use the shorter form with aliases:
|
||||
|
||||
```bash
|
||||
bru import openapi -s api.yml -o ~/Desktop/my-collection -n "My API"
|
||||
```
|
||||
|
||||
This creates a Bruno collection directory that can be opened in Bruno.
|
||||
|
||||
You can also import directly from a URL:
|
||||
|
||||
```bash
|
||||
bru import openapi --source https://example.com/api-spec.json --output ~/Desktop --collection-name "Remote API"
|
||||
```
|
||||
|
||||
You can also export the collection as a JSON file:
|
||||
|
||||
```bash
|
||||
bru import openapi --source api.yml --output-file ~/Desktop/my-collection.json --collection-name "My API"
|
||||
```
|
||||
|
||||
Import Options:
|
||||
|
||||
| Option | Details |
|
||||
| ------------------------- | -------------------------------------------------- |
|
||||
| --source, -s | Path to the source file or URL (required) |
|
||||
| --output, -o | Path to the output directory |
|
||||
| --output-file, -f | Path to the output JSON file |
|
||||
| --collection-name, -n | Name for the imported collection |
|
||||
| --insecure | Skip SSL certificate validation when fetching from URLs |
|
||||
|
||||
## Command Line Options
|
||||
|
||||
| Option | Details |
|
||||
| ---------------------------- | ----------------------------------------------------------------------------- |
|
||||
| -h, --help | Show help |
|
||||
| --version | Show version number |
|
||||
| -r | Indicates a recursive run (default: false) |
|
||||
| --cacert [string] | CA certificate to verify peer against |
|
||||
| --env [string] | Specify environment to run with |
|
||||
| --env-var [string] | Overwrite a single environment variable, multiple usages possible |
|
||||
| -o, --output [string] | Path to write file results to |
|
||||
| -f, --format [string] | Format of the file results; available formats are "json" (default) or "junit" |
|
||||
| --reporter-json [string] | Path to generate a JSON report |
|
||||
| --reporter-junit [string] | Path to generate a JUnit report |
|
||||
| --reporter-html [string] | Path to generate an HTML report |
|
||||
| --insecure | Allow insecure server connections |
|
||||
| --tests-only | Only run requests that have tests |
|
||||
| --bail | Stop execution after a failure of a request, test, or assertion |
|
||||
| --csv-file-path | CSV file to run the collection with |
|
||||
| --reporter--skip-all-headers | Skip all headers in the report |
|
||||
| --reporter-skip-headers | Skip specific headers in the report |
|
||||
| --client-cert-config | Client certificate configuration by passing a JSON file |
|
||||
| --delay [number] | Add delay to each request |
|
||||
|
||||
## Scripting
|
||||
|
||||
Bruno cli returns the following exit status codes:
|
||||
|
||||
230
packages/bruno-cli/src/commands/import.js
Normal file
230
packages/bruno-cli/src/commands/import.js
Normal file
@@ -0,0 +1,230 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const chalk = require('chalk');
|
||||
const jsyaml = require('js-yaml');
|
||||
const axios = require('axios');
|
||||
const { openApiToBruno } = require('@usebruno/converters');
|
||||
const { exists, isDirectory, sanitizeName } = require('../utils/filesystem');
|
||||
const { createCollectionFromBrunoObject } = require('../utils/collection');
|
||||
|
||||
const command = 'import <type>';
|
||||
const desc = 'Import a collection from other formats';
|
||||
|
||||
const builder = (yargs) => {
|
||||
yargs
|
||||
.positional('type', {
|
||||
describe: 'Type of collection to import',
|
||||
type: 'string',
|
||||
choices: ['openapi']
|
||||
})
|
||||
.option('source', {
|
||||
alias: 's',
|
||||
describe: 'Path to the source file or URL',
|
||||
type: 'string',
|
||||
demandOption: true
|
||||
})
|
||||
.option('output', {
|
||||
alias: 'o',
|
||||
describe: 'Path to the output directory',
|
||||
type: 'string',
|
||||
conflicts: 'output-file'
|
||||
})
|
||||
.option('output-file', {
|
||||
alias: 'f',
|
||||
describe: 'Path to the output JSON file',
|
||||
type: 'string',
|
||||
conflicts: 'output'
|
||||
})
|
||||
.option('collection-name', {
|
||||
alias: 'n',
|
||||
describe: 'Name for the imported collection',
|
||||
type: 'string'
|
||||
})
|
||||
.option('insecure', {
|
||||
type: 'boolean',
|
||||
describe: 'Skip SSL certificate verification when fetching from URLs',
|
||||
default: false
|
||||
})
|
||||
.example('$0 import openapi --source api.yml --output ~/Desktop/my-collection --collection-name "My API"')
|
||||
.example('$0 import openapi -s api.yml -o ~/Desktop/my-collection -n "My API"')
|
||||
.example('$0 import openapi --source https://example.com/api-spec.json --output ~/Desktop --collection-name "Remote API"')
|
||||
.example('$0 import openapi --source https://self-signed.example.com/api.json --insecure --output ~/Desktop')
|
||||
.example('$0 import openapi --source api.yml --output-file ~/Desktop/my-collection.json --collection-name "My API"')
|
||||
.example('$0 import openapi -s api.yml -f ~/Desktop/my-collection.json -n "My API"');
|
||||
};
|
||||
|
||||
const isUrl = (str) => {
|
||||
try {
|
||||
return Boolean(new URL(str));
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const readOpenApiFile = async (source, options = {}) => {
|
||||
try {
|
||||
let content;
|
||||
|
||||
if (isUrl(source)) {
|
||||
// Handle URL input
|
||||
console.log(chalk.yellow(`Fetching specification from URL: ${source}`));
|
||||
try {
|
||||
const axiosOptions = {
|
||||
timeout: 30000, // 30 second timeout
|
||||
maxContentLength: 10 * 1024 * 1024,
|
||||
validateStatus: status => status >= 200 && status < 300
|
||||
};
|
||||
|
||||
// Skip SSL certificate validation if insecure flag is set
|
||||
if (options.insecure) {
|
||||
console.log(chalk.yellow('Warning: SSL certificate verification is disabled. Use with caution.'));
|
||||
axiosOptions.httpsAgent = new (require('https')).Agent({ rejectUnauthorized: false });
|
||||
}
|
||||
|
||||
const response = await axios.get(source, axiosOptions);
|
||||
content = response.data;
|
||||
} catch (error) {
|
||||
if (error.code === 'ECONNABORTED') {
|
||||
throw new Error('Request timed out. The server took too long to respond.');
|
||||
} else if (error.code === 'CERT_HAS_EXPIRED' || error.code === 'DEPTH_ZERO_SELF_SIGNED_CERT' ||
|
||||
error.code === 'ERR_TLS_CERT_ALTNAME_INVALID') {
|
||||
throw new Error(`SSL Certificate error: ${error.code}. Try using --insecure if you trust this source.`);
|
||||
} else if (error.response) {
|
||||
throw new Error(`Failed to fetch from URL: ${error.response.status} ${error.response.statusText}`);
|
||||
} else if (error.request) {
|
||||
throw new Error(`No response received from server. Check the URL and your network connection.`);
|
||||
} else {
|
||||
throw new Error(`Error fetching URL: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// If response is already an object, return it directly
|
||||
if (typeof content === 'object' && content !== null) {
|
||||
return content;
|
||||
}
|
||||
} else {
|
||||
// Handle file input
|
||||
if (!await exists(source)) {
|
||||
throw new Error(`File does not exist: ${source}`);
|
||||
}
|
||||
content = fs.readFileSync(source, 'utf8');
|
||||
}
|
||||
|
||||
// If content is a string, try to parse as JSON or YAML
|
||||
if (typeof content === 'string') {
|
||||
try {
|
||||
return JSON.parse(content);
|
||||
} catch (jsonError) {
|
||||
try {
|
||||
return jsyaml.load(content);
|
||||
} catch (yamlError) {
|
||||
throw new Error('Failed to parse content as JSON or YAML');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return content;
|
||||
} catch (error) {
|
||||
// Let the specific error handling from above propagate
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const handler = async (argv) => {
|
||||
try {
|
||||
const { type, source, output, outputFile, collectionName, insecure } = argv;
|
||||
|
||||
if (!type || type !== 'openapi') {
|
||||
console.error(chalk.red('Only OpenAPI import is supported currently'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!source) {
|
||||
console.error(chalk.red('Source file or URL is required'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!output && !outputFile) {
|
||||
console.error(chalk.red('Either --output or --output-file is required'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(chalk.yellow(`Reading OpenAPI specification from ${source}...`));
|
||||
|
||||
const openApiSpec = await readOpenApiFile(source, { insecure });
|
||||
|
||||
if (!openApiSpec) {
|
||||
console.error(chalk.red('Failed to parse OpenAPI specification'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(chalk.yellow('Converting OpenAPI specification to Bruno format...'));
|
||||
|
||||
// Convert OpenAPI to Bruno format
|
||||
let brunoCollection = openApiToBruno(openApiSpec);
|
||||
|
||||
// Override collection name if provided
|
||||
if (collectionName) {
|
||||
brunoCollection.name = collectionName;
|
||||
}
|
||||
|
||||
if (outputFile) {
|
||||
// Save as JSON file
|
||||
const outputPath = path.resolve(outputFile);
|
||||
fs.writeFileSync(outputPath, JSON.stringify(brunoCollection, null, 2));
|
||||
console.log(chalk.green(`Bruno collection saved as JSON to ${outputPath}`));
|
||||
} else if (output) {
|
||||
const resolvedOutput = path.resolve(output);
|
||||
|
||||
// Check if output is an existing directory
|
||||
const isOutputDirectory = await exists(resolvedOutput) && isDirectory(resolvedOutput);
|
||||
|
||||
// Determine the final output directory
|
||||
let outputDir;
|
||||
if (isOutputDirectory) {
|
||||
// If output is an existing directory, use collection name to create a subdirectory
|
||||
const dirName = sanitizeName(brunoCollection.name);
|
||||
outputDir = path.join(resolvedOutput, dirName);
|
||||
|
||||
// Check if this subfolder already exists
|
||||
if (await exists(outputDir)) {
|
||||
const dirContents = fs.readdirSync(outputDir);
|
||||
if (dirContents.length > 0) {
|
||||
console.error(chalk.red(`Output directory is not empty: ${outputDir}`));
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
// Create the subfolder
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
}
|
||||
} else {
|
||||
// If output doesn't exist or is not a directory, use it directly
|
||||
outputDir = resolvedOutput;
|
||||
|
||||
// Check if parent directory exists
|
||||
const parentDir = path.dirname(outputDir);
|
||||
if (!await exists(parentDir)) {
|
||||
console.error(chalk.red(`Parent directory does not exist: ${parentDir}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
}
|
||||
|
||||
await createCollectionFromBrunoObject(brunoCollection, outputDir);
|
||||
console.log(chalk.green(`Bruno collection created at ${outputDir}`));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(chalk.red(`Error: ${error.message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
command,
|
||||
desc,
|
||||
builder,
|
||||
handler,
|
||||
isUrl,
|
||||
readOpenApiFile
|
||||
};
|
||||
@@ -2,6 +2,7 @@ const fs = require('fs');
|
||||
const chalk = require('chalk');
|
||||
const path = require('path');
|
||||
const { forOwn, cloneDeep } = require('lodash');
|
||||
const { getRunnerSummary } = require('@usebruno/common/runner');
|
||||
const { exists, isFile, isDirectory } = require('../utils/filesystem');
|
||||
const { runSingleRequest } = require('../runner/run-single-request');
|
||||
const { bruToEnvJson, getEnvVars } = require('../utils/bru');
|
||||
@@ -11,55 +12,24 @@ 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 { findItemInCollection, getAllRequestsInFolder, createCollectionJsonFromPathname } = require('../utils/collection');
|
||||
const command = 'run [filename]';
|
||||
const desc = 'Run a request';
|
||||
|
||||
const printRunSummary = (results) => {
|
||||
let totalRequests = 0;
|
||||
let passedRequests = 0;
|
||||
let failedRequests = 0;
|
||||
let skippedRequests = 0;
|
||||
let totalAssertions = 0;
|
||||
let passedAssertions = 0;
|
||||
let failedAssertions = 0;
|
||||
let totalTests = 0;
|
||||
let passedTests = 0;
|
||||
let failedTests = 0;
|
||||
|
||||
for (const result of results) {
|
||||
totalRequests += 1;
|
||||
totalTests += result.testResults.length;
|
||||
totalAssertions += result.assertionResults.length;
|
||||
let anyFailed = false;
|
||||
let hasAnyTestsOrAssertions = false;
|
||||
for (const testResult of result.testResults) {
|
||||
hasAnyTestsOrAssertions = true;
|
||||
if (testResult.status === 'pass') {
|
||||
passedTests += 1;
|
||||
} else {
|
||||
anyFailed = true;
|
||||
failedTests += 1;
|
||||
}
|
||||
}
|
||||
for (const assertionResult of result.assertionResults) {
|
||||
hasAnyTestsOrAssertions = true;
|
||||
if (assertionResult.status === 'pass') {
|
||||
passedAssertions += 1;
|
||||
} else {
|
||||
anyFailed = true;
|
||||
failedAssertions += 1;
|
||||
}
|
||||
}
|
||||
if (!hasAnyTestsOrAssertions && result.skipped) {
|
||||
skippedRequests += 1;
|
||||
}
|
||||
else if (!hasAnyTestsOrAssertions && result.error) {
|
||||
failedRequests += 1;
|
||||
} else {
|
||||
passedRequests += 1;
|
||||
}
|
||||
}
|
||||
const {
|
||||
totalRequests,
|
||||
passedRequests,
|
||||
failedRequests,
|
||||
skippedRequests,
|
||||
errorRequests,
|
||||
totalAssertions,
|
||||
passedAssertions,
|
||||
failedAssertions,
|
||||
totalTests,
|
||||
passedTests,
|
||||
failedTests
|
||||
} = getRunnerSummary(results);
|
||||
|
||||
const maxLength = 12;
|
||||
|
||||
@@ -67,6 +37,9 @@ const printRunSummary = (results) => {
|
||||
if (failedRequests > 0) {
|
||||
requestSummary += `, ${chalk.red(`${failedRequests} failed`)}`;
|
||||
}
|
||||
if (errorRequests > 0) {
|
||||
requestSummary += `, ${chalk.red(`${errorRequests} error`)}`;
|
||||
}
|
||||
if (skippedRequests > 0) {
|
||||
requestSummary += `, ${chalk.magenta(`${skippedRequests} skipped`)}`;
|
||||
}
|
||||
@@ -93,170 +66,14 @@ const printRunSummary = (results) => {
|
||||
passedRequests,
|
||||
failedRequests,
|
||||
skippedRequests,
|
||||
errorRequests,
|
||||
totalAssertions,
|
||||
passedAssertions,
|
||||
failedAssertions,
|
||||
totalTests,
|
||||
passedTests,
|
||||
failedTests
|
||||
};
|
||||
};
|
||||
|
||||
const createCollectionFromPath = (collectionPath) => {
|
||||
const environmentsPath = path.join(collectionPath, `environments`);
|
||||
const getFilesInOrder = (collectionPath) => {
|
||||
let collection = {
|
||||
pathname: collectionPath
|
||||
};
|
||||
const traverse = (currentPath) => {
|
||||
const filesInCurrentDir = fs.readdirSync(currentPath);
|
||||
|
||||
if (currentPath.includes('node_modules')) {
|
||||
return;
|
||||
}
|
||||
const currentDirItems = [];
|
||||
for (const file of filesInCurrentDir) {
|
||||
const filePath = path.join(currentPath, file);
|
||||
const stats = fs.lstatSync(filePath);
|
||||
if (
|
||||
stats.isDirectory() &&
|
||||
filePath !== environmentsPath &&
|
||||
!filePath.startsWith('.git') &&
|
||||
!filePath.startsWith('node_modules')
|
||||
) {
|
||||
let folderItem = { name: file, pathname: filePath, type: 'folder', items: traverse(filePath) }
|
||||
const folderBruFilePath = path.join(filePath, 'folder.bru');
|
||||
const folderBruFileExists = fs.existsSync(folderBruFilePath);
|
||||
if(folderBruFileExists) {
|
||||
const folderBruContent = fs.readFileSync(folderBruFilePath, 'utf8');
|
||||
let folderBruJson = collectionBruToJson(folderBruContent);
|
||||
folderItem.root = folderBruJson;
|
||||
}
|
||||
currentDirItems.push(folderItem);
|
||||
}
|
||||
}
|
||||
|
||||
for (const file of filesInCurrentDir) {
|
||||
if (['collection.bru', 'folder.bru'].includes(file)) {
|
||||
continue;
|
||||
}
|
||||
const filePath = path.join(currentPath, file);
|
||||
const stats = fs.lstatSync(filePath);
|
||||
|
||||
if (!stats.isDirectory() && path.extname(filePath) === '.bru') {
|
||||
const bruContent = fs.readFileSync(filePath, 'utf8');
|
||||
const bruJson = bruToJson(bruContent);
|
||||
currentDirItems.push({
|
||||
name: file,
|
||||
pathname: filePath,
|
||||
...bruJson
|
||||
});
|
||||
}
|
||||
}
|
||||
return currentDirItems;
|
||||
};
|
||||
collection.items = traverse(collectionPath);
|
||||
return collection;
|
||||
};
|
||||
return getFilesInOrder(collectionPath);
|
||||
};
|
||||
|
||||
const getBruFilesRecursively = (dir, testsOnly) => {
|
||||
const environmentsPath = 'environments';
|
||||
const collection = {};
|
||||
|
||||
const getFilesInOrder = (dir) => {
|
||||
let bruJsons = [];
|
||||
|
||||
const traverse = (currentPath) => {
|
||||
const filesInCurrentDir = fs.readdirSync(currentPath);
|
||||
|
||||
if (currentPath.includes('node_modules')) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const file of filesInCurrentDir) {
|
||||
const filePath = path.join(currentPath, file);
|
||||
const stats = fs.lstatSync(filePath);
|
||||
|
||||
// todo: we might need a ignore config inside bruno.json
|
||||
if (
|
||||
stats.isDirectory() &&
|
||||
filePath !== environmentsPath &&
|
||||
!filePath.startsWith('.git') &&
|
||||
!filePath.startsWith('node_modules')
|
||||
) {
|
||||
traverse(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
const currentDirBruJsons = [];
|
||||
for (const file of filesInCurrentDir) {
|
||||
if (['collection.bru', 'folder.bru'].includes(file)) {
|
||||
continue;
|
||||
}
|
||||
const filePath = path.join(currentPath, file);
|
||||
const stats = fs.lstatSync(filePath);
|
||||
|
||||
if (!stats.isDirectory() && path.extname(filePath) === '.bru') {
|
||||
const bruContent = fs.readFileSync(filePath, 'utf8');
|
||||
const bruJson = bruToJson(bruContent);
|
||||
const requestHasTests = bruJson.request?.tests;
|
||||
const requestHasActiveAsserts = bruJson.request?.assertions.some((x) => x.enabled) || false;
|
||||
|
||||
if (testsOnly) {
|
||||
if (requestHasTests || requestHasActiveAsserts) {
|
||||
currentDirBruJsons.push({
|
||||
bruFilepath: filePath,
|
||||
bruJson
|
||||
});
|
||||
}
|
||||
} else {
|
||||
currentDirBruJsons.push({
|
||||
bruFilepath: filePath,
|
||||
bruJson
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// order requests by sequence
|
||||
currentDirBruJsons.sort((a, b) => {
|
||||
const aSequence = a.bruJson.seq || 0;
|
||||
const bSequence = b.bruJson.seq || 0;
|
||||
return aSequence - bSequence;
|
||||
});
|
||||
|
||||
bruJsons = bruJsons.concat(currentDirBruJsons);
|
||||
};
|
||||
|
||||
traverse(dir);
|
||||
return bruJsons;
|
||||
};
|
||||
|
||||
return getFilesInOrder(dir);
|
||||
};
|
||||
|
||||
const getCollectionRoot = (dir) => {
|
||||
const collectionRootPath = path.join(dir, 'collection.bru');
|
||||
const exists = fs.existsSync(collectionRootPath);
|
||||
if (!exists) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(collectionRootPath, 'utf8');
|
||||
return collectionBruToJson(content);
|
||||
};
|
||||
|
||||
const getFolderRoot = (dir) => {
|
||||
const folderRootPath = path.join(dir, 'folder.bru');
|
||||
const exists = fs.existsSync(folderRootPath);
|
||||
if (!exists) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(folderRootPath, 'utf8');
|
||||
return collectionBruToJson(content);
|
||||
};
|
||||
|
||||
const getJsSandboxRuntime = (sandbox) => {
|
||||
@@ -351,7 +168,6 @@ const builder = async (yargs) => {
|
||||
type:"number",
|
||||
description: "Delay between each requests (in miliseconds)"
|
||||
})
|
||||
|
||||
.example('$0 run request.bru', 'Run a request')
|
||||
.example('$0 run request.bru --env local', 'Run a request with the environment set to local')
|
||||
.example('$0 run folder', 'Run all requests in a folder')
|
||||
@@ -421,25 +237,8 @@ const handler = async function (argv) {
|
||||
} = argv;
|
||||
const collectionPath = process.cwd();
|
||||
|
||||
// todo
|
||||
// right now, bru must be run from the root of the collection
|
||||
// will add support in the future to run it from anywhere inside the collection
|
||||
const brunoJsonPath = path.join(collectionPath, 'bruno.json');
|
||||
const brunoJsonExists = await exists(brunoJsonPath);
|
||||
if (!brunoJsonExists) {
|
||||
console.error(chalk.red(`You can run only at the root of a collection`));
|
||||
process.exit(constants.EXIT_STATUS.ERROR_NOT_IN_COLLECTION);
|
||||
}
|
||||
|
||||
const brunoConfigFile = fs.readFileSync(brunoJsonPath, 'utf8');
|
||||
const brunoConfig = JSON.parse(brunoConfigFile);
|
||||
const collectionRoot = getCollectionRoot(collectionPath);
|
||||
let collection = createCollectionFromPath(collectionPath);
|
||||
collection = {
|
||||
brunoConfig,
|
||||
root: collectionRoot,
|
||||
...collection
|
||||
}
|
||||
let collection = createCollectionJsonFromPathname(collectionPath);
|
||||
const { root: collectionRoot, brunoConfig } = collection;
|
||||
|
||||
if (clientCertConfig) {
|
||||
try {
|
||||
@@ -475,7 +274,6 @@ const handler = async function (argv) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (filename && filename.length) {
|
||||
const pathExists = await exists(filename);
|
||||
if (!pathExists) {
|
||||
@@ -597,54 +395,39 @@ const handler = async function (argv) {
|
||||
const _isFile = isFile(filename);
|
||||
let results = [];
|
||||
|
||||
let bruJsons = [];
|
||||
let requestItems = [];
|
||||
|
||||
if (_isFile) {
|
||||
console.log(chalk.yellow('Running Request \n'));
|
||||
const bruContent = fs.readFileSync(filename, 'utf8');
|
||||
const bruJson = bruToJson(bruContent);
|
||||
bruJsons.push({
|
||||
bruFilepath: filename,
|
||||
bruJson
|
||||
});
|
||||
const requestItem = bruToJson(bruContent);
|
||||
requestItem.pathname = path.resolve(collectionPath, filename);
|
||||
requestItems.push(requestItem);
|
||||
}
|
||||
|
||||
const _isDirectory = isDirectory(filename);
|
||||
if (_isDirectory) {
|
||||
if (!recursive) {
|
||||
console.log(chalk.yellow('Running Folder \n'));
|
||||
const files = fs.readdirSync(filename);
|
||||
const bruFiles = files.filter((file) => !['folder.bru'].includes(file) && file.endsWith('.bru'));
|
||||
|
||||
for (const bruFile of bruFiles) {
|
||||
const bruFilepath = path.join(filename, bruFile);
|
||||
const bruContent = fs.readFileSync(bruFilepath, 'utf8');
|
||||
const bruJson = bruToJson(bruContent);
|
||||
const requestHasTests = bruJson.request?.tests;
|
||||
const requestHasActiveAsserts = bruJson.request?.assertions.some((x) => x.enabled) || false;
|
||||
if (testsOnly) {
|
||||
if (requestHasTests || requestHasActiveAsserts) {
|
||||
bruJsons.push({
|
||||
bruFilepath,
|
||||
bruJson
|
||||
});
|
||||
}
|
||||
} else {
|
||||
bruJsons.push({
|
||||
bruFilepath,
|
||||
bruJson
|
||||
});
|
||||
}
|
||||
}
|
||||
bruJsons.sort((a, b) => {
|
||||
const aSequence = a.bruJson.seq || 0;
|
||||
const bSequence = b.bruJson.seq || 0;
|
||||
return aSequence - bSequence;
|
||||
});
|
||||
} else {
|
||||
console.log(chalk.yellow('Running Folder Recursively \n'));
|
||||
}
|
||||
const resolvedFilepath = path.resolve(filename);
|
||||
if (resolvedFilepath === collectionPath) {
|
||||
requestItems = getAllRequestsInFolder(collection?.items, recursive);
|
||||
} else {
|
||||
const folderItem = findItemInCollection(collection, resolvedFilepath);
|
||||
if (folderItem) {
|
||||
requestItems = getAllRequestsInFolder(folderItem.items, recursive);
|
||||
}
|
||||
}
|
||||
|
||||
bruJsons = getBruFilesRecursively(filename, testsOnly);
|
||||
if (testsOnly) {
|
||||
requestItems = requestItems.filter((iter) => {
|
||||
const requestHasTests = iter.request?.tests;
|
||||
const requestHasActiveAsserts = iter.request?.assertions.some((x) => x.enabled) || false;
|
||||
return requestHasTests || requestHasActiveAsserts;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -656,11 +439,10 @@ const handler = async function (argv) {
|
||||
if (itemPathname && !itemPathname?.endsWith('.bru')) {
|
||||
itemPathname = `${itemPathname}.bru`;
|
||||
}
|
||||
const bruJson = cloneDeep(findItemInCollection(collection, itemPathname));
|
||||
if (bruJson) {
|
||||
const requestItem = cloneDeep(findItemInCollection(collection, itemPathname));
|
||||
if (requestItem) {
|
||||
const res = await runSingleRequest(
|
||||
itemPathname,
|
||||
bruJson,
|
||||
requestItem,
|
||||
collectionPath,
|
||||
runtimeVariables,
|
||||
envVars,
|
||||
@@ -679,14 +461,13 @@ const handler = async function (argv) {
|
||||
|
||||
let currentRequestIndex = 0;
|
||||
let nJumps = 0; // count the number of jumps to avoid infinite loops
|
||||
while (currentRequestIndex < bruJsons.length) {
|
||||
const iter = cloneDeep(bruJsons[currentRequestIndex]);
|
||||
const { bruFilepath, bruJson } = iter;
|
||||
while (currentRequestIndex < requestItems.length) {
|
||||
const requestItem = cloneDeep(requestItems[currentRequestIndex]);
|
||||
const { pathname } = requestItem;
|
||||
|
||||
const start = process.hrtime();
|
||||
const result = await runSingleRequest(
|
||||
bruFilepath,
|
||||
bruJson,
|
||||
requestItem,
|
||||
collectionPath,
|
||||
runtimeVariables,
|
||||
envVars,
|
||||
@@ -698,7 +479,7 @@ const handler = async function (argv) {
|
||||
runSingleRequestByPathname
|
||||
);
|
||||
|
||||
const isLastRun = currentRequestIndex === bruJsons.length - 1;
|
||||
const isLastRun = currentRequestIndex === requestItems.length - 1;
|
||||
const isValidDelay = !Number.isNaN(delay) && delay > 0;
|
||||
if(isValidDelay && !isLastRun){
|
||||
console.log(chalk.yellow(`Waiting for ${delay}ms or ${(delay/1000).toFixed(3)}s before next request.`));
|
||||
@@ -712,7 +493,7 @@ const handler = async function (argv) {
|
||||
results.push({
|
||||
...result,
|
||||
runtime: process.hrtime(start)[0] + process.hrtime(start)[1] / 1e9,
|
||||
suitename: bruFilepath.replace('.bru', '')
|
||||
suitename: pathname.replace('.bru', '')
|
||||
});
|
||||
|
||||
if (reporterSkipAllHeaders) {
|
||||
@@ -746,7 +527,7 @@ const handler = async function (argv) {
|
||||
|
||||
// bail if option is set and there is a failure
|
||||
if (bail) {
|
||||
const requestFailure = result?.error;
|
||||
const requestFailure = result?.error && !result?.skipped;
|
||||
const testFailure = result?.testResults?.find((iter) => iter.status === 'fail');
|
||||
const assertionFailure = result?.assertionResults?.find((iter) => iter.status === 'fail');
|
||||
if (requestFailure || testFailure || assertionFailure) {
|
||||
@@ -770,7 +551,7 @@ const handler = async function (argv) {
|
||||
if (nextRequestName === null) {
|
||||
break;
|
||||
}
|
||||
const nextRequestIdx = bruJsons.findIndex((iter) => iter.bruJson.name === nextRequestName);
|
||||
const nextRequestIdx = requestItems.findIndex((iter) => iter.name === nextRequestName);
|
||||
if (nextRequestIdx >= 0) {
|
||||
currentRequestIndex = nextRequestIdx;
|
||||
} else {
|
||||
|
||||
@@ -32,7 +32,7 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc
|
||||
});
|
||||
});
|
||||
|
||||
const _interpolate = (str) => {
|
||||
const _interpolate = (str, { escapeJSONStrings } = {}) => {
|
||||
if (!str || !str.length || typeof str !== 'string') {
|
||||
return str;
|
||||
}
|
||||
@@ -51,7 +51,7 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc
|
||||
}
|
||||
};
|
||||
|
||||
return interpolate(str, combinedVars);
|
||||
return interpolate(str, combinedVars, { escapeJSONStrings });
|
||||
};
|
||||
|
||||
request.url = _interpolate(request.url);
|
||||
@@ -67,14 +67,14 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc
|
||||
if (typeof request.data === 'object') {
|
||||
try {
|
||||
let parsed = JSON.stringify(request.data);
|
||||
parsed = _interpolate(parsed);
|
||||
parsed = _interpolate(parsed, { escapeJSONStrings: true });
|
||||
request.data = JSON.parse(parsed);
|
||||
} catch (err) {}
|
||||
}
|
||||
|
||||
if (typeof request.data === 'string') {
|
||||
if (request?.data?.length) {
|
||||
request.data = _interpolate(request.data);
|
||||
request.data = _interpolate(request.data, { escapeJSONStrings: true });
|
||||
}
|
||||
}
|
||||
} else if (contentType === 'application/x-www-form-urlencoded') {
|
||||
@@ -156,6 +156,37 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc
|
||||
delete request.basicAuth;
|
||||
}
|
||||
|
||||
if (request?.oauth2?.grantType) {
|
||||
switch (request.oauth2.grantType) {
|
||||
case 'password':
|
||||
request.oauth2.accessTokenUrl = _interpolate(request.oauth2.accessTokenUrl) || '';
|
||||
request.oauth2.refreshTokenUrl = _interpolate(request.oauth2.refreshTokenUrl) || '';
|
||||
request.oauth2.username = _interpolate(request.oauth2.username) || '';
|
||||
request.oauth2.password = _interpolate(request.oauth2.password) || '';
|
||||
request.oauth2.clientId = _interpolate(request.oauth2.clientId) || '';
|
||||
request.oauth2.clientSecret = _interpolate(request.oauth2.clientSecret) || '';
|
||||
request.oauth2.scope = _interpolate(request.oauth2.scope) || '';
|
||||
request.oauth2.credentialsPlacement = _interpolate(request.oauth2.credentialsPlacement) || '';
|
||||
request.oauth2.tokenPlacement = _interpolate(request.oauth2.tokenPlacement) || '';
|
||||
request.oauth2.tokenHeaderPrefix = _interpolate(request.oauth2.tokenHeaderPrefix) || '';
|
||||
request.oauth2.tokenQueryKey = _interpolate(request.oauth2.tokenQueryKey) || '';
|
||||
break;
|
||||
case 'client_credentials':
|
||||
request.oauth2.accessTokenUrl = _interpolate(request.oauth2.accessTokenUrl) || '';
|
||||
request.oauth2.refreshTokenUrl = _interpolate(request.oauth2.refreshTokenUrl) || '';
|
||||
request.oauth2.clientId = _interpolate(request.oauth2.clientId) || '';
|
||||
request.oauth2.clientSecret = _interpolate(request.oauth2.clientSecret) || '';
|
||||
request.oauth2.scope = _interpolate(request.oauth2.scope) || '';
|
||||
request.oauth2.credentialsPlacement = _interpolate(request.oauth2.credentialsPlacement) || '';
|
||||
request.oauth2.tokenPlacement = _interpolate(request.oauth2.tokenPlacement) || '';
|
||||
request.oauth2.tokenHeaderPrefix = _interpolate(request.oauth2.tokenHeaderPrefix) || '';
|
||||
request.oauth2.tokenQueryKey = _interpolate(request.oauth2.tokenQueryKey) || '';
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (request.awsv4config) {
|
||||
request.awsv4config.accessKeyId = _interpolate(request.awsv4config.accessKeyId) || '';
|
||||
request.awsv4config.secretAccessKey = _interpolate(request.awsv4config.secretAccessKey) || '';
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user