Compare commits

...

90 Commits

Author SHA1 Message Date
lohit
b3a99a4d85 Merge pull request #5030 from stupidly-logical/fix/gen_code_auth_header
fix: Add null check for collection root in snippet generator #5029
2025-07-03 20:31:16 +05:30
lohit
bbfa2b39a0 Merge pull request #5036 from maintainer-bruno/feat/fix-params-table-scroll
fix: params table default scroll
2025-07-03 20:30:51 +05:30
lohit
1a93eabf01 request/response pane styling fixes (#5025) 2025-07-03 13:35:05 +05:30
lohit
df1c5f9363 Merge pull request #5028 from maintainer-bruno/fix/tests-2.7.0
fix: unit tests and e2e
2025-07-03 13:34:28 +05:30
Maintainer Bruno
803d2d96c9 fix: unit tests and e2e 2025-07-03 13:31:19 +05:30
Anoop M D
99873af281 Merge pull request #5020 from lohxt1/pm_translations_requestConfig_updates
handle `requestConfig` translations for variable references in `pm.sendRequest` calls
2025-07-01 20:23:41 +05:30
lohxt1
1b63798ff3 handle requestConfig translations when passed to pm.sendRequest as a variable 2025-07-01 20:04:42 +05:30
Anoop M D
c90d607046 Merge pull request #4973 from lohxt1/send_request_default_options
fix: set default proxy value as `false` for `bru.sendRequest`' axios request config
2025-07-01 13:02:58 +05:30
Pooja
c6c3931446 feat: support onFail api to catch errors in pre req (#4581)
support `onFail` api to catch errors in pre req

---------

Co-authored-by: Anoop M D <anoop.md1421@gmail.com>
Co-authored-by: lohit <lohit@usebruno.com>
2025-06-27 19:42:00 +05:30
Art051
10e872c6ab Merge pull request #4752 from Art051/bugfix/4749-generate-code-error-with-binary-file-request
Bugfix/4749 generate code error with binary file request
2025-06-27 19:35:37 +05:30
lohit
6792cc26bd Merge pull request #4999 from ganesh-bruno/feat/remove-beta-key
removed BETA keyword
2025-06-27 19:29:08 +05:30
lohit
c76d99d1b0 Merge pull request #4995 from pooja-bruno/fix/include-unsaved-changes-in-generate-code
fix: include unsaved changes in generate code
2025-06-27 19:11:28 +05:30
ganesh-bruno
b813c916b8 removed BETA keyword 2025-06-27 18:51:15 +05:30
lohit
fab9d00566 Merge pull request #3973 from betawait/bugfix/bug-remove-content-type-in-post-with-no-body
Fix: Allow empty Content-Type when no body (#1693)
2025-06-27 17:49:34 +05:30
lohit
afcd7395d9 Merge pull request #4980 from lohxt1/codemirror_autocomplete_logic_refactor
codemirror `api/variables` autocomplete refactor
2025-06-27 17:18:37 +05:30
lohit
ed9c61908d Merge branch 'main' into codemirror_autocomplete_logic_refactor 2025-06-27 17:17:15 +05:30
lohit
999e3e5b71 Merge pull request #4992 from maintainer-bruno/fix/curl-query-parsing
fix(import): handle repeated query keys and improve error handling in curl import
2025-06-27 17:10:16 +05:30
lohit
81ae8db1a9 Merge pull request #4958 from sanjaikumar-bruno/pr-706-improved
Improved feat: add bulk edit mode for request headers
2025-06-27 17:09:46 +05:30
sanjai0py
f2b5b6f783 refactor: implementation of bulk edit functionality for query parameters and request headers
refactor: integrate BulkEditCodeEditor for bulk editing of query parameters and request headers

refactor: refactor BulkEditCodeEditor component folder structure nad fix Bulk Edit button styles

refactor: now the queryparams are updated in both the ways

style: fix indentation

reverting the style changes which  fixes the alignment of the bulkedit button

refactor: add onSave prop to BulkEditCodeEditor and update value handling

feat: add onSave prop to BulkEditCodeEditor for improved header management

added onRun prop to BulkEditCodeEditor, QueryParams, and RequestHeaders

refactor: renamed BulkEditCodeEditor to BulkEditor and update the references, and updated names for bulkEdit states
2025-06-27 17:06:29 +05:30
Chris Casola
e8eab46f48 feat: add bulk edit mode for request headers
Closes #185
2025-06-27 17:05:15 +05:30
lohit
bb913d32bc Merge pull request #4987 from naman-bruno/bugfix/oauth2-scope
Remove scope parameter from token request when empty
2025-06-27 15:13:03 +05:30
lohit
2ea59dcdae Merge pull request #4994 from maintainer-bruno/fix/minor-layout-fixes
fix(layout): minor layout css fixes
2025-06-27 14:53:54 +05:30
pooja-bruno
bbdf514098 rm: optional chaining 2025-06-27 13:48:53 +05:30
pooja-bruno
a0950dc4f3 rm: condition 2025-06-27 13:32:41 +05:30
pooja-bruno
d65ae78119 rm: comment 2025-06-27 13:16:11 +05:30
pooja-bruno
e6afbc75ff fix: authHeaders 2025-06-27 13:13:09 +05:30
Maintainer Bruno
47e420dec1 fix(layout): minor layout css fixes 2025-06-27 13:00:52 +05:30
pooja-bruno
1d6566679b fix: include unsaved changes in generate code 2025-06-27 12:56:21 +05:30
Maintainer Bruno
535865fdeb fix(import): handle repeated query keys and improve error handling in curl import 2025-06-27 00:08:10 +05:30
naman-bruno
5065b2ac37 fix: oauth2 scope 2025-06-26 17:26:59 +05:30
lohit
6349e9b816 fix: oauth2 tokenHeaderPrefix can be set to an empty string value (#4928)
* ~ only prefill `Bearer` as token prefix only when the oauth2 is selected as the auth type for the first time
~ check if tokenPrefix is present before adding a space before the access_token value in the header

* review comment fixes

---------

Co-authored-by: lohit <lohit@usebruno.com>
2025-06-26 15:53:14 +05:30
lohit
eb70883127 codemirror api/variables autocomplete refactor 2025-06-26 14:38:48 +05:30
lohit
1e83b3b35c Feat: Update serialization logic for application/x-www-form-urlencoded body type (#4943)
* fix: update qs.stringify to use repeat array format for url serialization

* fix(cli): update qs.stringify to use repeat array format for url serialization

* feat(tests): add URL serialization test case for Duplicate Keys

* feat(cli): refactor formUrlEncoded handling to use buildFormUrlEncodedPayload function

* fix(cli): standardize quotes in qs.stringify for form-urlencoded data

* fix(electron): standardize quotes in qs.stringify for form-urlencoded data
2025-06-26 13:52:56 +05:30
Pragadesh-45
ef18805008 fix(electron): standardize quotes in qs.stringify for form-urlencoded data 2025-06-26 12:05:02 +05:45
Pragadesh-45
5d51a528d7 fix(cli): standardize quotes in qs.stringify for form-urlencoded data 2025-06-26 12:04:34 +05:45
Pooja
ff0ceb2879 feat: add dropdown to select language and add lib selector in code gen (#4345)
* feat: add dropdown to select language and add lib selector in code gen

* add: checkbox for interpolation

* rm: url should interpolate from url

* add: search in dropdown

* fixes

* add: autofocus for search

* add: arrow navigation in select

* fix

code improvements

fix

rm: editor wrapper

rm: font-size

improvement

rm: custom select

rm comments and add sparql mode

rm: styles

* add: tests and fixes

* fixes: file naming

* rm: comments

* fix

* fix: unit tests

* improvements

* fixes

* fix: indentation

* fix

* fixes: CodeViewToolbar

* trim: extra spaces
2025-06-25 20:26:42 +05:30
Pooja
4d7c044eba Fix: undefined auth fields in folder-level authentication (#4907) 2025-06-25 20:25:53 +05:30
ganesh
3a92cb4eda Fix: Made reporter-skip-headers option case-insensitive in bruno-cli (#4799) 2025-06-25 16:08:42 +05:30
Bacteria
6244679d5b Merge pull request #4956 from bacteriostat/feature/single-line-editor-placeholder
feat: Add placeholder for SingleLineEditor
2025-06-25 16:00:22 +05:30
lohit
59c1b6b675 set default proxy value as false for send_request axios request config 2025-06-25 12:07:41 +05:30
Anoop M D
92a0f093db Merge pull request #4970 from ganesh-bruno/fix/remeove-runtime-var-note
Removed text from runtime var section
2025-06-24 22:00:18 +05:30
lohit
39dccd4b5f Merge pull request #4969 from lohxt1/send_request_default_options
add explicit HTTP agents with keepAlive to `bru.sendRequest` axios request config
2025-06-24 19:53:58 +05:30
lohit
674820f7c9 Merge pull request #4959 from maintainer-bruno/feat/curl-parser
fix(import): curl parser library
2025-06-24 19:45:20 +05:30
ganesh-bruno
f138b126f3 removed text fron runtime var 2025-06-24 19:24:15 +05:30
lohit
efaac453ce feat: implement vertical layout for response pane and enhance drag (#4957) 2025-06-24 19:22:05 +05:30
lohit
879c124aec add explicit HTTP agents with keepAlive to bru.sendRequest axios instance 2025-06-24 17:12:17 +05:30
sanish chirayath
9fe13f1868 Fix: postman collection fails when auth object missing auth values (#4794)
* refactor: streamline authentication handling in postman-to-bruno.js by using a switch statement and introducing AUTH_TYPES constant for better readability and maintainability

* feat: enhance authentication handling in postman-to-bruno.js to manage missing auth values across collection, folder, and request levels, ensuring a default mode of 'none'

* fix: update authentication handling in postman-to-bruno.js to correctly set auth mode based on provided auth type

* fix: update authentication tests to ensure default values are set for various auth types in postman-to-bruno
2025-06-24 16:32:32 +05:30
sanish chirayath
2bbfb28090 fix: handle falsy values in Postman environment and collection variables (#4924)
* fix: handle falsy values in Postman environment and collection variables

* Updated the `postman-env-to-bruno-env` and `postman-to-bruno` converters to handle cases where variable keys or values are falsy, ensuring they default to empty strings.
* Added unit tests to verify the correct handling of falsy values in both environment and collection variables.

* fix: filter out null/undefined keys and values in Postman variable imports

* Updated the `postman-env-to-bruno-env` and `postman-to-bruno` converters to filter out variables with null keys and values during import.
* Removed redundant test cases for empty variables in the corresponding unit tests.
2025-06-24 15:58:29 +05:30
Maintainer Bruno
3c65642e92 fix(import): curl parser library 2025-06-24 02:31:49 +05:30
Pragadesh-45
cf5f52b7b9 feat(cli): refactor formUrlEncoded handling to use buildFormUrlEncodedPayload function 2025-06-23 18:49:29 +05:45
Pragadesh-45
04d0439c9d feat(tests): add URL serialization test case for Duplicate Keys 2025-06-23 18:14:01 +05:45
Anoop M D
f1116c3008 feat: implement vertical layout for response pane and enhance drag 2025-06-22 19:12:33 +05:30
Yash
bbf4ad6b98 Enable variable tootlip in json request body (#4885)
* Enable variable tootlip in json request body

* fix: enhance variable value popover and add test coverage

---------

Co-authored-by: Maintainer Bruno <code@usebruno.com>
2025-06-20 16:15:11 +05:30
Phil Jones
3fe3eec465 Add support for integer and boolean in OpenAPI to Bruno converter (#4734) 2025-06-20 12:16:41 +05:30
Johann Kaspar Lieberwirth
a93b05fd6e Update wording for clarification. Add tooltip. (#4761)
* Update wording for clarification. Add tooltip.

* Update hint to match deafult style
2025-06-20 12:12:05 +05:30
Henri Parquet
da25d46df4 feature: add randomNanoId to dynamic variables (#4932) 2025-06-20 12:11:44 +05:30
Pragadesh-45
0d13d40cd7 fix(cli): update qs.stringify to use repeat array format for url serialization 2025-06-19 20:02:25 +05:45
Pragadesh-45
4664fd60b5 fix: update qs.stringify to use repeat array format for url serialization 2025-06-19 20:01:36 +05:45
maintainer-bruno
65ba984c2f Merge pull request #1037 from Nikolai2038/docs/update-linux-installation-instructions-via-apt
docs(#1036): Update linux installation instructions via apt
2025-06-19 17:19:45 +05:30
Anoop M D
8355b67bae Merge pull request #4859 from georgegiosue/docs/update-contributing-es
Update Spanish contribution guide for clarity and accuracy
2025-06-19 14:32:55 +05:30
Nikolai Ivanov
9b3fe2fd97 Add Debian dependencies (in particular, for "libasound2") (#2356)
See https://github.com/usebruno/bruno/pull/1037#discussion_r1403537930
2025-06-18 20:27:35 +05:30
Sanjai Kumar
34614f039f Autocomplete random variables (#4695)
* Feature: adding dynamic variable support (#3609)


Co-authored-by: Raghav Sethi <109696225+rsxc@users.noreply.github.com>
Co-authored-by: sanjai0py <sanjailucifer666@gmail.com>
2025-06-18 20:06:45 +05:30
Pooja
acd42eaa1b add: pre and post in report template (#4931) 2025-06-18 17:54:15 +05:30
Anoop M D
aebc8241cc Merge pull request #4923 from maintainer-bruno/fix/e2etest-dependencies
fix(workflow): ensure E2E test collection dependencies are installed …
2025-06-17 14:46:55 +05:30
Maintainer Bruno
0eda1b761d fix(workflow): ensure E2E test collection dependencies are installed in GitHub Actions 2025-06-17 13:40:06 +05:30
lohit
a05f7cb686 Merge pull request #4918 from lohxt1/bru_send_request_fixes
bru.sendRequest translation fixes
2025-06-17 00:26:39 +05:30
lohit
745a71700c add await keyword to the translated bru.sendRequest function calls (#4906)
* add await keyword for the bru.sendRequest postman translations

---------

Co-authored-by: lohit <lohit@usebruno.com>
2025-06-16 22:50:45 +05:30
Anoop M D
ac9c190b41 Merge pull request #4914 from naman-bruno/bugfix/timeline-scroll
fix: timeline scroll
2025-06-16 22:48:44 +05:30
Pragadesh-45
1a1a230a1e Merge pull request #4901 from Pragadesh-45/feat/support-multiple-run-cli-v1
Co-authored-by: William Quintal <william95quintalwilliam@outlook.com>
Feat: Enhance run command to accept multiple inputs for requests and folders in Bruno CLI (Improves: #2956) (Fixes: #2955)
2025-06-16 22:27:34 +05:30
Anoop M D
b2e02b7762 Merge pull request #4908 from Pragadesh-45/feature/support-json-env-files
feat(cli): add support for environment file input in run command
2025-06-16 22:19:27 +05:30
naman-bruno
9cbfeccbed fix: timeline-scroll 2025-06-16 21:53:38 +05:30
Pragadesh-45
4725300c41 feat(cli): add support for environment file input in run command 2025-06-16 19:34:56 +05:45
naman-bruno
f2aedf780d Fix: showing test script errors (#4902)
* fix: catch errors in tests
2025-06-14 22:20:24 +05:30
lohit
f03047a2f9 feat: bru.sendRequest api (#4867)
* feat: bru.sendRequest api

* updated the postman-translations logic to handle `pm.sendRequest` to `bru.sendRequest` translations, and added unit tests

* ~ removed `maxRedirects` and `proxy` values for sendRequest axios-instance
~ fixed the imports for the `send-request-transformer` function
~ `sendRequest` and `runRequest` will return same response object in both safe and developer mode
~ sendRequest function optimization

* revert sendRequest to async function, added a testcase for sendRequest with url string

* sendRequest callback errors handling

* updated tests and added await for the callbacks

---------

Co-authored-by: lohit <lohit@usebruno.com>
2025-06-14 22:18:31 +05:30
lohit
a7ba23d97e Merge pull request #4886 from sanish-bruno/fix/bearer-undefined
fix: handle undefined bearer token to send an empty string instead
2025-06-14 21:50:08 +05:30
lohit
2521e980ea Merge pull request #4514 from jonman5/fix/digest-headers-split
Fix Digest auth header field key value extraction
2025-06-14 20:46:18 +05:30
lohit
1c118fa04a feat: add prompt for handling large responses (#4866)
* feat: add prompt for handling large responses

- Add `formatSize` utility function to format response size
- Add unit tests for `formatSize` utility function

* fix: update danger color in light theme
2025-06-14 20:44:08 +05:30
Anoop M D
b6fb5e02d4 Merge pull request #4893 from stupidly-logical/fix/watcher_err_handling
Fix watcher error message typo
2025-06-14 13:51:12 +05:30
Yash
5313704d84 Fix watcher error message typo 2025-06-14 13:25:21 +05:30
Anoop M D
b147f14fef Merge pull request #4758 from ShrutiShahi18/main
Added Hindi translation of Readme file
2025-06-13 22:31:06 +05:30
sanish-bruno
66fe1528df add: new Bearer Auth undefined test case and update Authorization header format 2025-06-13 14:42:57 +05:30
sanish-bruno
a598cda624 fix: handle undefined bearer token to send an empty string instead 2025-06-13 14:16:02 +05:30
ramki-bruno
69f218cc16 Merge branch 'main' into docs/update-linux-installation-instructions-via-apt 2025-06-12 18:36:45 +05:30
Pragadesh-45
e1c12ea699 fix: update danger color in light theme 2025-06-11 22:57:45 +05:45
Pragadesh-45
9801e91720 feat: add prompt for handling large responses
- Add `formatSize` utility function to format response size
- Add unit tests for `formatSize` utility function
2025-06-11 22:57:29 +05:45
georgegiosue
9e628fa6be docs(contributing): update Spanish contribution guide for clarity and accuracy 2025-06-08 12:36:55 -05:00
Shruti Shahi
1b52bb27f7 Added Hindi translation of Readme file 2025-05-24 01:52:54 +05:30
betawait
1d12bebce4 Fix: Allow empty Content-Type when no body (#1693)
By default Axios will set the Content-Type for POST/PUT/PATCH requests
to "application/x-www-form-urlencoded" if the Content-Type header is not
specified.

This explicitly sets the content type to "false" when there the body
mode is set to "none", and the user has not set an explicit content type
themselves. Setting the content type to false directs Axios not to send
a Content-Type header.
2025-05-15 07:40:21 +09:00
Jonathan Perlman
b5861dae39 Fix Digest auth header field key value extraction 2025-04-15 14:31:08 -04:00
Nikolai2038
5f9c21d00f Update linux installation instructions via apt
- Add instructions to install gpg;
- Use "gpg --list-keys" to let gpg create ".gnupg" directory with correct rights;
- Use "arch=amd64" - see commit 6c8c87fe28.
2024-05-22 22:38:45 +03:00
180 changed files with 9028 additions and 1620 deletions

View File

@@ -113,6 +113,10 @@ jobs:
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: Install dependencies for test collection environment
run: |
npm ci --prefix packages/bruno-tests/collection
- name: Build libraries
run: |
npm run build:graphql-docs

View File

@@ -1,4 +1,4 @@
[English](../../contributing.md)
[Inglés](../../contributing.md)
## ¡Juntos, hagamos a Bruno mejor!
@@ -6,58 +6,111 @@ Estamos encantados de que quieras ayudar a mejorar Bruno. A continuación encont
### Tecnologías utilizadas
Bruno está construido con NextJs y React. También usamos electron para distribuir una versión de escritorio (que soporta colecciones locales).
Bruno está construido con React y Electron
Librerías que utilizamos:
- CSS - Tailwind
- Editores de código - Codemirror
- CSS - TailwindCSS
- Editores de código - CodeMirror
- Manejo del estado - Redux
- Íconos - Tabler Icons
- Formularios - formik
- Validación de esquemas - Yup
- Cliente de peticiones - axios
- Monitor del sistema de archivos - chokidar
- i18n (internacionalización) - i18next
### Dependencias
Necesitarás [Node v20.x o la última versión LTS](https://nodejs.org/es) y npm 8.x. Ten en cuenta que utilizamos espacios de trabajo de npm en el proyecto.
> [!IMPORTANT]
> Necesitarás [Node v22.x o la última versión LTS](https://nodejs.org/es/). Ten en cuenta que Bruno usa los espacios de trabajo de npm
## Desarrollo
Bruno está siendo desarrollado como una aplicación de escritorio. Para ejecutarlo, primero debes ejecutar la aplicación de nextjs en una terminal y luego ejecutar la aplicación de electron en otra terminal.
Bruno es una aplicación de escritorio. A continuación se detallan las instrucciones paso a paso para ejecutar Bruno.
### Dependencias
> Nota: Utilizamos React para el frontend y rsbuild para el servidor de desarrollo.
- NodeJS v18
### Instalar dependencias
```bash
# Use la versión 22.x o LTS (Soporte a Largo Plazo) de Node.js
nvm use 22.11.0
# instalar las dependencias
npm i --legacy-peer-deps
```
> ¿Por qué `--legacy-peer-deps`?: Fuerza la instalación ignorando conflictos en dependencias “peer”, evitando errores de árbol de dependencias.
### Desarrollo local
#### Construir paquetes
##### Opción 1
```bash
# Utiliza la versión 18 de nodejs
nvm use
# Instala las dependencias
npm i --legacy-peer-deps
# Construye la documentación de graphql
# construir paquetes
npm run build:graphql-docs
# Construye bruno-query
npm run build:bruno-query
npm run build:bruno-common
npm run build:bruno-converters
npm run build:bruno-requests
# Ejecuta la aplicación de nextjs (terminal 1)
# empaquetar bibliotecas JavaScript del entorno de pruebas aislado
npm run sandbox:bundle-libraries --workspace=packages/bruno-js
```
##### Opción 2
```bash
# instalar dependencias y configurar el entorno
npm run setup
```
#### Ejecutar la aplicación
```bash
# ejecutar aplicación react (terminal 1)
npm run dev:web
# Ejecuta la aplicación de electron (terminal 2)
# ejecutar aplicación electron (terminal 2)
npm run dev:electron
```
##### Opción 1
```bash
# ejecutar aplicación react (terminal 1)
npm run dev:web
# ejecutar aplicación electron (terminal 2)
npm run dev:electron
```
##### Opción 2
```bash
# ejecutar aplicación electron y react de forma concurrente
npm run dev
```
#### Personalizar la ruta `userData` de Electron
Si la variable de entorno `ELECTRON_USER_DATA_PATH` está presente y se encuentra en modo de desarrollo, entonces la ruta `userData` se modifica en consecuencia.
ejemplo:
```sh
ELECTRON_USER_DATA_PATH=$(realpath ~/Desktop/bruno-test) npm run dev:electron
```
Esto creará una carpeta llamada `bruno-test` en tu escritorio y la usará como la ruta userData.
### Solución de problemas
Es posible que encuentres un error de `Unsupported platform` cuando ejecutes `npm install`. Para solucionarlo, debes eliminar la carpeta `node_modules` y el archivo `package-lock.json`, luego, ejecuta `npm install`. Lo anterior debería instalar todos los paquetes necesarios para ejecutar la aplicación.
Es posible que te encuentres con un error `Unsupported platform` cuando ejecutes `npm install`. Para solucionarlo, tendrás que eliminar las carpetas `node_modules` y el archivo `package-lock.json`, y luego volver a ejecutar `npm install`. Esto debería instalar todos los paquetes necesarios para que la aplicación funcione.
```shell
```sh
# Elimina la carpeta node_modules en los subdirectorios
find ./ -type d -name "node_modules" -print0 | while read -d $'\0' dir; do
rm -rf "$dir"
@@ -69,10 +122,42 @@ find . -type f -name "package-lock.json" -delete
### Pruebas
```bash
# ejecutar pruebas de esquema bruno
npm test --workspace=packages/bruno-schema
#### Pruebas individuales
```bash
# ejecutar pruebas de bruno-app
npm run test --workspace=packages/bruno-app
# ejecutar pruebas de bruno-electron
npm run test --workspace=packages/bruno-electron
# ejecutar pruebas de bruno-cli
npm run test --workspace=packages/bruno-cli
# ejecutar pruebas de bruno-common
npm run test --workspace=packages/bruno-common
# ejecutar pruebas de bruno-converters
npm run test --workspace=packages/bruno-converters
# ejecutar pruebas de bruno-schema
npm run test --workspace=packages/bruno-schema
# ejecutar pruebas de bruno-query
npm run test --workspace=packages/bruno-query
# ejecutar pruebas de bruno-js
npm run test --workspace=packages/bruno-js
# ejecutar pruebas de bruno-lang
npm run test --workspace=packages/bruno-lang
# ejecutar pruebas de bruno-toml
npm run test --workspace=packages/bruno-toml
```
#### Pruebas en conjunto
```bash
# ejecutar pruebas en todos los espacios de trabajo
npm test --workspaces --if-present
```

View File

@@ -74,12 +74,11 @@ flatpak install com.usebruno.Bruno
# على نظام Linux عبر Apt
sudo mkdir -p /etc/apt/keyrings
sudo apt update && sudo apt install gpg
sudo gpg --list-keys
sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266
echo "deb [signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update
sudo apt
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install bruno
```
### التشغيل عبر منصات متعددة 🖥️

View File

@@ -59,12 +59,11 @@ snap install bruno
# Apt এর মাধ্যমে লিনাক্সে
sudo mkdir -p /etc/apt/keyrings
sudo apt update && sudo apt install gpg
sudo gpg --list-keys
sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266
echo "deb [signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update
sudo apt install bruno
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install bruno
```
### একাধিক প্ল্যাটফর্মে চালান 🖥️

View File

@@ -63,12 +63,11 @@ snap install bruno
# 在 Linux 上用 Apt 安装
sudo mkdir -p /etc/apt/keyrings
sudo apt update && sudo apt install gpg
sudo gpg --list-keys
sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266
echo "deb [signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update
sudo apt install bruno
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install bruno
```
### 在 Mac 上通过 Homebrew 安装 🖥️

View File

@@ -78,12 +78,11 @@ flatpak install com.usebruno.Bruno
# Auf Linux via Apt
sudo mkdir -p /etc/apt/keyrings
sudo apt update && sudo apt install gpg
sudo gpg --list-keys
sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266
echo "deb [signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update
sudo apt install bruno
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install bruno
```
### Einsatz auf verschiedensten Plattformen 🖥️

View File

@@ -75,12 +75,11 @@ flatpak install com.usebruno.Bruno
# En Linux con Apt
sudo mkdir -p /etc/apt/keyrings
sudo apt update && sudo apt install gpg
sudo gpg --list-keys
sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266
echo "deb [signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update
sudo apt install bruno
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install bruno
```
### Ejecútalo en múltiples plataformas 🖥️

View File

@@ -63,12 +63,11 @@ snap install bruno
# Linux via Apt
sudo mkdir -p /etc/apt/keyrings
sudo apt update && sudo apt install gpg
sudo gpg --list-keys
sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266
echo "deb [signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update
sudo apt install bruno
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install bruno
```
### Fonctionne sur de multiples plateformes 🖥️

151
docs/readme/readme_hi.md Normal file
View File

@@ -0,0 +1,151 @@
<br />
<img src="../../assets/images/logo-transparent.png" width="80"/>
### ब्रूनो - API इंटरफेस (API) का अन्वेषण और परीक्षण करने के लिए एक ओपन-सोर्स विकास वातावरण।
[![GitHub संस्करण](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%2Fbruno)
[![CI](https://github.com/usebruno/bruno/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
[![कमिट गतिविधि](https://img.shields.io/github/commit-activity/m/usebruno/bruno)](https://github.com/usebruno/bruno/pulse)
[![X](https://img.shields.io/twitter/follow/use_bruno?style=social&logo=x)](https://twitter.com/use_bruno)
[![वेबसाइट](https://img.shields.io/badge/Website-Visit-blue)](https://www.usebruno.com)
[![डाउनलोड](https://img.shields.io/badge/Download-Latest-brightgreen)](https://www.usebruno.com/downloads)
[English](../../readme.md)
| [Українська](./readme_ua.md)
| [Русский](./readme_ru.md)
| [Türkçe](./readme_tr.md)
| [Deutsch](./readme_de.md)
| [Français](./readme_fr.md)
| [Português (BR)](./readme_pt_br.md)
| [한국어](./readme_kr.md)
| [বাংলা](./readme_bn.md)
| [Español](./readme_es.md)
| [Italiano](./readme_it.md)
| [Română](./readme_ro.md)
| [Polski](./readme_pl.md)
| [简体中文](./readme_cn.md)
| [正體中文](./readme_zhtw.md)
| [العربية](./readme_ar.md)
| [日本語](./readme_ja.md)
| [ქართული](./readme_ka.md)
| **हिन्दी**
ब्रूनो एक नया और अभिनव API क्लाइंट है, जिसका उद्देश्य Postman और अन्य समान उपकरणों द्वारा प्रस्तुत स्थिति को बदलना है।
ब्रूनो आपकी कलेक्शनों को सीधे आपकी फाइल सिस्टम के एक फ़ोल्डर में संग्रहीत करता है। हम API अनुरोधों के बारे में जानकारी सहेजने के लिए एक सामान्य टेक्स्ट मार्कअप भाषा, Bru, का उपयोग करते हैं।
आप अपनी API कलेक्शनों पर सहयोग करने के लिए Git या अपनी पसंद के किसी भी संस्करण नियंत्रण प्रणाली का उपयोग कर सकते हैं।
ब्रूनो केवल ऑफ़लाइन उपयोग के लिए है। ब्रूनो में कभी भी क्लाउड-सिंक जोड़ने की कोई योजना नहीं है। हम आपके डेटा की गोपनीयता को महत्व देते हैं और मानते हैं कि इसे आपके डिवाइस पर ही रहना चाहिए। हमारी दीर्घकालिक दृष्टि [यहाँ](https://github.com/usebruno/bruno/discussions/269) पढ़ें।
📢 हमारे हालिया India FOSS 3.0 सम्मेलन में हमारे वार्तालाप को [यहाँ](https://www.youtube.com/watch?v=7bSMFpbcPiY) देखें।
![bruno](/assets/images/landing-2.png) <br /><br />
### गोल्डन संस्करण ✨
हमारी अधिकांश सुविधाएँ मुफ्त और ओपन-सोर्स हैं।
हम [पारदर्शिता और स्थिरता के सिद्धांतों](https://github.com/usebruno/bruno/discussions/269) के बीच एक सामंजस्यपूर्ण संतुलन प्राप्त करने का प्रयास करते हैं।
[गोल्डन संस्करण](https://www.usebruno.com/pricing) के लिए खरीदारी जल्द ही $9 की कीमत पर उपलब्ध होगी! <br/>
[यहाँ सदस्यता लें](https://usebruno.ck.page/4c65576bd4) ताकि आपको लॉन्च पर सूचनाएं मिलें।
### स्थापना
ब्रूनो Mac, Windows और Linux के लिए हमारे [वेबसाइट](https://www.usebruno.com/downloads) पर एक बाइनरी डाउनलोड के रूप में उपलब्ध है।
आप ब्रूनो को Homebrew, Chocolatey, Scoop, Snap, Flatpak और Apt जैसे पैकेज प्रबंधकों के माध्यम से भी स्थापित कर सकते हैं।
```sh
# Mac पर Homebrew के माध्यम से
brew install bruno
# Windows पर Chocolatey के माध्यम से
choco install bruno
# Windows पर Scoop के माध्यम से
scoop bucket add extras
scoop install bruno
# Linux पर Snap के माध्यम से
snap install bruno
# Linux पर Flatpak के माध्यम से
flatpak install com.usebruno.Bruno
# Linux पर Apt के माध्यम से
sudo mkdir -p /etc/apt/keyrings
sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266
echo "deb [signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update
sudo apt install bruno
कई प्लेटफार्मों पर चलाएं 🖥️
<br /><br />
Git के माध्यम से सहयोग करें 👩‍💻🧑‍💻
या अपनी पसंद के किसी भी संस्करण नियंत्रण प्रणाली का उपयोग करें
<br /><br />
महत्वपूर्ण लिंक 📌
हमारी दीर्घकालिक दृष्टि
रोडमैप
प्रलेखन
Stack Overflow
वेबसाइट
मूल्य निर्धारण
डाउनलोड
GitHub प्रायोजक
प्रस्तुतियाँ 🎥
प्रशंसापत्र
ज्ञान केंद्र
Scriptmania
समर्थन ❤️
यदि आप ब्रूनो को पसंद करते हैं और हमारे ओपन-सोर्स कार्य का समर्थन करना चाहते हैं, तो कृपया GitHub प्रायोजक के माध्यम से हमें प्रायोजित करने पर विचार करें।
प्रशंसापत्र साझा करें 📣
यदि ब्रूनो ने आपके और आपकी टीमों के लिए काम में मदद की है, तो कृपया हमारे GitHub चर्चा में अपने प्रशंसापत्र साझा करना न भूलें
नए पैकेज प्रबंधकों में प्रकाशित करना
अधिक जानकारी के लिए कृपया यहाँ देखें।
हमसे संपर्क करें 🌐
𝕏 (ट्विटर) <br />
वेबसाइट <br />
डिस्कॉर्ड <br />
लिंक्डइन
ट्रेडमार्क
नाम
ब्रूनो एक ट्रेडमार्क है जो अनूप एम डी के स्वामित्व में है।
लोगो
लोगो OpenMoji से लिया गया है। लाइसेंस: CC BY-SA 4.0
योगदान 👩‍💻🧑‍💻
हमें खुशी है कि आप ब्रूनो को बेहतर बनाने में रुचि रखते हैं। कृपया योगदान गाइड देखें।
यदि आप सीधे कोड के माध्यम से योगदान नहीं कर सकते, तो भी कृपया बग्स की रिपोर्ट करने और उन सुविधाओं का अनुरोध करने में संकोच न करें जिन्हें आपकी स्थिति को हल करने के लिए लागू किया जाना चाहिए।
लेखक
<div align="center"> <a href="https://github.com/usebruno/bruno/graphs/contributors"> <img src="https://contrib.rocks/image?repo=usebruno/bruno" /> </a> </div>
लाइसेंस 📄
MIT

View File

@@ -59,12 +59,11 @@ snap install bruno
# Su Linux tramite Apt
sudo mkdir -p /etc/apt/keyrings
sudo apt update && sudo apt install gpg
sudo gpg --list-keys
sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266
echo "deb [signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update
sudo apt install bruno
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install bruno
```
### Funziona su diverse piattaforme 🖥️

View File

@@ -78,12 +78,11 @@ flatpak install com.usebruno.Bruno
# LinuxでAptを使ってインストール
sudo mkdir -p /etc/apt/keyrings
sudo apt update && sudo apt install gpg
sudo gpg --list-keys
sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266
echo "deb [signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update
sudo apt install bruno
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install bruno
```
### マルチプラットフォームでの実行に対応 🖥️

View File

@@ -59,12 +59,11 @@ snap install bruno
# On Linux via Apt
sudo mkdir -p /etc/apt/keyrings
sudo apt update && sudo apt install gpg
sudo gpg --list-keys
sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266
echo "deb [signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update
sudo apt install bruno
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install bruno
```
### 여러 플랫폼에서 실행하세요. 🖥️

View File

@@ -69,12 +69,11 @@ flatpak install com.usebruno.Bruno
# On Linux via Apt
sudo mkdir -p /etc/apt/keyrings
sudo apt update && sudo apt install gpg
sudo gpg --list-keys
sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update
sudo apt install bruno
sudo apt update && sudo apt install bruno
```
### Uruchom na wielu platformach 🖥️

View File

@@ -76,12 +76,11 @@ flatpak install com.usebruno.Bruno
# No Linux via Apt
sudo mkdir -p /etc/apt/keyrings
sudo apt update && sudo apt install gpg
sudo gpg --list-keys
sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266
echo "deb [signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update
sudo apt install bruno
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install bruno
```
### Execute em várias plataformas 🖥️

View File

@@ -59,12 +59,11 @@ snap install bruno
# Pe Linux cu Apt
sudo mkdir -p /etc/apt/keyrings
sudo apt update && sudo apt install gpg
sudo gpg --list-keys
sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266
echo "deb [signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update
sudo apt install bruno
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install bruno
```
### Utilizați pe mai multe platforme 🖥️

View File

@@ -63,12 +63,11 @@ snap install bruno
# Apt aracılığıyla Linux'ta
sudo mkdir -p /etc/apt/keyrings
sudo apt update && sudo apt install gpg
sudo gpg --list-keys
sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266
echo "deb [signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update
sudo apt install bruno
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install bruno
```
### Birden fazla platformda çalıştırın 🖥️

View File

@@ -63,12 +63,11 @@ snap install bruno
# 在 Linux 上使用 Apt 安裝
sudo mkdir -p /etc/apt/keyrings
sudo apt update && sudo apt install gpg
sudo gpg --list-keys
sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266
echo "deb [signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update
sudo apt install bruno
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install bruno
```
### 跨多個平台運行 🖥️

View File

@@ -8,7 +8,7 @@ test('Create new collection and add a simple HTTP request', async ({ page, creat
await page.getByLabel('Location').fill(await createTmpDir('test-collection'));
await page.getByRole('button', { name: 'Create', exact: true }).click();
await page.getByText('test-collection').click();
await page.getByLabel('Safe ModeBETA').check();
await page.getByLabel('Safe Mode').check();
await page.getByRole('button', { name: 'Save' }).click();
await page.locator('#create-new-tab').getByRole('img').click();
await page.getByPlaceholder('Request Name').fill('r1');

View File

@@ -21,14 +21,14 @@ test.describe.parallel('Run Testbench Requests', () => {
.slice(1);
await expect(parseInt(failed)).toBe(0);
await expect(parseInt(passed)).toBe(parseInt(totalRequests) - parseInt(skipped) - 1);
await expect(parseInt(passed)).toBe(parseInt(totalRequests) - parseInt(skipped) - parseInt(failed));
});
test.fixme('Run bruno-testbench in Safe Mode', async ({ pageWithUserData: page }) => {
test.setTimeout(2 * 60 * 1000);
await page.getByText('bruno-testbench').click();
await page.getByLabel('Safe ModeBETA').check();
await page.getByLabel('Safe Mode').check();
await page.getByRole('button', { name: 'Save' }).click();
await page.locator('.environment-selector').nth(1).click();
await page.locator('.dropdown-item').getByText('Prod').click();
@@ -44,6 +44,6 @@ test.describe.parallel('Run Testbench Requests', () => {
.slice(1);
await expect(parseInt(failed)).toBe(0);
await expect(parseInt(passed)).toBe(parseInt(totalRequests) - parseInt(skipped) - 1);
await expect(parseInt(passed)).toBe(parseInt(totalRequests) - parseInt(skipped) - parseInt(failed));
});
});

View File

@@ -25,6 +25,19 @@ module.exports = defineConfig([
"no-undef": "error",
},
},
{
// It prevents lint errors when using CommonJS exports (module.exports) in Jest mocks.
files: ["packages/bruno-app/src/test-utils/mocks/codemirror.js"],
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
},
rules: {
"no-undef": "error",
},
},
{
files: ["packages/bruno-electron/**/*.{js}"],
ignores: ["**/*.config.js"],

263
package-lock.json generated
View File

@@ -8209,78 +8209,6 @@
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@testing-library/dom": {
"version": "9.3.4",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.4.tgz",
"integrity": "sha512-FlS4ZWlp97iiNWig0Muq8p+3rVDjRiYE+YKGbAqXOu9nwJFFOdL00kFpz42M+4huzYi86vAK1sOOfyOG45muIQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
"@types/aria-query": "^5.0.1",
"aria-query": "5.1.3",
"chalk": "^4.1.0",
"dom-accessibility-api": "^0.5.9",
"lz-string": "^1.5.0",
"pretty-format": "^27.0.2"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@testing-library/dom/node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/@testing-library/dom/node_modules/pretty-format": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
"react-is": "^17.0.1"
},
"engines": {
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
}
},
"node_modules/@testing-library/dom/node_modules/pretty-format/node_modules/ansi-styles": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/@testing-library/dom/node_modules/react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
"license": "MIT"
},
"node_modules/@testing-library/jest-dom": {
"version": "6.6.3",
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.6.3.tgz",
@@ -8309,25 +8237,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@testing-library/react": {
"version": "14.3.1",
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-14.3.1.tgz",
"integrity": "sha512-H99XjUhWQw0lTgyMN05W3xQG1Nh4lq574D8keFf1dDoNTJgp66VbJozRaczoF+wsiaPJNt/TcnfpLGufGxSrZQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.12.5",
"@testing-library/dom": "^9.0.0",
"@types/react-dom": "^18.0.0"
},
"engines": {
"node": ">=14"
},
"peerDependencies": {
"react": "^18.0.0",
"react-dom": "^18.0.0"
}
},
"node_modules/@tippyjs/react": {
"version": "4.2.6",
"resolved": "https://registry.npmjs.org/@tippyjs/react/-/react-4.2.6.tgz",
@@ -8700,16 +8609,6 @@
"csstype": "^3.0.2"
}
},
"node_modules/@types/react-dom": {
"version": "18.3.7",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^18.0.0"
}
},
"node_modules/@types/react-redux": {
"version": "7.1.34",
"resolved": "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.34.tgz",
@@ -28130,12 +28029,12 @@
"react-tooltip": "^5.5.2",
"sass": "^1.46.0",
"semver": "^7.7.1",
"shell-quote": "^1.8.3",
"strip-json-comments": "^5.0.1",
"styled-components": "^5.3.3",
"system": "^2.0.1",
"url": "^0.11.3",
"xml-formatter": "^3.5.0",
"yargs-parser": "^21.1.1",
"yup": "^0.32.11"
},
"devDependencies": {
@@ -28148,9 +28047,9 @@
"@rsbuild/plugin-react": "^1.0.7",
"@rsbuild/plugin-sass": "^1.1.0",
"@rsbuild/plugin-styled-components": "1.1.0",
"@testing-library/dom": "^9.3.3",
"@testing-library/jest-dom": "^6.1.5",
"@testing-library/react": "^14.1.2",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"autoprefixer": "10.4.20",
"babel-jest": "^29.7.0",
"babel-plugin-react-compiler": "19.0.0-beta-a7bf2bd-20241110",
@@ -29478,6 +29377,64 @@
"semver": "bin/semver.js"
}
},
"packages/bruno-app/node_modules/@testing-library/dom": {
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz",
"integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
"@types/aria-query": "^5.0.1",
"aria-query": "5.3.0",
"chalk": "^4.1.0",
"dom-accessibility-api": "^0.5.9",
"lz-string": "^1.5.0",
"pretty-format": "^27.0.2"
},
"engines": {
"node": ">=18"
}
},
"packages/bruno-app/node_modules/@testing-library/react": {
"version": "16.3.0",
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.0.tgz",
"integrity": "sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.12.5"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@testing-library/dom": "^10.0.0",
"@types/react": "^18.0.0 || ^19.0.0",
"@types/react-dom": "^18.0.0 || ^19.0.0",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"packages/bruno-app/node_modules/aria-query": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"dequal": "^2.0.3"
}
},
"packages/bruno-app/node_modules/babel-plugin-polyfill-corejs3": {
"version": "0.11.1",
"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.11.1.tgz",
@@ -29546,6 +29503,23 @@
],
"license": "CC-BY-4.0"
},
"packages/bruno-app/node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"packages/bruno-app/node_modules/cookie": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
@@ -29647,6 +29621,41 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"packages/bruno-app/node_modules/pretty-format": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
"react-is": "^17.0.1"
},
"engines": {
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
}
},
"packages/bruno-app/node_modules/pretty-format/node_modules/ansi-styles": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"packages/bruno-app/node_modules/react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
"license": "MIT"
},
"packages/bruno-app/node_modules/semver": {
"version": "7.7.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz",
@@ -29658,6 +29667,18 @@
"node": ">=10"
}
},
"packages/bruno-app/node_modules/shell-quote": {
"version": "1.8.3",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"packages/bruno-app/node_modules/update-browserslist-db": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",
@@ -31461,7 +31482,6 @@
"version": "2.0.0",
"dependencies": {
"@aws-sdk/credential-providers": "3.750.0",
"@faker-js/faker": "^9.5.1",
"@usebruno/common": "0.1.0",
"@usebruno/converters": "^0.1.0",
"@usebruno/js": "0.12.0",
@@ -31971,23 +31991,6 @@
}
}
},
"packages/bruno-electron/node_modules/@faker-js/faker": {
"version": "9.5.1",
"resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-9.5.1.tgz",
"integrity": "sha512-0fzMEDxkExR2cn731kpDaCCnBGBUOIXEi2S1N5l8Hltp6aPf4soTMJ+g4k8r2sI5oB+rpwIW8Uy/6jkwGpnWPg==",
"deprecated": "Please update to a newer version",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/fakerjs"
}
],
"license": "MIT",
"engines": {
"node": ">=18.0.0",
"npm": ">=9.0.0"
}
},
"packages/bruno-electron/node_modules/@smithy/abort-controller": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.0.1.tgz",
@@ -32748,7 +32751,8 @@
"version": "0.1.0",
"license": "MIT",
"dependencies": {
"@types/qs": "^6.9.18"
"@types/qs": "^6.9.18",
"axios": "^1.9.0"
},
"devDependencies": {
"@rollup/plugin-commonjs": "^23.0.2",
@@ -32761,6 +32765,17 @@
"typescript": "^4.8.4"
}
},
"packages/bruno-requests/node_modules/axios": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.9.0.tgz",
"integrity": "sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
},
"packages/bruno-schema": {
"name": "@usebruno/schema",
"version": "0.7.0",

View File

@@ -1,4 +1,4 @@
{
"presets": ["@babel/preset-env"],
"presets": ["@babel/preset-env", "@babel/preset-react"],
"plugins": [["styled-components", { "ssr": true }]]
}

View File

@@ -1,5 +1,11 @@
module.exports = {
rootDir: '.',
transform: {
'^.+\\.[jt]sx?$': 'babel-jest',
},
transformIgnorePatterns: [
"/node_modules/(?!strip-json-comments|nanoid|xml-formatter)/",
],
moduleNameMapper: {
'^assets/(.*)$': '<rootDir>/src/assets/$1',
'^components/(.*)$': '<rootDir>/src/components/$1',
@@ -8,18 +14,17 @@ module.exports = {
'^api/(.*)$': '<rootDir>/src/api/$1',
'^pageComponents/(.*)$': '<rootDir>/src/pageComponents/$1',
'^providers/(.*)$': '<rootDir>/src/providers/$1',
'^utils/(.*)$': '<rootDir>/src/utils/$1'
'^utils/(.*)$': '<rootDir>/src/utils/$1',
'^test-utils/(.*)$': '<rootDir>/src/test-utils/$1'
},
clearMocks: true,
moduleDirectories: ['node_modules', 'src'],
testEnvironment: 'jsdom',
transform: {
'^.+\\.[jt]sx?$': 'babel-jest'
},
transformIgnorePatterns: [
'/node_modules/(?!(nanoid|xml-formatter)/)'
setupFilesAfterEnv: ['@testing-library/jest-dom'],
setupFiles: [
'<rootDir>/jest.setup.js',
],
testMatch: [
'<rootDir>/src/**/*.spec.[jt]s?(x)'
]
};
};

View File

@@ -0,0 +1,11 @@
jest.mock('nanoid', () => {
return {
nanoid: () => {}
};
});
jest.mock('strip-json-comments', () => {
return {
stripJsonComments: (str) => str
};
});

View File

@@ -6,6 +6,7 @@
"baseUrl": "./",
"paths": {
"assets/*": ["src/assets/*"],
"ui/*": ["src/ui/*"],
"components/*": ["src/components/*"],
"hooks/*": ["src/hooks/*"],
"themes/*": ["src/themes/*"],

View File

@@ -73,12 +73,12 @@
"react-tooltip": "^5.5.2",
"sass": "^1.46.0",
"semver": "^7.7.1",
"shell-quote": "^1.8.3",
"strip-json-comments": "^5.0.1",
"styled-components": "^5.3.3",
"system": "^2.0.1",
"url": "^0.11.3",
"xml-formatter": "^3.5.0",
"yargs-parser": "^21.1.1",
"yup": "^0.32.11"
},
"devDependencies": {
@@ -91,9 +91,9 @@
"@rsbuild/plugin-react": "^1.0.7",
"@rsbuild/plugin-sass": "^1.1.0",
"@rsbuild/plugin-styled-components": "1.1.0",
"@testing-library/dom": "^9.3.3",
"@testing-library/jest-dom": "^6.1.5",
"@testing-library/react": "^14.1.2",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"autoprefixer": "10.4.20",
"babel-jest": "^29.7.0",
"babel-plugin-react-compiler": "19.0.0-beta-a7bf2bd-20241110",
@@ -111,4 +111,4 @@
"webpack": "^5.64.4",
"webpack-cli": "^4.9.1"
}
}
}

View File

@@ -20,6 +20,11 @@ export default defineConfig({
],
source: {
tsconfigPath: './jsconfig.json', // Specifies the path to the JavaScript/TypeScript configuration file,
exclude: [
'**/test-utils/**',
'**/*.test.*',
'**/*.spec.*'
]
},
html: {
title: 'Bruno'

View File

@@ -0,0 +1,40 @@
import React, { useMemo } from 'react';
import CodeEditor from 'components/CodeEditor';
import { useTheme } from 'providers/Theme';
import { useSelector } from 'react-redux';
import { parseBulkKeyValue, serializeBulkKeyValue } from 'utils/common/bulkKeyValueUtils';
const BulkEditor = ({ params, onChange, onToggle, onSave, onRun }) => {
const preferences = useSelector((state) => state.app.preferences);
const { displayedTheme } = useTheme();
const parsedParams = useMemo(() => serializeBulkKeyValue(params), [params]);
const handleEdit = (value) => {
const parsed = parseBulkKeyValue(value);
onChange(parsed);
};
return (
<>
<div className="h-[200px]">
<CodeEditor
mode="text/plain"
theme={displayedTheme}
font={preferences.codeFont || 'default'}
value={parsedParams}
onEdit={handleEdit}
onSave={onSave}
onRun={onRun}
/>
</div>
<div className="flex btn-action justify-between items-center mt-3">
<button className="text-link select-none ml-auto" onClick={onToggle}>
Key/Value Edit
</button>
</div>
</>
);
};
export default BulkEditor;

View File

@@ -8,120 +8,19 @@
import React from 'react';
import { isEqual, escapeRegExp } from 'lodash';
import { defineCodeMirrorBrunoVariablesMode } from 'utils/common/codemirror';
import { setupAutoComplete } from 'utils/codemirror/autocomplete';
import StyledWrapper from './StyledWrapper';
import * as jsonlint from '@prantlf/jsonlint';
import { JSHINT } from 'jshint';
import stripJsonComments from 'strip-json-comments';
import { getAllVariables } from 'utils/collections';
let CodeMirror;
const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
const CodeMirror = require('codemirror');
window.jsonlint = jsonlint;
window.JSHINT = JSHINT;
const TAB_SIZE = 2;
if (!SERVER_RENDERED) {
CodeMirror = require('codemirror');
window.jsonlint = jsonlint;
window.JSHINT = JSHINT;
//This should be done dynamically if possible
const hintWords = [
'res',
'res.status',
'res.statusText',
'res.headers',
'res.body',
'res.responseTime',
'res.getStatus()',
'res.getStatusText()',
'res.getHeader(name)',
'res.getHeaders()',
'res.getBody()',
'res.setBody(data)',
'res.getResponseTime()',
'req',
'req.url',
'req.method',
'req.headers',
'req.body',
'req.timeout',
'req.getUrl()',
'req.setUrl(url)',
'req.getMethod()',
'req.getAuthMode()',
'req.setMethod(method)',
'req.getHeader(name)',
'req.getHeaders()',
'req.setHeader(name, value)',
'req.setHeaders(data)',
'req.getBody()',
'req.setBody(data)',
'req.setMaxRedirects(maxRedirects)',
'req.getTimeout()',
'req.setTimeout(timeout)',
'req.getExecutionMode()',
'req.getName()',
'bru',
'bru.cwd()',
'bru.getEnvName()',
'bru.getProcessEnv(key)',
'bru.hasEnvVar(key)',
'bru.getEnvVar(key)',
'bru.getFolderVar(key)',
'bru.getCollectionVar(key)',
'bru.setEnvVar(key,value)',
'bru.deleteEnvVar(key)',
'bru.hasVar(key)',
'bru.getVar(key)',
'bru.setVar(key,value)',
'bru.deleteVar(key)',
'bru.deleteAllVars()',
'bru.setNextRequest(requestName)',
'req.disableParsingResponseJson()',
'bru.getRequestVar(key)',
'bru.runRequest(requestPathName)',
'bru.getAssertionResults()',
'bru.getTestResults()',
'bru.sleep(ms)',
'bru.getCollectionName()',
'bru.getGlobalEnvVar(key)',
'bru.setGlobalEnvVar(key, value)',
'bru.runner',
'bru.runner.setNextRequest(requestName)',
'bru.runner.skipRequest()',
'bru.runner.stopExecution()',
'bru.interpolate(str)'
];
CodeMirror.registerHelper('hint', 'brunoJS', (editor, options) => {
const cursor = editor.getCursor();
const currentLine = editor.getLine(cursor.line);
let startBru = cursor.ch;
let endBru = startBru;
while (endBru < currentLine.length && /[\w.]/.test(currentLine.charAt(endBru))) ++endBru;
while (startBru && /[\w.]/.test(currentLine.charAt(startBru - 1))) --startBru;
let curWordBru = startBru != endBru && currentLine.slice(startBru, endBru);
let start = cursor.ch;
let end = start;
while (end < currentLine.length && /[\w]/.test(currentLine.charAt(end))) ++end;
while (start && /[\w]/.test(currentLine.charAt(start - 1))) --start;
const jsHinter = CodeMirror.hint.javascript;
let result = jsHinter(editor) || { list: [] };
result.to = CodeMirror.Pos(cursor.line, end);
result.from = CodeMirror.Pos(cursor.line, start);
if (curWordBru) {
hintWords.forEach((h) => {
if (h.includes('.') == curWordBru.includes('.') && h.startsWith(curWordBru)) {
result.list.push(curWordBru.includes('.') ? h.split('.')?.at(-1) : h);
}
});
result.list?.sort();
}
return result;
});
CodeMirror.commands.autocomplete = (cm, hint, options) => {
cm.showHint({ hint, ...options });
};
}
export default class CodeEditor extends React.Component {
constructor(props) {
super(props);
@@ -141,12 +40,17 @@ export default class CodeEditor extends React.Component {
}
componentDidMount() {
const variables = getAllVariables(this.props.collection, this.props.item);
const editor = (this.editor = CodeMirror(this._node, {
value: this.props.value || '',
lineNumbers: true,
lineWrapping: true,
tabSize: TAB_SIZE,
mode: this.props.mode || 'application/ld+json',
brunoVarInfo: {
variables
},
keyMap: 'sublime',
autoCloseBrackets: true,
matchBrackets: true,
@@ -278,30 +182,24 @@ export default class CodeEditor extends React.Component {
}
return found;
});
if (editor) {
editor.setOption('lint', this.props.mode && editor.getValue().trim().length > 0 ? this.lintOptions : false);
editor.on('change', this._onEdit);
this.addOverlay();
}
if (this.props.mode == 'javascript') {
editor.on('keyup', function (cm, event) {
const cursor = editor.getCursor();
const currentLine = editor.getLine(cursor.line);
let start = cursor.ch;
let end = start;
while (end < currentLine.length && /[^{}();\s\[\]\,]/.test(currentLine.charAt(end))) ++end;
while (start && /[^{}();\s\[\]\,]/.test(currentLine.charAt(start - 1))) --start;
let curWord = start != end && currentLine.slice(start, end);
// Qualify if autocomplete will be shown
if (
/^(?!Shift|Tab|Enter|Escape|ArrowUp|ArrowDown|ArrowLeft|ArrowRight|Meta|Alt|Home|End\s)\w*/.test(event.key) &&
curWord.length > 0 &&
!/\/\/|\/\*|.*{{|`[^$]*{|`[^{]*$/.test(currentLine.slice(0, end)) &&
/(?<!\d)[a-zA-Z\._]$/.test(curWord)
) {
CodeMirror.commands.autocomplete(cm, CodeMirror.hint.brunoJS, { completeSingle: false });
}
});
// Setup AutoComplete Helper for all modes
const autoCompleteOptions = {
showHintsFor: this.props.showHintsFor
};
const getVariables = () => getAllVariables(this.props.collection, this.props.item);
this.brunoAutoCompleteCleanup = setupAutoComplete(
editor,
getVariables,
autoCompleteOptions
);
}
}
@@ -342,6 +240,9 @@ export default class CodeEditor extends React.Component {
}
this._unbindSearchHandler();
if (this.brunoAutoCompleteCleanup) {
this.brunoAutoCompleteCleanup();
}
}
render() {

View File

@@ -0,0 +1,51 @@
import React from 'react';
import { render, act } from '@testing-library/react';
import CodeEditor from './index';
import { ThemeProvider } from 'styled-components';
jest.mock('codemirror', () => {
const codemirror = require('test-utils/mocks/codemirror');
return codemirror;
});
const MOCK_THEME = {
codemirror: {
bg: "#1e1e1e",
border: "#333",
},
textLink: "#007acc",
};
const setupEditorState = (editor, { value, cursorPosition }) => {
editor._currentValue = value;
editor.getCursor.mockReturnValue({ line: 0, ch: cursorPosition });
editor.getRange.mockImplementation((from, to) => {
if (from.line === 0 && from.ch === 0 && to.line === 0 && to.ch === cursorPosition) {
return value;
}
return editor._currentValue.slice(from.ch, to.ch);
});
editor.state = {
completionActive: null,
}
};
const setupEditorWithRef = () => {
const ref = React.createRef();
const { rerender } = render(
<ThemeProvider theme={MOCK_THEME}>
<CodeEditor ref={ref} />
</ThemeProvider>
);
return { ref, rerender };
};
describe('CodeEditor', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.resetModules();
});
it("add CodeEditor related tests here", () => {});
});

View File

@@ -53,6 +53,7 @@ const Script = ({ collection }) => {
onSave={handleSave}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'bru']}
/>
</div>
<div className="flex-1 mt-6">
@@ -66,6 +67,7 @@ const Script = ({ collection }) => {
onSave={handleSave}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'res', 'bru']}
/>
</div>

View File

@@ -37,6 +37,7 @@ const Tests = ({ collection }) => {
onSave={handleSave}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'res', 'bru']}
/>
<div className="mt-6">

View File

@@ -9,6 +9,7 @@ import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions'
import SingleLineEditor from 'components/SingleLineEditor';
import StyledWrapper from './StyledWrapper';
import { headers as StandardHTTPHeaders } from 'know-your-http-well';
import { MimeTypes } from 'utils/codemirror/autocompleteConstants';
const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header);
const Headers = ({ collection, folder }) => {
@@ -117,6 +118,7 @@ const Headers = ({ collection, folder }) => {
}
collection={collection}
item={folder}
autocomplete={MimeTypes}
/>
</td>
<td>

View File

@@ -55,6 +55,7 @@ const Script = ({ collection, folder }) => {
onSave={handleSave}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'bru']}
/>
</div>
<div className="flex flex-col flex-1 mt-2 gap-y-2">
@@ -68,6 +69,7 @@ const Script = ({ collection, folder }) => {
onSave={handleSave}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'res', 'bru']}
/>
</div>

View File

@@ -38,6 +38,7 @@ const Tests = ({ collection, folder }) => {
onSave={handleSave}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'res', 'bru']}
/>
<div className="mt-6">

View File

@@ -2,14 +2,10 @@ import React, { Component } from 'react';
import isEqual from 'lodash/isEqual';
import { getAllVariables } from 'utils/collections';
import { defineCodeMirrorBrunoVariablesMode } from 'utils/common/codemirror';
import { setupAutoComplete } from 'utils/codemirror/autocomplete';
import StyledWrapper from './StyledWrapper';
let CodeMirror;
const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
if (!SERVER_RENDERED) {
CodeMirror = require('codemirror');
}
const CodeMirror = require('codemirror');
class MultiLineEditor extends Component {
constructor(props) {
@@ -78,14 +74,21 @@ class MultiLineEditor extends Component {
'Shift-Tab': false
}
});
if (this.props.autocomplete) {
this.editor.on('keyup', (cm, event) => {
if (!cm.state.completionActive /*Enables keyboard navigation in autocomplete list*/ && event.keyCode != 13) {
/*Enter - do not open autocomplete list just after item has been selected in it*/
CodeMirror.commands.autocomplete(cm, CodeMirror.hint.anyword, { autocomplete: this.props.autocomplete });
}
});
}
// Setup AutoComplete Helper
const autoCompleteOptions = {
showHintsFor: ['variables'],
anywordAutocompleteHints: this.props.autocomplete
};
const getVariables = () => getAllVariables(this.props.collection, this.props.item);
this.brunoAutoCompleteCleanup = setupAutoComplete(
this.editor,
getVariables,
autoCompleteOptions
);
this.editor.setValue(String(this.props.value) || '');
this.editor.on('change', this._onEdit);
this.addOverlay(variables);
@@ -125,6 +128,9 @@ class MultiLineEditor extends Component {
}
componentWillUnmount() {
if (this.brunoAutoCompleteCleanup) {
this.brunoAutoCompleteCleanup();
}
this.editor.getWrapperElement().remove();
}

View File

@@ -3,6 +3,7 @@ import get from 'lodash/get';
import { useSelector, useDispatch } from 'react-redux';
import { savePreferences } from 'providers/ReduxStore/slices/app';
import StyledWrapper from './StyledWrapper';
import toast from 'react-hot-toast';
const Font = ({ close }) => {
const dispatch = useDispatch();
@@ -31,7 +32,10 @@ const Font = ({ close }) => {
}
})
).then(() => {
toast.success('Preferences saved successfully')
close();
}).catch(() => {
toast.error('Failed to save preferences')
});
};

View File

@@ -80,9 +80,9 @@ const General = ({ close }) => {
storeCookies: newPreferences.storeCookies,
sendCookies: newPreferences.sendCookies
}
})
)
}))
.then(() => {
toast.success('Preferences saved successfully')
close();
})
.catch((err) => console.log(err) && toast.error('Failed to update preferences'));

View File

@@ -84,7 +84,10 @@ const ProxySettings = ({ close }) => {
proxy: validatedProxy
})
).then(() => {
toast.success('Preferences saved successfully')
close();
}).catch(() => {
toast.error('Failed to save preferences')
});
})
.catch((error) => {

View File

@@ -28,11 +28,11 @@ const AwsV4Auth = ({ item, collection, updateAuth, request, save }) => {
itemUid: item.uid,
content: {
accessKeyId: accessKeyId,
secretAccessKey: awsv4Auth.secretAccessKey,
sessionToken: awsv4Auth.sessionToken,
service: awsv4Auth.service,
region: awsv4Auth.region,
profileName: awsv4Auth.profileName
secretAccessKey: awsv4Auth.secretAccessKey || '',
sessionToken: awsv4Auth.sessionToken || '',
service: awsv4Auth.service || '',
region: awsv4Auth.region || '',
profileName: awsv4Auth.profileName || ''
}
})
);
@@ -45,12 +45,12 @@ const AwsV4Auth = ({ item, collection, updateAuth, request, save }) => {
collectionUid: collection.uid,
itemUid: item.uid,
content: {
accessKeyId: awsv4Auth.accessKeyId,
secretAccessKey: secretAccessKey,
sessionToken: awsv4Auth.sessionToken,
service: awsv4Auth.service,
region: awsv4Auth.region,
profileName: awsv4Auth.profileName
accessKeyId: awsv4Auth.accessKeyId || '',
secretAccessKey: secretAccessKey || '',
sessionToken: awsv4Auth.sessionToken || '',
service: awsv4Auth.service || '',
region: awsv4Auth.region || '',
profileName: awsv4Auth.profileName || ''
}
})
);
@@ -63,12 +63,12 @@ const AwsV4Auth = ({ item, collection, updateAuth, request, save }) => {
collectionUid: collection.uid,
itemUid: item.uid,
content: {
accessKeyId: awsv4Auth.accessKeyId,
secretAccessKey: awsv4Auth.secretAccessKey,
sessionToken: sessionToken,
service: awsv4Auth.service,
region: awsv4Auth.region,
profileName: awsv4Auth.profileName
accessKeyId: awsv4Auth.accessKeyId || '',
secretAccessKey: awsv4Auth.secretAccessKey || '',
sessionToken: sessionToken || '',
service: awsv4Auth.service || '',
region: awsv4Auth.region || '',
profileName: awsv4Auth.profileName || ''
}
})
);
@@ -81,12 +81,12 @@ const AwsV4Auth = ({ item, collection, updateAuth, request, save }) => {
collectionUid: collection.uid,
itemUid: item.uid,
content: {
accessKeyId: awsv4Auth.accessKeyId,
secretAccessKey: awsv4Auth.secretAccessKey,
sessionToken: awsv4Auth.sessionToken,
service: service,
region: awsv4Auth.region,
profileName: awsv4Auth.profileName
accessKeyId: awsv4Auth.accessKeyId || '',
secretAccessKey: awsv4Auth.secretAccessKey || '',
sessionToken: awsv4Auth.sessionToken || '',
service: service || '',
region: awsv4Auth.region || '',
profileName: awsv4Auth.profileName || ''
}
})
);
@@ -99,12 +99,12 @@ const AwsV4Auth = ({ item, collection, updateAuth, request, save }) => {
collectionUid: collection.uid,
itemUid: item.uid,
content: {
accessKeyId: awsv4Auth.accessKeyId,
secretAccessKey: awsv4Auth.secretAccessKey,
sessionToken: awsv4Auth.sessionToken,
service: awsv4Auth.service,
region: region,
profileName: awsv4Auth.profileName
accessKeyId: awsv4Auth.accessKeyId || '',
secretAccessKey: awsv4Auth.secretAccessKey || '',
sessionToken: awsv4Auth.sessionToken || '',
service: awsv4Auth.service || '',
region: region || '',
profileName: awsv4Auth.profileName || ''
}
})
);
@@ -117,12 +117,12 @@ const AwsV4Auth = ({ item, collection, updateAuth, request, save }) => {
collectionUid: collection.uid,
itemUid: item.uid,
content: {
accessKeyId: awsv4Auth.accessKeyId,
secretAccessKey: awsv4Auth.secretAccessKey,
sessionToken: awsv4Auth.sessionToken,
service: awsv4Auth.service,
region: awsv4Auth.region,
profileName: profileName
accessKeyId: awsv4Auth.accessKeyId || '',
secretAccessKey: awsv4Auth.secretAccessKey || '',
sessionToken: awsv4Auth.sessionToken || '',
service: awsv4Auth.service || '',
region: awsv4Auth.region || '',
profileName: profileName || ''
}
})
);

View File

@@ -26,8 +26,8 @@ const BasicAuth = ({ item, collection, updateAuth, request, save }) => {
collectionUid: collection.uid,
itemUid: item.uid,
content: {
username: username,
password: basicAuth.password
username: username || '',
password: basicAuth.password || ''
}
})
);
@@ -40,8 +40,8 @@ const BasicAuth = ({ item, collection, updateAuth, request, save }) => {
collectionUid: collection.uid,
itemUid: item.uid,
content: {
username: basicAuth.username,
password: password
username: basicAuth.username || '',
password: password || ''
}
})
);

View File

@@ -25,8 +25,8 @@ const DigestAuth = ({ item, collection, updateAuth, request, save }) => {
collectionUid: collection.uid,
itemUid: item.uid,
content: {
username: username,
password: digestAuth.password
username: username || '',
password: digestAuth.password || ''
}
})
);
@@ -39,8 +39,8 @@ const DigestAuth = ({ item, collection, updateAuth, request, save }) => {
collectionUid: collection.uid,
itemUid: item.uid,
content: {
username: digestAuth.username,
password: password
username: digestAuth.username || '',
password: password || ''
}
})
);

View File

@@ -26,9 +26,9 @@ const NTLMAuth = ({ item, collection, request, save, updateAuth }) => {
collectionUid: collection.uid,
itemUid: item.uid,
content: {
username: username,
password: ntlmAuth.password,
domain: ntlmAuth.domain
username: username || '',
password: ntlmAuth.password || '',
domain: ntlmAuth.domain || ''
}
})
);
@@ -41,9 +41,9 @@ const NTLMAuth = ({ item, collection, request, save, updateAuth }) => {
collectionUid: collection.uid,
itemUid: item.uid,
content: {
username: ntlmAuth.username,
password: password,
domain: ntlmAuth.domain
username: ntlmAuth.username || '',
password: password || '',
domain: ntlmAuth.domain || ''
}
})
);
@@ -56,9 +56,9 @@ const NTLMAuth = ({ item, collection, request, save, updateAuth }) => {
collectionUid: collection.uid,
itemUid: item.uid,
content: {
username: ntlmAuth.username,
password: ntlmAuth.password,
domain: domain
username: ntlmAuth.username || '',
password: ntlmAuth.password || '',
domain: domain || ''
}
})
);

View File

@@ -26,8 +26,8 @@ const WsseAuth = ({ item, collection, updateAuth, request, save }) => {
collectionUid: collection.uid,
itemUid: item.uid,
content: {
username,
password: wsseAuth.password
username: username || '',
password: wsseAuth.password || ''
}
})
);
@@ -40,8 +40,8 @@ const WsseAuth = ({ item, collection, updateAuth, request, save }) => {
collectionUid: collection.uid,
itemUid: item.uid,
content: {
username: wsseAuth.username,
password
username: wsseAuth.username || '',
password: password || ''
}
})
);

View File

@@ -18,8 +18,9 @@ import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collection
import StyledWrapper from './StyledWrapper';
import Documentation from 'components/Documentation/index';
import GraphQLSchemaActions from '../GraphQLSchemaActions/index';
import HeightBoundContainer from 'ui/HeightBoundContainer';
const GraphQLRequestPane = ({ item, collection, leftPaneWidth, onSchemaLoad, toggleDocs, handleGqlClickReference }) => {
const GraphQLRequestPane = ({ item, collection, onSchemaLoad, toggleDocs, handleGqlClickReference }) => {
const dispatch = useDispatch();
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
@@ -66,7 +67,6 @@ const GraphQLRequestPane = ({ item, collection, leftPaneWidth, onSchemaLoad, tog
collection={collection}
theme={displayedTheme}
schema={schema}
width={leftPaneWidth}
onSave={onSave}
value={query}
onRun={onRun}
@@ -154,7 +154,9 @@ const GraphQLRequestPane = ({ item, collection, leftPaneWidth, onSchemaLoad, tog
</div>
<GraphQLSchemaActions item={item} collection={collection} onSchemaLoad={setSchema} toggleDocs={toggleDocs} />
</div>
<section className="flex w-full mt-5 flex-1 relative">{getTabPanel(focusedTab.requestPaneTab)}</section>
<section className="flex w-full mt-5 flex-1 relative">
<HeightBoundContainer>{getTabPanel(focusedTab.requestPaneTab)}</HeightBoundContainer>
</section>
</StyledWrapper>
);
};

View File

@@ -68,6 +68,7 @@ const GraphQLVariables = ({ variables, item, collection }) => {
onRun={onRun}
onSave={onSave}
enableVariableHighlighting={true}
showHintsFor={['variables']}
/>
</>
);

View File

@@ -15,6 +15,7 @@ import Tests from 'components/RequestPane/Tests';
import StyledWrapper from './StyledWrapper';
import { find, get } from 'lodash';
import Documentation from 'components/Documentation/index';
import HeightBoundContainer from 'ui/HeightBoundContainer';
import { useEffect } from 'react';
const ContentIndicator = () => {
@@ -33,7 +34,7 @@ const ErrorIndicator = () => {
);
};
const HttpRequestPane = ({ item, collection, leftPaneWidth }) => {
const HttpRequestPane = ({ item, collection }) => {
const dispatch = useDispatch();
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
@@ -180,7 +181,9 @@ const HttpRequestPane = ({ item, collection, leftPaneWidth }) => {
'mt-5': !isMultipleContentTab
})}
>
{getTabPanel(focusedTab.requestPaneTab)}
<HeightBoundContainer>
{getTabPanel(focusedTab.requestPaneTab)}
</HeightBoundContainer>
</section>
</StyledWrapper>
);

View File

@@ -18,12 +18,7 @@ import { IconWand } from '@tabler/icons';
import onHasCompletion from './onHasCompletion';
let CodeMirror;
const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
if (!SERVER_RENDERED) {
CodeMirror = require('codemirror');
}
const CodeMirror = require('codemirror');
const md = new MD();
const AUTO_COMPLETE_AFTER_KEY = /^[a-zA-Z0-9_@(]$/;

View File

@@ -31,7 +31,7 @@ const Wrapper = styled.div`
}
}
.btn-add-param {
.btn-action {
font-size: 0.8125rem;
&:hover span {
text-decoration: underline;

View File

@@ -1,16 +1,17 @@
import React from 'react';
import React, { useState } from 'react';
import get from 'lodash/get';
import cloneDeep from 'lodash/cloneDeep';
import InfoTip from 'components/InfoTip';
import { IconTrash } from '@tabler/icons';
import { useDispatch } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import { useTheme } from 'providers/Theme';
import {
addQueryParam,
updateQueryParam,
deleteQueryParam,
moveQueryParam,
updatePathParam
updatePathParam,
setQueryParams
} from 'providers/ReduxStore/slices/collections';
import SingleLineEditor from 'components/SingleLineEditor';
import { saveRequest, sendRequest } from 'providers/ReduxStore/slices/collections/actions';
@@ -18,6 +19,7 @@ import { saveRequest, sendRequest } from 'providers/ReduxStore/slices/collection
import StyledWrapper from './StyledWrapper';
import Table from 'components/Table/index';
import ReorderTable from 'components/ReorderTable';
import BulkEditor from '../../BulkEditor';
const QueryParams = ({ item, collection }) => {
const dispatch = useDispatch();
@@ -25,6 +27,8 @@ const QueryParams = ({ item, collection }) => {
const params = item.draft ? get(item, 'draft.request.params') : get(item, 'request.params');
const queryParams = params.filter((param) => param.type === 'query');
const pathParams = params.filter((param) => param.type === 'path');
const [isBulkEditMode, setIsBulkEditMode] = useState(false);
const handleAddQueryParam = () => {
dispatch(
@@ -113,8 +117,31 @@ const QueryParams = ({ item, collection }) => {
);
};
const toggleBulkEditMode = () => {
setIsBulkEditMode(!isBulkEditMode);
};
const handleBulkParamsChange = (newParams) => {
const paramsWithType = newParams.map((item) => ({ ...item, type: 'query' }));
dispatch(setQueryParams({ collectionUid: collection.uid, itemUid: item.uid, params: paramsWithType }));
};
if (isBulkEditMode) {
return (
<StyledWrapper className="w-full mt-3">
<BulkEditor
params={queryParams}
onChange={handleBulkParamsChange}
onToggle={toggleBulkEditMode}
onSave={onSave}
onRun={handleRun}
/>
</StyledWrapper>
);
}
return (
<StyledWrapper className="w-full flex flex-col absolute">
<StyledWrapper className="w-full flex flex-col">
<div className="flex-1 mt-2">
<div className="mb-1 title text-xs">Query</div>
<Table
@@ -171,9 +198,14 @@ const QueryParams = ({ item, collection }) => {
</ReorderTable>
</Table>
<button className="btn-add-param text-link pr-2 py-3 mt-2 select-none" onClick={handleAddQueryParam}>
+&nbsp;<span>Add Param</span>
</button>
<div className="flex justify-between mt-2">
<button className="btn-action text-link pr-2 py-3 select-none" onClick={handleAddQueryParam}>
+&nbsp;<span>Add Param</span>
</button>
<button className="btn-action text-link select-none" onClick={toggleBulkEditMode}>
Bulk Edit
</button>
</div>
<div className="mb-2 title text-xs flex items-stretch">
<span>Path</span>
<InfoTip infotipId="path-param-InfoTip">

View File

@@ -21,7 +21,7 @@ const RequestBodyMode = ({ item, collection }) => {
const Icon = forwardRef((props, ref) => {
return (
<div ref={ref} className="flex items-center justify-center pl-3 py-1 select-none selected-body-mode">
{humanizeRequestBodyMode(bodyMode)} <IconCaretDown className="caret ml-2 mr-2" size={14} strokeWidth={2} />
{humanizeRequestBodyMode(bodyMode)} <IconCaretDown className="caret ml-2" size={14} strokeWidth={2} />
</div>
);
});
@@ -149,7 +149,7 @@ const RequestBodyMode = ({ item, collection }) => {
</Dropdown>
</div>
{(bodyMode === 'json' || bodyMode === 'xml') && (
<button className="ml-1" onClick={onPrettify}>
<button className="ml-2" onClick={onPrettify}>
Prettify
</button>
)}

View File

@@ -59,6 +59,7 @@ const RequestBody = ({ item, collection }) => {
onSave={onSave}
mode={codeMirrorMode[bodyMode]}
enableVariableHighlighting={true}
showHintsFor={['variables']}
/>
</StyledWrapper>
);

View File

@@ -22,8 +22,11 @@ const Wrapper = styled.div`
}
}
.btn-add-header {
.btn-action {
font-size: 0.8125rem;
&:hover span {
text-decoration: underline;
}
}
input[type='text'] {

View File

@@ -1,10 +1,10 @@
import React from 'react';
import React, { useState } from 'react';
import get from 'lodash/get';
import cloneDeep from 'lodash/cloneDeep';
import { IconTrash } from '@tabler/icons';
import { useDispatch } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import { useTheme } from 'providers/Theme';
import { addRequestHeader, updateRequestHeader, deleteRequestHeader, moveRequestHeader } from 'providers/ReduxStore/slices/collections';
import { addRequestHeader, updateRequestHeader, deleteRequestHeader, moveRequestHeader, setRequestHeaders } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import SingleLineEditor from 'components/SingleLineEditor';
import StyledWrapper from './StyledWrapper';
@@ -12,12 +12,16 @@ import { headers as StandardHTTPHeaders } from 'know-your-http-well';
import { MimeTypes } from 'utils/codemirror/autocompleteConstants';
import Table from 'components/Table/index';
import ReorderTable from 'components/ReorderTable/index';
import BulkEditor from '../../BulkEditor';
const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header);
const RequestHeaders = ({ item, collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const headers = item.draft ? get(item, 'draft.request.headers') : get(item, 'request.headers');
const [isBulkEditMode, setIsBulkEditMode] = useState(false);
const addHeader = () => {
dispatch(
@@ -75,6 +79,28 @@ const RequestHeaders = ({ item, collection }) => {
);
};
const toggleBulkEditMode = () => {
setIsBulkEditMode(!isBulkEditMode);
};
const handleBulkHeadersChange = (newHeaders) => {
dispatch(setRequestHeaders({ collectionUid: collection.uid, itemUid: item.uid, headers: newHeaders }));
};
if (isBulkEditMode) {
return (
<StyledWrapper className="w-full mt-3">
<BulkEditor
params={headers}
onChange={handleBulkHeadersChange}
onToggle={toggleBulkEditMode}
onSave={onSave}
onRun={handleRun}
/>
</StyledWrapper>
);
}
return (
<StyledWrapper className="w-full">
<Table
@@ -153,9 +179,14 @@ const RequestHeaders = ({ item, collection }) => {
: null}
</ReorderTable>
</Table>
<button className="btn-add-header text-link pr-2 py-3 mt-2 select-none" onClick={addHeader}>
+ Add Header
</button>
<div className="flex justify-between mt-2">
<button className="btn-action text-link pr-2 py-3 select-none" onClick={addHeader}>
+ Add Header
</button>
<button className="btn-action text-link select-none" onClick={toggleBulkEditMode}>
Bulk Edit
</button>
</div>
</StyledWrapper>
);
};

View File

@@ -52,6 +52,7 @@ const Script = ({ item, collection }) => {
mode="javascript"
onRun={onRun}
onSave={onSave}
showHintsFor={['req', 'bru']}
/>
</div>
<div className="flex flex-col flex-1 mt-2 gap-y-2">
@@ -66,6 +67,7 @@ const Script = ({ item, collection }) => {
mode="javascript"
onRun={onRun}
onSave={onSave}
showHintsFor={['req', 'res', 'bru']}
/>
</div>
</StyledWrapper>

View File

@@ -37,6 +37,7 @@ const Tests = ({ item, collection }) => {
mode="javascript"
onRun={onRun}
onSave={onSave}
showHintsFor={['req', 'res', 'bru']}
/>
);
};

View File

@@ -3,9 +3,13 @@ import styled from 'styled-components';
const StyledWrapper = styled.div`
&.dragging {
cursor: col-resize;
&.vertical-layout {
cursor: row-resize;
}
}
div.drag-request {
div.dragbar-wrapper {
display: flex;
align-items: center;
justify-content: center;
@@ -15,18 +19,47 @@ const StyledWrapper = styled.div`
cursor: col-resize;
background: transparent;
div.drag-request-border {
div.dragbar-handle {
display: flex;
height: 100%;
width: 1px;
border-left: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.border};
}
&:hover div.drag-request-border {
&:hover div.dragbar-handle {
border-left: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.activeBorder};
}
}
&.vertical-layout {
.request-pane {
padding-bottom: 0.5rem;
}
.response-pane {
padding-top: 0.5rem;
}
div.dragbar-wrapper {
width: 100%;
height: 10px;
cursor: row-resize;
padding: 0 1rem;
div.dragbar-handle {
width: 100%;
height: 1px;
border-left: none;
border-top: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.border};
}
&:hover div.dragbar-handle {
border-left: none;
border-top: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.activeBorder};
}
}
}
div.graphql-docs-explorer-container {
background: white;
outline: none;

View File

@@ -29,7 +29,8 @@ import FolderNotFound from './FolderNotFound';
const MIN_LEFT_PANE_WIDTH = 300;
const MIN_RIGHT_PANE_WIDTH = 350;
const DEFAULT_PADDING = 5;
const MIN_TOP_PANE_HEIGHT = 150;
const MIN_BOTTOM_PANE_HEIGHT = 150;
const RequestTabPanel = () => {
if (typeof window == 'undefined') {
@@ -41,6 +42,8 @@ const RequestTabPanel = () => {
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments);
const _collections = useSelector((state) => state.collections.collections);
const preferences = useSelector((state) => state.app.preferences);
const isVerticalLayout = preferences?.layout?.responsePaneOrientation === 'vertical';
// merge `globalEnvironmentVariables` into the active collection and rebuild `collections` immer proxy object
let collections = produce(_collections, (draft) => {
@@ -64,13 +67,15 @@ const RequestTabPanel = () => {
let asideWidth = useSelector((state) => state.app.leftSidebarWidth);
const [leftPaneWidth, setLeftPaneWidth] = useState(
focusedTab && focusedTab.requestPaneWidth ? focusedTab.requestPaneWidth : (screenWidth - asideWidth) / 2.2
); // 2.2 so that request pane is relatively smaller
const [rightPaneWidth, setRightPaneWidth] = useState(screenWidth - asideWidth - leftPaneWidth - DEFAULT_PADDING);
); // 2.2 is intentional to make both panes appear to be of equal width
const [topPaneHeight, setTopPaneHeight] = useState(focusedTab?.requestPaneHeight || MIN_TOP_PANE_HEIGHT);
const [dragging, setDragging] = useState(false);
const dragOffset = useRef({ x: 0, y: 0 });
// Not a recommended pattern here to have the child component
// make a callback to set state, but treating this as an exception
const docExplorerRef = useRef(null);
const mainSectionRef = useRef(null);
const [schema, setSchema] = useState(null);
const [showGqlDocs, setShowGqlDocs] = useState(false);
const onSchemaLoad = (schema) => setSchema(schema);
@@ -85,43 +90,72 @@ const RequestTabPanel = () => {
};
useEffect(() => {
const leftPaneWidth = (screenWidth - asideWidth) / 2.2;
setLeftPaneWidth(leftPaneWidth);
}, [screenWidth]);
useEffect(() => {
setRightPaneWidth(screenWidth - asideWidth - leftPaneWidth - DEFAULT_PADDING);
}, [screenWidth, asideWidth, leftPaneWidth]);
// Initialize vertical heights when switching to vertical layout
if (mainSectionRef.current) {
const mainRect = mainSectionRef.current.getBoundingClientRect();
if (isVerticalLayout) {
const initialHeight = mainRect.height / 2;
setTopPaneHeight(initialHeight);
// In vertical mode, set leftPaneWidth to full container width
setLeftPaneWidth(mainRect.width);
} else {
// In horizontal mode, set to roughly half width
setLeftPaneWidth((screenWidth - asideWidth) / 2.2);
}
}
}, [isVerticalLayout, screenWidth, asideWidth]);
const handleMouseMove = (e) => {
if (dragging) {
if (dragging && mainSectionRef.current) {
e.preventDefault();
let leftPaneXPosition = e.clientX + 2;
if (
leftPaneXPosition < asideWidth + DEFAULT_PADDING + MIN_LEFT_PANE_WIDTH ||
leftPaneXPosition > screenWidth - MIN_RIGHT_PANE_WIDTH
) {
return;
const mainRect = mainSectionRef.current.getBoundingClientRect();
if (isVerticalLayout) {
const newHeight = e.clientY - mainRect.top - dragOffset.current.y;
if (newHeight < MIN_TOP_PANE_HEIGHT || newHeight > mainRect.height - MIN_BOTTOM_PANE_HEIGHT) {
return;
}
setTopPaneHeight(newHeight);
} else {
const newWidth = e.clientX - mainRect.left - dragOffset.current.x;
if (newWidth < MIN_LEFT_PANE_WIDTH || newWidth > mainRect.width - MIN_RIGHT_PANE_WIDTH) {
return;
}
setLeftPaneWidth(newWidth);
}
setLeftPaneWidth(leftPaneXPosition - asideWidth);
setRightPaneWidth(screenWidth - e.clientX - DEFAULT_PADDING);
}
};
const handleMouseUp = (e) => {
if (dragging) {
if (dragging && mainSectionRef.current) {
e.preventDefault();
setDragging(false);
dispatch(
updateRequestPaneTabWidth({
uid: activeTabUid,
requestPaneWidth: e.clientX - asideWidth - DEFAULT_PADDING
})
);
if (!isVerticalLayout) {
const mainRect = mainSectionRef.current.getBoundingClientRect();
dispatch(
updateRequestPaneTabWidth({
uid: activeTabUid,
requestPaneWidth: e.clientX - mainRect.left
})
);
}
}
};
const handleDragbarMouseDown = (e) => {
e.preventDefault();
setDragging(true);
if (isVerticalLayout) {
const dragBar = e.currentTarget;
const dragBarRect = dragBar.getBoundingClientRect();
dragOffset.current.y = e.clientY - dragBarRect.top;
} else {
const dragBar = e.currentTarget;
const dragBarRect = dragBar.getBoundingClientRect();
dragOffset.current.x = e.clientX - dragBarRect.left;
}
};
useEffect(() => {
@@ -132,7 +166,7 @@ const RequestTabPanel = () => {
document.removeEventListener('mouseup', handleMouseUp);
document.removeEventListener('mousemove', handleMouseMove);
};
}, [dragging, asideWidth]);
}, [dragging]);
if (!activeTabUid) {
return <Welcome />;
@@ -197,15 +231,19 @@ const RequestTabPanel = () => {
};
return (
<StyledWrapper className={`flex flex-col flex-grow relative ${dragging ? 'dragging' : ''}`}>
<StyledWrapper className={`flex flex-col flex-grow relative ${dragging ? 'dragging' : ''} ${isVerticalLayout ? 'vertical-layout' : ''}`}>
<div className="pt-4 pb-3 px-4">
<QueryUrl item={item} collection={collection} handleRun={handleRun} />
</div>
<section className="main flex flex-grow pb-4 relative">
<section ref={mainSectionRef} className={`main flex ${isVerticalLayout ? 'flex-col' : ''} flex-grow pb-4 relative`}>
<section className="request-pane">
<div
className="px-4 h-full"
style={{
style={isVerticalLayout ? {
height: `${Math.max(topPaneHeight, MIN_TOP_PANE_HEIGHT)}px`,
minHeight: `${MIN_TOP_PANE_HEIGHT}px`,
width: '100%'
} : {
width: `${Math.max(leftPaneWidth, MIN_LEFT_PANE_WIDTH)}px`
}}
>
@@ -213,7 +251,6 @@ const RequestTabPanel = () => {
<GraphQLRequestPane
item={item}
collection={collection}
leftPaneWidth={leftPaneWidth}
onSchemaLoad={onSchemaLoad}
toggleDocs={toggleDocs}
handleGqlClickReference={handleGqlClickReference}
@@ -221,17 +258,17 @@ const RequestTabPanel = () => {
) : null}
{item.type === 'http-request' ? (
<HttpRequestPane item={item} collection={collection} leftPaneWidth={leftPaneWidth} />
<HttpRequestPane item={item} collection={collection} />
) : null}
</div>
</section>
<div className="drag-request" onMouseDown={handleDragbarMouseDown}>
<div className="drag-request-border" />
<div className="dragbar-wrapper" onMouseDown={handleDragbarMouseDown}>
<div className="dragbar-handle" />
</div>
<section className="response-pane flex-grow">
<ResponsePane item={item} collection={collection} rightPaneWidth={rightPaneWidth} response={item.response} />
<section className="response-pane flex-grow overflow-x-auto">
<ResponsePane item={item} collection={collection} response={item.response} />
</section>
</section>

View File

@@ -0,0 +1,65 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
display: flex;
flex-direction: column;
align-items: center;
.warning-container {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 1.5rem;
margin-top: 10%;
text-align: center;
max-width: 480px;
}
.warning-icon {
margin-bottom: 1rem;
color: ${(props) => props.theme.colors.text.yellow};
}
.warning-title {
font-weight: 600;
color: ${(props) => props.theme.text};
margin-bottom: 1rem;
}
.warning-description {
color: ${(props) => props.theme.colors.text.muted};
.size-highlight {
padding: 0.125rem 0.375rem;
border-radius: 4px;
font-size: 0.8rem;
}
.current-size {
color: ${(props) => props.theme.colors.text.danger};
background: ${(props) => props.theme.colors.text.danger}15;
}
.supported-size {
color: ${(props) => props.theme.colors.text.yellow};
background: ${(props) => props.theme.colors.text.yellow}15;
}
}
.warning-actions {
display: flex;
gap: 0.75rem;
}
button {
align-items: center;
display: flex;
gap: 0.5rem;
background: ${(props) => props.theme.button.secondary.bg};
border-radius: 4px;
padding: 0.5rem 1rem;
cursor: pointer;
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,92 @@
import React from 'react';
import { IconDownload, IconCopy, IconEye, IconAlertTriangle } from '@tabler/icons';
import toast from 'react-hot-toast';
import get from 'lodash/get';
import StyledWrapper from './StyledWrapper';
import { formatSize } from 'utils/common/index';
const LargeResponseWarning = ({ item, responseSize, onRevealResponse }) => {
const { ipcRenderer } = window;
const response = item.response || {};
const saveResponseToFile = () => {
return new Promise((resolve, reject) => {
ipcRenderer
.invoke('renderer:save-response-to-file', response, item.requestSent.url)
.then(() => {
toast.success('Response saved to file');
resolve();
})
.catch((err) => {
toast.error(get(err, 'error.message') || 'Something went wrong!');
reject(err);
});
});
};
const copyResponse = () => {
try {
const textToCopy = typeof response.data === 'string'
? response.data
: JSON.stringify(response.data, null, 2);
navigator.clipboard.writeText(textToCopy).then(() => {
toast.success('Response copied to clipboard');
}).catch(() => {
toast.error('Failed to copy response');
});
} catch (error) {
toast.error('Failed to copy response');
}
};
return (
<StyledWrapper>
<div className="warning-container">
<div className="warning-icon">
<IconAlertTriangle size={45} strokeWidth={2} />
</div>
<div className="warning-content">
<div className="warning-title">
Large Response Warning
</div>
<div className="warning-description">
Handling responses over <span className="size-highlight supported-size">{formatSize(10 * 1024 * 1024)}</span> could degrade performance.
<br />
Size of current response: <span className="size-highlight current-size">{formatSize(responseSize)}</span>
</div>
</div>
</div>
<div className="warning-actions">
<button
className="btn-reveal"
onClick={onRevealResponse}
title="Show response content"
>
<IconEye size={18} strokeWidth={1.5} />
View
</button>
<button
className="btn-save"
onClick={saveResponseToFile}
disabled={!response.dataBuffer}
title="Save response to file"
>
<IconDownload size={18} strokeWidth={1.5} />
Save
</button>
<button
className="btn-copy"
onClick={copyResponse}
disabled={!response.data}
title="Copy response to clipboard"
>
<IconCopy size={18} strokeWidth={1.5} />
Copy
</button>
</div>
</StyledWrapper>
);
};
export default LargeResponseWarning;

View File

@@ -22,6 +22,15 @@ const StyledWrapper = styled.div`
animation: rotateCounterClockwise 1s linear infinite;
}
}
// spinner and request time content looks better centered vertically in vertical layout
// while in horizontal layout, it looks better when the content is aligned to the top
&.vertical-layout {
div.overlay {
justify-content: center;
padding: 1rem;
}
}
`;
export default StyledWrapper;

View File

@@ -1,19 +1,21 @@
import React from 'react';
import { IconRefresh } from '@tabler/icons';
import { useDispatch } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import { cancelRequest } from 'providers/ReduxStore/slices/collections/actions';
import StopWatch from '../../StopWatch';
import StyledWrapper from './StyledWrapper';
const ResponseLoadingOverlay = ({ item, collection }) => {
const dispatch = useDispatch();
const preferences = useSelector((state) => state.app.preferences);
const isVerticalLayout = preferences?.layout?.responsePaneOrientation === 'vertical';
const handleCancelRequest = () => {
dispatch(cancelRequest(item.cancelTokenUid, item, collection));
};
return (
<StyledWrapper className="w-full">
<StyledWrapper className={`w-full ${isVerticalLayout ? 'vertical-layout' : ''}`}>
<div className="overlay">
<div style={{ marginBottom: 15, fontSize: 26 }}>
<div style={{ display: 'inline-block', fontSize: 20, marginLeft: 5, marginRight: 5 }}>

View File

@@ -1,12 +1,19 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
display: flex;
flex-direction: column;
padding-top: 20%;
width: 100%;
.send-icon {
color: ${(props) => props.theme.requestTabPanel.responseSendIcon};
}
&.vertical-layout {
padding: 1rem;
justify-content: center;
}
`;
export default StyledWrapper;

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { IconSend } from '@tabler/icons';
import { useSelector } from 'react-redux';
import StyledWrapper from './StyledWrapper';
import { isMacOS } from 'utils/common/platform';
@@ -8,9 +9,11 @@ const Placeholder = () => {
const sendRequestShortcut = isMac ? 'Cmd + Enter' : 'Ctrl + Enter';
const newRequestShortcut = isMac ? 'Cmd + B' : 'Ctrl + B';
const editEnvironmentShortcut = isMac ? 'Cmd + E' : 'Ctrl + E';
const preferences = useSelector((state) => state.app.preferences);
const isVerticalLayout = preferences?.layout?.responsePaneOrientation === 'vertical';
return (
<StyledWrapper>
<StyledWrapper className={`${isVerticalLayout ? 'vertical-layout' : ''}`}>
<div className="send-icon flex justify-center" style={{ fontSize: 200 }}>
<IconSend size={150} strokeWidth={1} />
</div>

View File

@@ -11,6 +11,7 @@ import StyledWrapper from './StyledWrapper';
import { useState, useMemo, useEffect } from 'react';
import { useTheme } from 'providers/Theme/index';
import { getEncoding, uuid } from 'utils/common/index';
import LargeResponseWarning from '../LargeResponseWarning';
const formatResponse = (data, dataBuffer, encoding, mode, filter) => {
if (data === undefined || !dataBuffer || !mode) {
@@ -73,10 +74,11 @@ const formatErrorMessage = (error) => {
return error;
};
const QueryResult = ({ item, collection, data, dataBuffer, width, disableRunEventListener, headers, error }) => {
const QueryResult = ({ item, collection, data, dataBuffer, disableRunEventListener, headers, error }) => {
const contentType = getContentType(headers);
const mode = getCodeMirrorModeBasedOnContentType(contentType, data);
const [filter, setFilter] = useState(null);
const [showLargeResponse, setShowLargeResponse] = useState(false);
const responseEncoding = getEncoding(headers);
const formattedData = useMemo(
() => formatResponse(data, dataBuffer, responseEncoding, mode, filter),
@@ -84,6 +86,25 @@ const QueryResult = ({ item, collection, data, dataBuffer, width, disableRunEven
);
const { displayedTheme } = useTheme();
const responseSize = useMemo(() => {
const response = item.response || {};
if (typeof response.size === 'number') {
return response.size;
}
if (!dataBuffer) return 0;
try {
// dataBuffer is base64 encoded, so we need to calculate the actual size
const buffer = Buffer.from(dataBuffer, 'base64');
return buffer.length;
} catch (error) {
return 0;
}
}, [dataBuffer, item.response]);
const isLargeResponse = responseSize > 10 * 1024 * 1024; // 10 MB
const debouncedResultFilterOnChange = debounce((e) => {
setFilter(e.target.value);
}, 250);
@@ -143,7 +164,6 @@ const QueryResult = ({ item, collection, data, dataBuffer, width, disableRunEven
return (
<StyledWrapper
className="w-full h-full relative flex"
style={{ maxWidth: width }}
queryFilterEnabled={queryFilterEnabled}
>
<div className="flex justify-end gap-2 text-xs" role="tablist">
@@ -151,7 +171,9 @@ const QueryResult = ({ item, collection, data, dataBuffer, width, disableRunEven
</div>
{error ? (
<div>
{hasScriptError ? null : <div className="text-red-500">{formatErrorMessage(error)}</div>}
{hasScriptError ? null : (
<div className="text-red-500" style={{ whiteSpace: 'pre-line' }}>{formatErrorMessage(error)}</div>
)}
{error && typeof error === 'string' && error.toLowerCase().includes('self signed certificate') ? (
<div className="mt-6 muted text-xs">
@@ -160,6 +182,12 @@ const QueryResult = ({ item, collection, data, dataBuffer, width, disableRunEven
</div>
) : null}
</div>
) : isLargeResponse && !showLargeResponse ? (
<LargeResponseWarning
item={item}
responseSize={responseSize}
onRevealResponse={() => setShowLargeResponse(true)}
/>
) : (
<div className="h-full flex flex-col">
<div className="flex-1 relative">

View File

@@ -0,0 +1,15 @@
import styled from 'styled-components';
const Wrapper = styled.div`
button {
display: flex;
align-items: center;
padding: 0.25rem;
background: transparent;
border: none;
cursor: pointer;
color: ${(props) => props.theme.colors.text.muted};
}
`;
export default Wrapper;

View File

@@ -0,0 +1,84 @@
import React from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { savePreferences } from 'providers/ReduxStore/slices/app';
import StyledWrapper from './StyledWrapper';
const IconDockToBottom = () => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="currentColor"
fill="none"
>
<path stroke="none" fill="none" d="M0 0h24v24H0z" />
<path d="M4 4m0 2a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2z" />
<path d="M4 15l16 0" />
<path
fill="currentColor"
d="M 5.5135136,19.111502 C 5.2542477,18.995986 5.0221761,18.756859 4.8928709,18.47199 4.7922381,18.250288 4.7788524,18.078909 4.7777079,16.997543 l -0.0013,-1.223586 H 12 19.223587 v 1.22675 c 0,1.194609 -0.0039,1.234605 -0.149369,1.526503 -0.09333,0.187285 -0.240773,0.363095 -0.392978,0.46858 l -0.243606,0.168829 -6.373606,0.0129 c -5.2129418,0.0105 -6.4058225,-0.0015 -6.5505114,-0.06597 z"
/>
</svg>
);
};
const IconDockToRight = () => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="currentColor"
fill="none"
>
<path fill="none" stroke="none" d="M 0,24 V 0 h 24 v 24 z" />
<path d="m 4,20 m 2,0 A 2,2 0 0 1 4,18 V 6 A 2,2 0 0 1 6,4 h 12 a 2,2 0 0 1 2,2 v 12 a 2,2 0 0 1 -2,2 z" />
<path d="M 15,20 V 4" />
<path
fill="currentColor"
stroke="currentColor"
d="m 19.111502,18.486486 c -0.115516,0.259266 -0.354643,0.491338 -0.639512,0.620643 -0.221702,0.100633 -0.393081,0.114019 -1.474447,0.115163 l -1.223586,0.0013 V 12 4.7764125 h 1.22675 c 1.194609,0 1.234605,0.0039 1.526503,0.14937 0.187285,0.09333 0.363095,0.2407725 0.46858,0.3929775 l 0.168829,0.243606 0.0129,6.373606 c 0.0105,5.212942 -0.0015,6.405822 -0.06597,6.550511 z"
/>
</svg>
);
};
const ResponseLayoutToggle = () => {
const dispatch = useDispatch();
const preferences = useSelector((state) => state.app.preferences);
const orientation = preferences?.layout?.responsePaneOrientation || 'horizontal';
const toggleOrientation = () => {
const newOrientation = orientation === 'horizontal' ? 'vertical' : 'horizontal';
const updatedPreferences = {
...preferences,
layout: {
...preferences.layout,
responsePaneOrientation: newOrientation
}
};
dispatch(savePreferences(updatedPreferences));
};
return (
<StyledWrapper className="ml-2 flex items-center">
<button
onClick={toggleOrientation}
title={orientation === 'horizontal' ? 'Switch to vertical layout' : 'Switch to horizontal layout'}
>
{orientation === 'horizontal' ? (
<IconDockToBottom />
) : (
<IconDockToRight />
)}
</button>
</StyledWrapper>
);
};
export default ResponseLayoutToggle;

View File

@@ -0,0 +1,173 @@
import '@testing-library/jest-dom';
import React from 'react';
import { render, screen, fireEvent} from '@testing-library/react';
import { Provider } from 'react-redux';
import { ThemeProvider } from 'providers/Theme';
import { configureStore, createSlice } from '@reduxjs/toolkit';
import ResponseLayoutToggle from './index';
const mockSavePreferences = jest.fn((payload) => ({ type: 'app/savePreferences', payload }));
// Mock the savePreferences action
jest.mock('providers/ReduxStore/slices/app', () => ({
savePreferences: (payload) => mockSavePreferences(payload)
}));
// Mock localStorage
const mockLocalStorage = {
getItem: jest.fn(() => 'dark'),
setItem: jest.fn(),
removeItem: jest.fn()
};
// Mock matchMedia
beforeAll(() => {
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation(query => ({
matches: false,
media: query,
addEventListener: jest.fn(),
removeEventListener: jest.fn()
})),
});
Object.defineProperty(window, 'localStorage', {
value: mockLocalStorage
});
});
beforeEach(() => {
mockSavePreferences.mockClear();
});
const initialState = {
app: {
preferences: {
layout: {
responsePaneOrientation: 'horizontal'
}
}
}
};
const createTestStore = (initialState) => {
const appSlice = createSlice({
name: 'app',
initialState: initialState.app,
reducers: {
savePreferences: (state, action) => {
state.preferences = action.payload;
}
}
});
return configureStore({
reducer: { app: appSlice.reducer }
});
};
const renderWithProviders = (component, customState = initialState) => {
const store = createTestStore(customState);
return {
store,
...render(
<Provider store={store}>
<ThemeProvider>
{component}
</ThemeProvider>
</Provider>
)
};
};
describe('ResponseLayoutToggle', () => {
describe('Initial Render', () => {
it('should render with horizontal orientation by default', () => {
renderWithProviders(<ResponseLayoutToggle />);
const button = screen.getByRole('button');
expect(button).toBeInTheDocument();
expect(button).toHaveAttribute('title', 'Switch to vertical layout');
});
it('should render with vertical orientation when specified', () => {
const customState = {
app: {
preferences: {
layout: {
responsePaneOrientation: 'vertical'
}
}
}
};
renderWithProviders(<ResponseLayoutToggle />, customState);
const button = screen.getByRole('button');
expect(button).toBeInTheDocument();
expect(button).toHaveAttribute('title', 'Switch to horizontal layout');
});
});
describe('Interaction', () => {
it('should switch to vertical layout when clicked in horizontal mode', () => {
const { store } = renderWithProviders(<ResponseLayoutToggle />);
const button = screen.getByRole('button');
// Initial state check
expect(button).toHaveAttribute('title', 'Switch to vertical layout');
fireEvent.click(button);
// Check if action was called
expect(mockSavePreferences).toHaveBeenCalledWith({
layout: {
responsePaneOrientation: 'vertical'
}
});
// Manually update store to simulate state change
store.dispatch(mockSavePreferences({
layout: {
responsePaneOrientation: 'vertical'
}
}));
// Check if button title was updated
expect(button).toHaveAttribute('title', 'Switch to horizontal layout');
});
it('should switch to horizontal layout when clicked in vertical mode', () => {
const customState = {
app: {
preferences: {
layout: {
responsePaneOrientation: 'vertical'
}
}
}
};
const { store } = renderWithProviders(<ResponseLayoutToggle />, customState);
const button = screen.getByRole('button');
// Initial state check
expect(button).toHaveAttribute('title', 'Switch to horizontal layout');
fireEvent.click(button);
// Check if action was called
expect(mockSavePreferences).toHaveBeenCalledWith({
layout: {
responsePaneOrientation: 'horizontal'
}
});
// Manually update store to simulate state change
store.dispatch(mockSavePreferences({
layout: {
responsePaneOrientation: 'horizontal'
}
}));
// Check if button title was updated
expect(button).toHaveAttribute('title', 'Switch to vertical layout');
});
});
});

View File

@@ -6,22 +6,48 @@ import StyledWrapper from './StyledWrapper';
const ScriptError = ({ item, onClose }) => {
const preRequestError = item?.preRequestScriptErrorMessage;
const postResponseError = item?.postResponseScriptErrorMessage;
const testScriptError = item?.testScriptErrorMessage;
if (!preRequestError && !postResponseError) return null;
if (!preRequestError && !postResponseError && !testScriptError) return null;
const errorMessage = preRequestError || postResponseError;
const errorTitle = preRequestError ? 'Pre-Request Script Error' : 'Post-Response Script Error';
const errors = [];
if (preRequestError) {
errors.push({
title: 'Pre-Request Script Error',
message: preRequestError
});
}
if (postResponseError) {
errors.push({
title: 'Post-Response Script Error',
message: postResponseError
});
}
if (testScriptError) {
errors.push({
title: 'Test Script Error',
message: testScriptError
});
}
return (
<StyledWrapper className="mt-4 mb-2">
<div className="flex items-start gap-3 px-4 py-3">
<div className="flex-1 min-w-0">
<div className="error-title">
{errorTitle}
</div>
<div className="error-message">
{errorMessage}
</div>
{errors.map((error, index) => (
<div key={index}>
{index > 0 && <div className="border-t border-gray-300 my-3 dark:border-gray-600"></div>}
<div className="error-title">
{error.title}
</div>
<div className="error-message">
{error.message}
</div>
</div>
))}
</div>
<div
className="close-button flex-shrink-0 cursor-pointer"

View File

@@ -1,7 +1,7 @@
import QueryResult from "components/ResponsePane/QueryResult/index";
import { useState } from "react";
const BodyBlock = ({ collection, data, dataBuffer, headers, error, item, width }) => {
const BodyBlock = ({ collection, data, dataBuffer, headers, error, item }) => {
const [isBodyCollapsed, toggleBody] = useState(true);
return (
<div className="collapsible-section">
@@ -17,7 +17,6 @@ const BodyBlock = ({ collection, data, dataBuffer, headers, error, item, width }
<QueryResult
item={item}
collection={collection}
width={width}
data={data}
dataBuffer={dataBuffer}
headers={headers}

View File

@@ -16,7 +16,7 @@ const safeStringifyJSONIfNotString = (obj) => {
};
const Request = ({ collection, request, item, width }) => {
const Request = ({ collection, request, item }) => {
let { url, headers, data, dataBuffer, error } = request || {};
if (!dataBuffer) {
dataBuffer = Buffer.from(safeStringifyJSONIfNotString(data))?.toString('base64');
@@ -33,7 +33,7 @@ const Request = ({ collection, request, item, width }) => {
<Headers headers={headers} type={'request'} />
{/* Body */}
<BodyBlock collection={collection} data={data} dataBuffer={dataBuffer} error={error} headers={headers} item={item} width={width} />
<BodyBlock collection={collection} data={data} dataBuffer={dataBuffer} error={error} headers={headers} item={item} />
</div>
)
}

View File

@@ -16,7 +16,7 @@ const safeStringifyJSONIfNotString = (obj) => {
}
};
const Response = ({ collection, response, item, width }) => {
const Response = ({ collection, response, item }) => {
let { status, statusCode, statusText, dataBuffer, headers, data, error } = response || {};
if (!dataBuffer) {
dataBuffer = Buffer.from(safeStringifyJSONIfNotString(data))?.toString('base64');
@@ -35,7 +35,7 @@ const Response = ({ collection, response, item, width }) => {
<Headers headers={headers} type={'response'} />
{/* Body */}
<BodyBlock collection={collection} data={data} dataBuffer={dataBuffer} error={error} headers={headers} item={item} width={width} />
<BodyBlock collection={collection} data={data} dataBuffer={dataBuffer} error={error} headers={headers} item={item} />
</div>
)
}

View File

@@ -6,7 +6,7 @@ import Method from "./Common/Method/index";
import Status from "./Common/Status/index";
import { RelativeTime } from "./Common/Time/index";
const TimelineItem = ({ timestamp, request, response, item, collection, width, isOauth2 }) => {
const TimelineItem = ({ timestamp, request, response, item, collection, isOauth2 }) => {
const [isCollapsed, _toggleCollapse] = useState(false);
const [activeTab, setActiveTab] = useState('request');
const toggleCollapse = () => _toggleCollapse(prev => !prev);
@@ -57,15 +57,15 @@ const TimelineItem = ({ timestamp, request, response, item, collection, width, i
</div>
{/* Tab Content */}
<div className="tab-content">
<div className="tab-content break-all">
{/* Request Tab */}
{activeTab === 'request' && (
<Request request={request} item={item} collection={collection} width={width} />
<Request request={request} item={item} collection={collection} />
)}
{/* Response Tab */}
{activeTab === 'response' && (
<Response response={response} item={item} collection={collection} width={width} />
<Response response={response} item={item} collection={collection} />
)}
{/* Network Logs Tab */}

View File

@@ -41,7 +41,7 @@ const getEffectiveAuthSource = (collection, item) => {
return effectiveSource;
};
const Timeline = ({ collection, item, width }) => {
const Timeline = ({ collection, item }) => {
// Get the effective auth source if auth mode is inherit
const authSource = getEffectiveAuthSource(collection, item);
@@ -62,7 +62,6 @@ const Timeline = ({ collection, item, width }) => {
return (
<StyledWrapper
className="pb-4 w-full flex flex-grow flex-col"
style={{ maxWidth: width - 60, overflowWrap: 'break-word' }}
>
{combinedTimeline.map((event, index) => {
if (event.type === 'request') {
@@ -76,7 +75,6 @@ const Timeline = ({ collection, item, width }) => {
response={response}
item={item}
collection={collection}
width={width}
/>
</div>
);
@@ -101,7 +99,6 @@ const Timeline = ({ collection, item, width }) => {
response={data?.response}
item={item}
collection={collection}
width={width - 50}
isOauth2={true}
/>
</div>

View File

@@ -20,8 +20,10 @@ import ResponseSave from 'src/components/ResponsePane/ResponseSave';
import ResponseClear from 'src/components/ResponsePane/ResponseClear';
import SkippedRequest from './SkippedRequest';
import ClearTimeline from './ClearTimeline/index';
import ResponseLayoutToggle from './ResponseLayoutToggle';
import HeightBoundContainer from 'ui/HeightBoundContainer';
const ResponsePane = ({ rightPaneWidth, item, collection }) => {
const ResponsePane = ({ item, collection }) => {
const dispatch = useDispatch();
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
@@ -33,10 +35,10 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
});
useEffect(() => {
if (item?.preRequestScriptErrorMessage || item?.postResponseScriptErrorMessage) {
if (item?.preRequestScriptErrorMessage || item?.postResponseScriptErrorMessage || item?.testScriptErrorMessage) {
setShowScriptErrorCard(true);
}
}, [item?.preRequestScriptErrorMessage, item?.postResponseScriptErrorMessage]);
}, [item?.preRequestScriptErrorMessage, item?.postResponseScriptErrorMessage, item?.testScriptErrorMessage]);
const selectTab = (tab) => {
dispatch(
@@ -57,7 +59,6 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
<QueryResult
item={item}
collection={collection}
width={rightPaneWidth}
data={response.data}
dataBuffer={response.dataBuffer}
headers={response.headers}
@@ -70,7 +71,7 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
return <ResponseHeaders headers={response.headers} />;
}
case 'timeline': {
return <Timeline collection={collection} item={item} width={rightPaneWidth} />;
return <Timeline collection={collection} item={item} />;
}
case 'tests': {
return <TestResults
@@ -105,9 +106,9 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
if (!item.response && !requestTimeline?.length) {
return (
<StyledWrapper className="flex h-full relative">
<HeightBoundContainer>
<Placeholder />
</StyledWrapper>
</HeightBoundContainer>
);
}
@@ -128,11 +129,11 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
const responseHeadersCount = typeof response.headers === 'object' ? Object.entries(response.headers).length : 0;
const hasScriptError = item?.preRequestScriptErrorMessage || item?.postResponseScriptErrorMessage;
const hasScriptError = item?.preRequestScriptErrorMessage || item?.postResponseScriptErrorMessage || item?.testScriptErrorMessage;
return (
<StyledWrapper className="flex flex-col h-full relative">
<div className="flex flex-wrap items-center pl-3 pr-4 tabs" role="tablist">
<div className="flex flex-wrap items-center px-4 tabs" role="tablist">
<div className={getTabClassname('response')} role="tab" onClick={() => selectTab('response')}>
Response
</div>
@@ -159,6 +160,7 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
onClick={() => setShowScriptErrorCard(true)}
/>
)}
<ResponseLayoutToggle />
{focusedTab?.responsePaneTab === "timeline" ? (
<ClearTimeline item={item} collection={collection} />
) : (item?.response && !item?.response?.error) ? (
@@ -174,7 +176,11 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
) : null}
</div>
<section
className={`flex flex-col flex-grow relative pl-3 pr-4 ${focusedTab.responsePaneTab === 'response' ? '' : 'mt-4'}`}
className={`flex flex-col min-h-0 relative px-4 auto`}
style={{
flex: '1 1 0',
height: hasScriptError && showScriptErrorCard ? 'auto' : '100%'
}}
>
{isLoading ? <Overlay item={item} collection={collection} /> : null}
{hasScriptError && showScriptErrorCard && (
@@ -183,17 +189,18 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
onClose={() => setShowScriptErrorCard(false)}
/>
)}
{!item?.response ? (
focusedTab?.responsePaneTab === "timeline" && requestTimeline?.length ? (
<Timeline
collection={collection}
item={item}
width={rightPaneWidth}
/>
) : null
) : (
<>{getTabPanel(focusedTab.responsePaneTab)}</>
)}
<div className='flex-1 min-h-[200px] overflow-y-auto'>
{!item?.response ? (
focusedTab?.responsePaneTab === "timeline" && requestTimeline?.length ? (
<Timeline
collection={collection}
item={item}
/>
) : null
) : (
<>{getTabPanel(focusedTab.responsePaneTab)}</>
)}
</div>
</section>
</StyledWrapper>
);

View File

@@ -3,16 +3,6 @@ import styled from 'styled-components';
const StyledWrapper = styled.div`
max-width: 800px;
span.beta-tag {
display: flex;
align-items: center;
padding: 0.1rem 0.25rem;
font-size: 0.75rem;
border-radius: 0.25rem;
color: ${(props) => props.theme.colors.text.green};
border: solid 1px ${(props) => props.theme.colors.text.green} !important;
}
span.developer-mode-warning {
font-weight: 400;
color: ${(props) => props.theme.colors.text.yellow};

View File

@@ -61,7 +61,6 @@ const JsSandboxModeModal = ({ collection }) => {
<span className={jsSandboxMode === 'safe' ? 'font-medium' : 'font-normal'}>
Safe Mode
</span>
<span className='beta-tag'>BETA</span>
</label>
<p className='text-sm text-muted mt-1'>
JavaScript code is executed in a secure sandbox and cannot access your filesystem or execute system commands.
@@ -85,9 +84,6 @@ const JsSandboxModeModal = ({ collection }) => {
<p className='text-sm text-muted mt-1'>
JavaScript code has access to the filesystem, can execute system commands and access sensitive information.
</p>
<small className='text-muted mt-6'>
* SAFE mode has been introduced v1.26 onwards and is in beta. Please report any issues on github.
</small>
</div>
</StyledWrapper>
</Modal>

View File

@@ -3,16 +3,6 @@ import styled from 'styled-components';
const StyledWrapper = styled.div`
max-width: 800px;
span.beta-tag {
display: flex;
align-items: center;
padding: 0.1rem 0.25rem;
font-size: 0.75rem;
border-radius: 0.25rem;
color: ${(props) => props.theme.colors.text.green};
border: solid 1px ${(props) => props.theme.colors.text.green} !important;
}
span.developer-mode-warning {
font-weight: 400;
color: ${(props) => props.theme.colors.text.yellow};

View File

@@ -47,7 +47,6 @@ const SecuritySettings = ({ collection }) => {
<span className={jsSandboxMode === 'safe' ? 'font-medium' : 'font-normal'}>
Safe Mode
</span>
<span className='beta-tag'>BETA</span>
</label>
<p className='text-sm text-muted mt-1'>
JavaScript code is executed in a secure sandbox and cannot access your filesystem or execute system commands.
@@ -75,9 +74,6 @@ const SecuritySettings = ({ collection }) => {
<button onClick={handleSave} className="submit btn btn-sm btn-secondary w-fit mt-6">
Save
</button>
<small className='text-muted mt-6'>
* SAFE mode has been introduced v1.26 onwards and is in beta. Please report any issues on github.
</small>
</div>
</StyledWrapper>
);

View File

@@ -1,19 +1,59 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
position: relative;
height: 100%;
position: relative;
.editor-content {
height: 100%;
.CodeMirror {
height: 100%;
font-size: 12px;
line-height: 1.5;
padding: 0;
.CodeMirror-gutters {
background: ${props => props.theme.codemirror.gutter.bg};
border-right: 1px solid ${props => props.theme.codemirror.border};
}
.CodeMirror-linenumber {
color: ${props => props.theme.colors.text.muted};
font-size: 11px;
padding: 0 3px 0 5px;
}
.CodeMirror-lines {
padding: 0;
}
.CodeMirror-line {
padding: 0 4px;
}
}
}
.copy-to-clipboard {
position: absolute;
cursor: pointer;
top: 10px;
right: 10px;
z-index: 10;
opacity: 0.5;
background: transparent;
border: none;
color: ${props => props.theme.colors.text.muted};
cursor: pointer;
padding: 6px;
opacity: 0.7;
transition: all 0.2s ease;
&:hover {
opacity: 1;
color: ${props => props.theme.text};
}
&:active {
transform: translateY(1px);
}
}
`;

View File

@@ -1,64 +1,52 @@
import CodeEditor from 'components/CodeEditor/index';
import get from 'lodash/get';
import { HTTPSnippet } from 'httpsnippet';
import { useTheme } from 'providers/Theme/index';
import StyledWrapper from './StyledWrapper';
import { buildHarRequest } from 'utils/codegenerator/har';
import { useSelector } from 'react-redux';
import { CopyToClipboard } from 'react-copy-to-clipboard';
import toast from 'react-hot-toast';
import { IconCopy } from '@tabler/icons';
import { findCollectionByItemUid, getGlobalEnvironmentVariables } from '../../../../../../../utils/collections/index';
import { getAuthHeaders } from '../../../../../../../utils/codegenerator/auth';
import { findCollectionByItemUid, getGlobalEnvironmentVariables } from 'utils/collections/index';
import { cloneDeep } from 'lodash';
import { useMemo } from 'react';
import { generateSnippet } from '../utils/snippet-generator';
const CodeView = ({ language, item }) => {
const { displayedTheme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments);
const { target, client, language: lang } = language;
const requestHeaders = item.draft ? get(item, 'draft.request.headers') : get(item, 'request.headers');
let _collection = findCollectionByItemUid(
const generateCodePrefs = useSelector((state) => state.app.generateCode);
let collectionOriginal = findCollectionByItemUid(
useSelector((state) => state.collections.collections),
item.uid
);
let collection = cloneDeep(_collection);
const collection = useMemo(() => {
const c = cloneDeep(collectionOriginal);
const globalEnvironmentVariables = getGlobalEnvironmentVariables({
globalEnvironments,
activeGlobalEnvironmentUid
});
c.globalEnvironmentVariables = globalEnvironmentVariables;
return c;
}, [collectionOriginal, globalEnvironments, activeGlobalEnvironmentUid]);
// add selected global env variables to the collection object
const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid });
collection.globalEnvironmentVariables = globalEnvironmentVariables;
const collectionRootAuth = collection?.root?.request?.auth;
const requestAuth = item.draft ? get(item, 'draft.request.auth') : get(item, 'request.auth');
const headers = [
...getAuthHeaders(collectionRootAuth, requestAuth),
...(collection?.root?.request?.headers || []),
...(requestHeaders || [])
];
let snippet = '';
try {
snippet = new HTTPSnippet(buildHarRequest({ request: item.request, headers, type: item.type })).convert(
target,
client
);
} catch (e) {
console.error(e);
snippet = 'Error generating code snippet';
}
const snippet = useMemo(() => {
return generateSnippet({ language, item, collection, shouldInterpolate: generateCodePrefs.shouldInterpolate });
}, [language, item, collection, generateCodePrefs.shouldInterpolate]);
return (
<>
<StyledWrapper>
<CopyToClipboard
className="copy-to-clipboard"
text={snippet}
onCopy={() => toast.success('Copied to clipboard!')}
>
<StyledWrapper>
<CopyToClipboard
text={snippet}
onCopy={() => toast.success('Copied to clipboard!')}
>
<button className="copy-to-clipboard">
<IconCopy size={25} strokeWidth={1.5} />
</CopyToClipboard>
</button>
</CopyToClipboard>
<div className="editor-content">
<CodeEditor
readOnly
collection={collection}
@@ -67,10 +55,12 @@ const CodeView = ({ language, item }) => {
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
theme={displayedTheme}
mode={lang}
mode={language.language}
enableVariableHighlighting={true}
showHintsFor={['variables']}
/>
</StyledWrapper>
</>
</div>
</StyledWrapper>
);
};

View File

@@ -0,0 +1,117 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
background: ${props => props.theme.requestTabPanel.card.bg};
border-bottom: 1px solid ${props => props.theme.requestTabPanel.card.border};
gap: 12px;
flex-shrink: 0;
}
.left-controls {
display: flex;
align-items: center;
gap: 12px;
}
.select-wrapper {
position: relative;
display: flex;
align-items: center;
}
.select-arrow {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
pointer-events: none;
color: ${props => props.theme.colors.text.muted};
}
.native-select {
background: ${props => props.theme.requestTabPanel.url.bg};
border: 1px solid ${props => props.theme.input.border};
border-radius: 3px;
color: ${props => props.theme.text};
font-size: 12px;
padding: 6px 28px 6px 10px;
min-width: 140px;
height: 32px;
cursor: pointer;
transition: all 0.2s ease;
appearance: none;
&:hover {
border-color: ${props => props.theme.input.focusBorder};
}
&:focus {
outline: none;
border-color: ${props => props.theme.input.focusBorder};
box-shadow: 0 0 0 2px ${props => props.theme.input.focusBoxShadow};
}
option {
background: ${props => props.theme.bg};
color: ${props => props.theme.text};
padding: 8px 12px;
}
}
.library-options {
display: flex;
gap: 6px;
}
.lib-btn {
height: 32px;
padding: 0 12px;
background: ${props => props.theme.requestTabPanel.url.bg};
border: 1px solid ${props => props.theme.input.border};
border-radius: 3px;
color: ${props => props.theme.text};
font-size: 12px;
cursor: pointer;
transition: all 0.15s ease;
display: flex;
align-items: center;
&:hover {
background: ${props => props.theme.dropdown.hoverBg};
border-color: ${props => props.theme.input.focusBorder};
}
&.active {
background: ${props => props.theme.button.secondary.bg};
border-color: ${props => props.theme.button.secondary.border};
color: ${props => props.theme.button.secondary.color};
}
}
.right-controls {
.interpolate-checkbox {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-size: 13px;
color: ${props => props.theme.text};
input[type="checkbox"] {
cursor: pointer;
margin: 0;
}
&:hover {
opacity: 0.8;
}
}
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,106 @@
import { IconChevronDown } from '@tabler/icons';
import { useSelector, useDispatch } from 'react-redux';
import { useMemo } from 'react';
import { getLanguages } from 'utils/codegenerator/targets';
import { updateGenerateCode } from 'providers/ReduxStore/slices/app';
import StyledWrapper from './StyledWrapper';
const CodeViewToolbar = () => {
const dispatch = useDispatch();
const languages = getLanguages();
const generateCodePrefs = useSelector((state) => state.app.generateCode);
// Group languages by their main language type
const languageGroups = useMemo(() => {
return languages.reduce((acc, lang) => {
const mainLang = lang.name.split('-')[0];
if (!acc[mainLang]) {
acc[mainLang] = [];
}
acc[mainLang].push({
...lang,
libraryName: lang.name.split('-')[1] || 'default'
});
return acc;
}, {});
}, [languages]);
const mainLanguages = useMemo(() => Object.keys(languageGroups), [languageGroups]);
const availableLibraries = useMemo(() => {
return languageGroups[generateCodePrefs.mainLanguage] || [];
}, [generateCodePrefs.mainLanguage, languageGroups]);
// Event handlers
const handleMainLanguageChange = (e) => {
const newMainLang = e.target.value;
const defaultLibrary = languageGroups[newMainLang][0].libraryName;
dispatch(updateGenerateCode({
mainLanguage: newMainLang,
library: defaultLibrary
}));
};
const handleLibraryChange = (libraryName) => {
dispatch(updateGenerateCode({
library: libraryName
}));
};
const handleInterpolateChange = (e) => {
dispatch(updateGenerateCode({
shouldInterpolate: e.target.checked
}));
};
return (
<StyledWrapper>
<div className="toolbar">
<div className="left-controls">
<div className="select-wrapper">
<select
className="native-select"
value={generateCodePrefs.mainLanguage}
onChange={handleMainLanguageChange}
>
{mainLanguages.map((lang) => (
<option key={lang} value={lang}>
{lang}
</option>
))}
</select>
<IconChevronDown size={16} className="select-arrow" />
</div>
{availableLibraries.length > 1 && (
<div className="library-options">
{availableLibraries.map((lib) => (
<button
key={lib.libraryName}
className={`lib-btn ${generateCodePrefs.library === lib.libraryName ? 'active' : ''}`}
onClick={() => handleLibraryChange(lib.libraryName)}
>
{lib.libraryName}
</button>
))}
</div>
)}
</div>
<div className="right-controls">
<label className="interpolate-checkbox">
<input
type="checkbox"
checked={generateCodePrefs.shouldInterpolate}
onChange={handleInterpolateChange}
/>
<span>Interpolate Variables</span>
</label>
</div>
</div>
</StyledWrapper>
);
};
export default CodeViewToolbar;

View File

@@ -1,60 +1,44 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
margin-inline: -1rem;
margin-block: -1.5rem;
margin: -1.5rem -1rem;
height: 50vh;
display: flex;
flex-direction: column;
background-color: ${(props) => props.theme.collection.environment.settings.bg};
.generate-code-sidebar {
background-color: ${(props) => props.theme.collection.environment.settings.sidebar.bg};
border-right: solid 1px ${(props) => props.theme.collection.environment.settings.sidebar.borderRight};
max-height: 80vh;
.code-generator {
display: flex;
flex-direction: column;
height: 100%;
overflow-y: auto;
}
.generate-code-item {
min-width: 150px;
display: block;
.editor-container {
flex: 1;
overflow: hidden;
position: relative;
cursor: pointer;
padding: 8px 10px;
border-left: solid 2px transparent;
text-decoration: none;
background: ${props => props.theme.bg};
}
&:hover {
text-decoration: none;
background-color: ${(props) => props.theme.collection.environment.settings.item.hoverBg};
.error-message {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: ${props => props.theme.colors.text.muted};
text-align: center;
padding: 20px;
h1 {
font-size: 14px;
margin-bottom: 8px;
color: ${props => props.theme.text};
}
}
.active {
background-color: ${(props) => props.theme.collection.environment.settings.item.active.bg} !important;
border-left: solid 2px ${(props) => props.theme.collection.environment.settings.item.border};
&:hover {
background-color: ${(props) => props.theme.collection.environment.settings.item.active.hoverBg} !important;
}
}
.flexible-container {
width: 100%;
}
@media (max-width: 600px) {
.flexible-container {
width: 500px;
}
}
@media (min-width: 601px) and (max-width: 1200px) {
.flexible-container {
width: 800px;
}
}
@media (min-width: 1201px) {
.flexible-container {
width: 900px;
p {
font-size: 12px;
opacity: 0.8;
}
}
`;

View File

@@ -1,72 +1,30 @@
import Modal from 'components/Modal/index';
import { useState } from 'react';
import { useMemo } from 'react';
import CodeView from './CodeView';
import CodeViewToolbar from './CodeViewToolbar';
import StyledWrapper from './StyledWrapper';
import { isValidUrl } from 'utils/url';
import { get } from 'lodash';
import { findEnvironmentInCollection, findItemInCollection, findParentItemInCollection } from 'utils/collections';
import {
findEnvironmentInCollection
} from 'utils/collections';
import { interpolateUrl, interpolateUrlPathParams } from 'utils/url/index';
import { getLanguages } from 'utils/codegenerator/targets';
import { useSelector } from 'react-redux';
import { getGlobalEnvironmentVariables } from 'utils/collections/index';
const getTreePathFromCollectionToItem = (collection, _itemUid) => {
let path = [];
let item = findItemInCollection(collection, _itemUid);
while (item) {
path.unshift(item);
item = findParentItemInCollection(collection, item?.uid);
}
return path;
};
// Function to resolve inherited auth
const resolveInheritedAuth = (item, collection) => {
const request = item.draft?.request || item.request;
const authMode = request?.auth?.mode;
// If auth is not inherit or no auth defined, return the request as is
if (!authMode || authMode !== 'inherit') {
return {
...request
};
}
// Get the tree path from collection to item
const requestTreePath = getTreePathFromCollectionToItem(collection, item.uid);
// Default to collection auth
const collectionAuth = get(collection, 'root.request.auth', { mode: 'none' });
let effectiveAuth = collectionAuth;
let source = 'collection';
// Check folders in reverse to find the closest auth configuration
for (let i of [...requestTreePath].reverse()) {
if (i.type === 'folder') {
const folderAuth = get(i, 'root.request.auth');
if (folderAuth && folderAuth.mode && folderAuth.mode !== 'none' && folderAuth.mode !== 'inherit') {
effectiveAuth = folderAuth;
source = 'folder';
break;
}
}
}
return {
...request,
auth: effectiveAuth
};
};
import { resolveInheritedAuth } from './utils/auth-utils';
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 });
const generateCodePrefs = useSelector((state) => state.app.generateCode);
const globalEnvironmentVariables = getGlobalEnvironmentVariables({
globalEnvironments,
activeGlobalEnvironmentUid
});
const environment = findEnvironmentInCollection(collection, collection?.activeEnvironmentUid);
let envVars = {};
if (environment) {
const vars = get(environment, 'variables', []);
@@ -79,7 +37,6 @@ const GenerateCodeItem = ({ collectionUid, item, onClose }) => {
const requestUrl =
get(item, 'draft.request.url') !== undefined ? get(item, 'draft.request.url') : get(item, 'request.url');
// interpolate the url
const interpolatedUrl = interpolateUrl({
url: requestUrl,
globalEnvironmentVariables,
@@ -94,54 +51,27 @@ const GenerateCodeItem = ({ collectionUid, item, onClose }) => {
get(item, 'draft.request.params') !== undefined ? get(item, 'draft.request.params') : get(item, 'request.params')
);
// Get the full language object based on current preferences
const selectedLanguage = useMemo(() => {
const fullName = generateCodePrefs.library === 'default'
? generateCodePrefs.mainLanguage
: `${generateCodePrefs.mainLanguage}-${generateCodePrefs.library}`;
return languages.find(lang => lang.name === fullName) || languages[0];
}, [generateCodePrefs.mainLanguage, generateCodePrefs.library, languages]);
// Resolve auth inheritance
const resolvedRequest = resolveInheritedAuth(item, collection);
const [selectedLanguage, setSelectedLanguage] = useState(languages[0]);
return (
<Modal size="lg" title="Generate Code" handleCancel={onClose} hideFooter={true}>
<StyledWrapper>
<div className="flex w-full flexible-container">
<div>
<div className="generate-code-sidebar">
{languages &&
languages.length &&
languages.map((language) => (
<div
key={language.name}
className={
language.name === selectedLanguage.name ? 'generate-code-item active' : 'generate-code-item'
}
role="button"
tabIndex={0}
onClick={() => setSelectedLanguage(language)}
onKeyDown={(e) => {
if (e.key === 'Tab' || (e.shiftKey && e.key === 'Tab')) {
e.preventDefault();
const currentIndex = languages.findIndex((lang) => lang.name === selectedLanguage.name);
const nextIndex = e.shiftKey
? (currentIndex - 1 + languages.length) % languages.length
: (currentIndex + 1) % languages.length;
setSelectedLanguage(languages[nextIndex]);
<div className="code-generator">
<CodeViewToolbar />
// Explicitly focus on the new active element
const nextElement = document.querySelector(`[data-language="${languages[nextIndex].name}"]`);
nextElement?.focus();
}
}}
data-language={language.name}
aria-pressed={language.name === selectedLanguage.name}
>
<span className="capitalize">{language.name}</span>
</div>
))}
</div>
</div>
<div className="flex-grow p-4">
<div className="editor-container">
{isValidUrl(finalUrl) ? (
<CodeView
tabIndex={-1}
language={selectedLanguage}
item={{
...item,
@@ -152,11 +82,9 @@ const GenerateCodeItem = ({ collectionUid, item, onClose }) => {
}}
/>
) : (
<div className="flex flex-col justify-center items-center w-full">
<div className="text-center">
<h1 className="text-2xl font-bold">Invalid URL: {finalUrl}</h1>
<p className="text-gray-500">Please check the URL and try again</p>
</div>
<div className="error-message">
<h1>Invalid URL: {finalUrl}</h1>
<p>Please check the URL and try again</p>
</div>
)}
</div>

View File

@@ -0,0 +1,53 @@
import { get } from 'lodash';
import {
findItemInCollection,
findParentItemInCollection
} from 'utils/collections';
export const getTreePathFromCollectionToItem = (collection, _itemUid) => {
let path = [];
let item = findItemInCollection(collection, _itemUid);
while (item) {
path.unshift(item);
item = findParentItemInCollection(collection, item?.uid);
}
return path;
};
// Resolve inherited auth by traversing up the folder hierarchy
export const resolveInheritedAuth = (item, collection) => {
const mergedRequest = {
...(item.request || {}),
...(item.draft?.request || {})
};
const authMode = mergedRequest.auth.mode;
// If auth is not inherit or no auth defined, return the merged request as is
if (!authMode || authMode !== 'inherit') {
return mergedRequest;
}
// Get the tree path from collection to item
const requestTreePath = getTreePathFromCollectionToItem(collection, item.uid);
// Default to collection auth
const collectionAuth = get(collection, 'root.request.auth', { mode: 'none' });
let effectiveAuth = collectionAuth;
// Check folders in reverse to find the closest auth configuration
for (let i of [...requestTreePath].reverse()) {
if (i.type === 'folder') {
const folderAuth = get(i, 'root.request.auth');
if (folderAuth && folderAuth.mode && folderAuth.mode !== 'none' && folderAuth.mode !== 'inherit') {
effectiveAuth = folderAuth;
break;
}
}
}
return {
...mergedRequest,
auth: effectiveAuth
};
};

View File

@@ -0,0 +1,68 @@
import { resolveInheritedAuth } from './auth-utils';
// Helper to build mock collection structure
const buildCollection = () => {
return {
uid: 'c1',
root: {
request: {
auth: { mode: 'bearer', bearer: { token: 'COLLECTION' } }
}
},
items: [
{
uid: 'f1',
type: 'folder',
name: 'Folder',
root: {
request: {
auth: { mode: 'basic', basic: { username: 'user', password: 'pass' } }
}
},
items: [
{
uid: 'r1',
type: 'request',
name: 'Request',
request: {
auth: { mode: 'inherit' },
url: 'http://example.com',
method: 'GET'
}
}
]
}
]
};
};
describe('auth-utils.resolveInheritedAuth', () => {
it('should resolve to nearest folder auth when request mode is inherit', () => {
const collection = buildCollection();
const item = collection.items[0].items[0]; // r1
const resolved = resolveInheritedAuth(item, collection);
expect(resolved.auth.mode).toBe('basic');
expect(resolved.auth.basic.username).toBe('user');
});
it('should resolve to collection auth if no folder auth', () => {
const collection = buildCollection();
collection.items[0].root.request.auth = { mode: 'inherit' };
const item = collection.items[0].items[0];
const resolved = resolveInheritedAuth(item, collection);
expect(resolved.auth.mode).toBe('bearer');
expect(resolved.auth.bearer.token).toBe('COLLECTION');
});
it('should return original request when mode is not inherit', () => {
const collection = buildCollection();
const item = collection.items[0].items[0];
item.request.auth = { mode: 'basic', basic: { username: 'override', password: 'pwd' } };
const resolved = resolveInheritedAuth(item, collection);
expect(resolved.auth.mode).toBe('basic');
expect(resolved.auth.basic.username).toBe('override');
});
});

View File

@@ -0,0 +1,88 @@
import { interpolate } from '@usebruno/common';
import { cloneDeep } from 'lodash';
export const interpolateHeaders = (headers = [], variables = {}) => {
return headers.map((header) => ({
...header,
name: interpolate(header.name, variables),
value: interpolate(header.value, variables)
}));
};
export const interpolateBody = (body, variables = {}) => {
if (!body) return null;
const interpolatedBody = cloneDeep(body);
switch (body.mode) {
case 'json':
let parsed = body.json;
// If it's already a string, use it directly; if it's an object, stringify it first
if (typeof parsed === 'object') {
parsed = JSON.stringify(parsed);
}
parsed = interpolate(parsed, variables, { escapeJSONStrings: true });
try {
const jsonObj = JSON.parse(parsed);
interpolatedBody.json = JSON.stringify(jsonObj, null, 2);
} catch {
interpolatedBody.json = parsed;
}
break;
case 'text':
interpolatedBody.text = interpolate(body.text, variables);
break;
case 'xml':
interpolatedBody.xml = interpolate(body.xml, variables);
break;
case 'sparql':
interpolatedBody.sparql = interpolate(body.sparql, variables);
break;
case 'formUrlEncoded':
interpolatedBody.formUrlEncoded = body.formUrlEncoded.map((param) => ({
...param,
value: param.enabled ? interpolate(param.value, variables) : param.value
}));
break;
case 'multipartForm':
interpolatedBody.multipartForm = body.multipartForm.map((param) => ({
...param,
value:
param.type === 'text' && param.enabled
? interpolate(param.value, variables)
: param.value
}));
break;
default:
break;
}
return interpolatedBody;
};
export const createVariablesObject = ({
globalEnvironmentVariables = {},
collectionVars = {},
allVariables = {},
collection = {},
runtimeVariables = {},
processEnvVars = {}
}) => {
return {
...globalEnvironmentVariables,
...allVariables,
...collectionVars,
...runtimeVariables,
process: {
env: {
...processEnvVars
}
}
};
};

View File

@@ -0,0 +1,48 @@
import { interpolateHeaders, interpolateBody } from './interpolation';
describe('interpolation utils', () => {
describe('interpolateHeaders', () => {
it('should interpolate variables in header name and value while preserving other props', () => {
const headers = [
{ uid: '1', name: 'X-{{var}}', value: 'value-{{var}}', enabled: true }
];
const variables = { var: 'test' };
const result = interpolateHeaders(headers, variables);
expect(result).toEqual([
{
uid: '1',
name: 'X-test',
value: 'value-test',
enabled: true
}
]);
});
});
describe('interpolateBody', () => {
it('should interpolate JSON body strings and keep formatting', () => {
const body = {
mode: 'json',
json: '{"name": "{{username}}"}'
};
const variables = { username: 'bruno' };
const result = interpolateBody(body, variables);
expect(result.json).toBe('{\n "name": "bruno"\n}');
});
it('should interpolate text body', () => {
const body = {
mode: 'text',
text: 'Hello {{name}}'
};
const result = interpolateBody(body, { name: 'World' });
expect(result.text).toBe('Hello World');
});
it('should return null when body is null', () => {
expect(interpolateBody(null, { a: 1 })).toBeNull();
});
});
});

View File

@@ -0,0 +1,62 @@
import { buildHarRequest } from 'utils/codegenerator/har';
import { getAuthHeaders } from 'utils/codegenerator/auth';
import { getAllVariables } from 'utils/collections/index';
import { interpolateHeaders, interpolateBody, createVariablesObject } from './interpolation';
const generateSnippet = ({ language, item, collection, shouldInterpolate = false }) => {
try {
// Get HTTPSnippet dynamically so mocks can be applied in tests
const { HTTPSnippet } = require('httpsnippet');
const allVariables = getAllVariables(collection, item);
// Create variables object for interpolation
const variables = createVariablesObject({
globalEnvironmentVariables: collection.globalEnvironmentVariables || {},
collectionVars: collection.collectionVars || {},
allVariables,
collection,
runtimeVariables: collection.runtimeVariables || {},
processEnvVars: collection.processEnvVariables || {}
});
const request = item.request;
// Prepare headers
let headers = [...(request.headers || [])];
// Add auth headers if needed
if (request.auth && request.auth.mode !== 'none') {
const collectionAuth = collection?.root?.request?.auth || null;
const authHeaders = getAuthHeaders(collectionAuth, request.auth);
headers = [...headers, ...authHeaders];
}
// Interpolate headers and body if needed
if (shouldInterpolate) {
headers = interpolateHeaders(headers, variables);
if (request.body) {
request.body = interpolateBody(request.body, variables);
}
}
// Build HAR request
const harRequest = buildHarRequest({
request,
headers
});
// Generate snippet using HTTPSnippet
const snippet = new HTTPSnippet(harRequest);
const result = snippet.convert(language.target, language.client);
return result;
} catch (error) {
console.error('Error generating code snippet:', error);
return 'Error generating code snippet';
}
};
export {
generateSnippet
};

View File

@@ -0,0 +1,421 @@
jest.mock('httpsnippet', () => {
return {
HTTPSnippet: jest.fn().mockImplementation((harRequest) => ({
convert: jest.fn(() => {
const method = harRequest?.method || 'GET';
const url = harRequest?.url || 'http://example.com';
const hasBody = harRequest?.postData?.text;
if (method === 'POST' && hasBody) {
return `curl -X POST ${url} -H "Content-Type: application/json" -d '${hasBody}'`;
}
return `curl -X ${method} ${url}`;
})
}))
};
});
jest.mock('utils/codegenerator/har', () => ({
buildHarRequest: jest.fn((data) => {
const request = data.request || {};
const method = request.method || 'GET';
const url = request.url || 'http://example.com';
const body = request.body || {};
const harRequest = {
method: method,
url: url,
headers: data.headers || [],
httpVersion: 'HTTP/1.1'
};
// Add body data for POST requests
if (method === 'POST' && body.mode === 'json' && body.json) {
harRequest.postData = {
mimeType: 'application/json',
text: body.json
};
}
return harRequest;
})
}));
jest.mock('utils/codegenerator/auth', () => ({
getAuthHeaders: jest.fn(() => [])
}));
jest.mock('utils/collections/index', () => ({
getAllVariables: jest.fn(() => ({
baseUrl: 'https://api.example.com',
apiKey: 'secret-key-123',
userId: '12345'
}))
}));
import { generateSnippet } from './snippet-generator';
describe('Snippet Generator - Simple Tests', () => {
// Simple test request - easy to understand
const testRequest = {
uid: 'test-request-123',
name: 'test api call',
type: 'http-request',
request: {
method: 'POST',
url: 'https://api.example.com/{{endpoint}}',
headers: [
{ uid: 'h1', name: 'Authorization', value: 'Bearer {{apiToken}}', enabled: true },
{ uid: 'h2', name: 'Content-Type', value: 'application/json', enabled: true },
{ uid: 'h3', name: 'X-Custom', value: '{{customValue}}', enabled: true }
],
body: {
mode: 'json',
json: '{"message": "{{greeting}}", "count": {{number}}}'
},
auth: { mode: 'none' },
assertions: [],
tests: '',
docs: '',
params: [],
vars: { req: [] }
}
};
const testCollection = {
root: {
request: {
auth: { mode: 'none' },
headers: []
}
},
globalEnvironmentVariables: {
endpoint: 'data',
apiToken: 'token123',
customValue: 'test-value',
greeting: 'Hello World',
number: 42
},
runtimeVariables: {},
processEnvVariables: {}
};
const curlLanguage = { target: 'shell', client: 'curl' };
beforeEach(() => {
jest.clearAllMocks();
require('httpsnippet').HTTPSnippet = jest.fn().mockImplementation((harRequest) => ({
convert: jest.fn(() => {
const method = harRequest?.method || 'GET';
const url = harRequest?.url || 'http://example.com';
const hasBody = harRequest?.postData?.text;
if (method === 'POST' && hasBody) {
return `curl -X POST ${url} -H "Content-Type: application/json" -d '${hasBody}'`;
}
return `curl -X ${method} ${url}`;
})
}));
});
it('should generate curl for POST request with JSON body', () => {
const result = generateSnippet({
language: curlLanguage,
item: testRequest,
collection: testCollection,
shouldInterpolate: false
});
expect(result).toBe('curl -X POST https://api.example.com/{{endpoint}} -H "Content-Type: application/json" -d \'{"message": "{{greeting}}", "count": {{number}}}\'');
});
it('should interpolate variables when enabled', () => {
const result = generateSnippet({
language: curlLanguage,
item: testRequest,
collection: testCollection,
shouldInterpolate: true
});
const expectedBody = `{
"message": "Hello World",
"count": 42
}`;
expect(result).toBe(`curl -X POST https://api.example.com/{{endpoint}} -H "Content-Type: application/json" -d '${expectedBody}'`);
});
it('should handle GET requests', () => {
const getRequest = {
...testRequest,
request: {
...testRequest.request,
method: 'GET',
body: { mode: 'none' }
}
};
const result = generateSnippet({
language: curlLanguage,
item: getRequest,
collection: testCollection,
shouldInterpolate: false
});
expect(result).toBe('curl -X GET https://api.example.com/{{endpoint}}');
});
it('should handle requests with different headers', () => {
const requestWithDifferentHeaders = {
...testRequest,
request: {
...testRequest.request,
headers: [
{ uid: 'h1', name: 'X-API-Key', value: '{{apiKey}}', enabled: true },
{ uid: 'h2', name: 'Accept', value: 'application/json', enabled: true },
{ uid: 'h3', name: 'User-Agent', value: 'TestApp/{{version}}', enabled: true }
]
}
};
const collectionWithDifferentVars = {
...testCollection,
globalEnvironmentVariables: {
...testCollection.globalEnvironmentVariables,
apiKey: 'secret-key-456',
version: '1.0.0'
}
};
const result = generateSnippet({
language: curlLanguage,
item: requestWithDifferentHeaders,
collection: collectionWithDifferentVars,
shouldInterpolate: true
});
// Body should have interpolated variables with proper formatting
const expectedBody = `{
"message": "Hello World",
"count": 42
}`;
expect(result).toBe(`curl -X POST https://api.example.com/{{endpoint}} -H "Content-Type: application/json" -d '${expectedBody}'`);
});
it('should handle complex nested JSON body', () => {
const complexBody = {
user: {
name: '{{userName}}',
settings: {
theme: '{{userTheme}}',
active: true
}
},
data: {
items: ['{{item1}}', '{{item2}}'],
total: '{{totalCount}}'
}
};
const requestWithComplexBody = {
...testRequest,
request: {
...testRequest.request,
body: {
mode: 'json',
json: JSON.stringify(complexBody, null, 2)
}
}
};
const collectionWithComplexVars = {
...testCollection,
globalEnvironmentVariables: {
...testCollection.globalEnvironmentVariables,
userName: 'Alice',
userTheme: 'dark',
item1: 'first',
item2: 'second',
totalCount: 100
}
};
const result = generateSnippet({
language: curlLanguage,
item: requestWithComplexBody,
collection: collectionWithComplexVars,
shouldInterpolate: true
});
const expectedComplexBody = JSON.stringify({
user: {
name: 'Alice',
settings: {
theme: 'dark',
active: true
}
},
data: {
items: ['first', 'second'],
total: '100'
}
}, null, 2);
expect(result).toBe(`curl -X POST https://api.example.com/{{endpoint}} -H "Content-Type: application/json" -d '${expectedComplexBody}'`);
});
it('should handle errors gracefully', () => {
// Set up the error mock after beforeEach has run
const originalHTTPSnippet = require('httpsnippet').HTTPSnippet;
require('httpsnippet').HTTPSnippet = jest.fn(() => {
throw new Error('Mock error!');
});
const originalConsoleError = console.error;
console.error = jest.fn();
const result = generateSnippet({
language: curlLanguage,
item: testRequest,
collection: testCollection,
shouldInterpolate: false
});
expect(result).toBe('Error generating code snippet');
require('httpsnippet').HTTPSnippet = originalHTTPSnippet;
console.error = originalConsoleError;
});
it('should work with JavaScript language', () => {
const javascriptLanguage = { target: 'javascript', client: 'fetch' };
const expectedJavaScriptCode = `fetch("https://api.example.com/data", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ "message": "Hello World", "count": 42 })
})`;
const originalHTTPSnippet = require('httpsnippet').HTTPSnippet;
require('httpsnippet').HTTPSnippet = jest.fn().mockImplementation(() => ({
convert: jest.fn(() => expectedJavaScriptCode)
}));
const result = generateSnippet({
language: javascriptLanguage,
item: testRequest,
collection: testCollection,
shouldInterpolate: false
});
expect(result).toBe(expectedJavaScriptCode);
// Restore the original mock
require('httpsnippet').HTTPSnippet = originalHTTPSnippet;
});
it('should interpolate simple headers and body variables', () => {
const simpleTestRequest = {
uid: 'test-123',
name: 'simple test',
type: 'http-request',
request: {
method: 'POST',
url: 'https://api.test.com/{{endpoint}}',
headers: [
{ uid: 'h1', name: 'Authorization', value: 'Bearer {{token}}', enabled: true },
{ uid: 'h2', name: 'X-User-ID', value: '{{userId}}', enabled: true },
{ uid: 'h3', name: 'Content-Type', value: 'application/json', enabled: true }
],
body: {
mode: 'json',
json: '{"name": "{{userName}}", "email": "{{userEmail}}", "age": {{userAge}}}'
}
}
};
// Simple collection with clear variable values
const simpleTestCollection = {
root: {
request: {
auth: { mode: 'none' },
headers: []
}
},
globalEnvironmentVariables: {
endpoint: 'users',
token: 'abc123token',
userId: 'user456',
userName: 'John Smith',
userEmail: 'john@test.com',
userAge: 30
},
runtimeVariables: {},
processEnvVariables: {}
};
const result = generateSnippet({
language: curlLanguage,
item: simpleTestRequest,
collection: simpleTestCollection,
shouldInterpolate: true
});
const expectedInterpolatedBody = `{
"name": "John Smith",
"email": "john@test.com",
"age": 30
}`;
expect(result).toBe(`curl -X POST https://api.test.com/{{endpoint}} -H "Content-Type: application/json" -d '${expectedInterpolatedBody}'`);
});
it('should NOT interpolate when shouldInterpolate is false', () => {
const simpleTestRequest = {
uid: 'test-123',
name: 'simple test',
type: 'http-request',
request: {
method: 'POST',
url: 'https://api.test.com/{{endpoint}}',
headers: [
{ uid: 'h1', name: 'Authorization', value: 'Bearer {{token}}', enabled: true },
{ uid: 'h2', name: 'X-User-ID', value: '{{userId}}', enabled: true },
{ uid: 'h3', name: 'Content-Type', value: 'application/json', enabled: true }
],
body: {
mode: 'json',
json: '{"name": "{{userName}}", "email": "{{userEmail}}", "age": {{userAge}}}'
}
}
};
const simpleTestCollection = {
root: {
request: {
auth: { mode: 'none' },
headers: []
}
},
globalEnvironmentVariables: {
endpoint: 'users',
token: 'abc123token',
userId: 'user456',
userName: 'John Smith',
userEmail: 'john@test.com',
userAge: 30
},
runtimeVariables: {},
processEnvVariables: {}
};
const result = generateSnippet({
language: curlLanguage,
item: simpleTestRequest,
collection: simpleTestCollection,
shouldInterpolate: false
});
expect(result).toBe('curl -X POST https://api.test.com/{{endpoint}} -H "Content-Type: application/json" -d \'{"name": "{{userName}}", "email": "{{userEmail}}", "age": {{userAge}}}\'');
});
});

View File

@@ -4,6 +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 Help from 'components/Help';
const ImportCollectionLocation = ({ onClose, handleSubmit, collectionName }) => {
const inputRef = useRef();
@@ -54,8 +56,16 @@ const ImportCollectionLocation = ({ onClose, handleSubmit, collectionName }) =>
</label>
<div className="mt-2">{collectionName}</div>
<>
<label htmlFor="collectionLocation" className="block font-semibold mt-3">
<label htmlFor="collectionLocation" className="block font-semibold mt-3 flex items-center">
Location
<Help>
<p>
Bruno stores your collections on your computer's filesystem.
</p>
<p className="mt-2">
Choose the location where you want to store this collection.
</p>
</Help>
</label>
<input
id="collection-location"

View File

@@ -26,6 +26,11 @@ const StyledWrapper = styled.div`
.CodeMirror-lines {
padding: 0;
.CodeMirror-placeholder {
color: ${(props) => props.theme.codemirror.placeholder.color} !important;
opacity: ${(props) => props.theme.codemirror.placeholder.opacity} !important
}
}
.CodeMirror-cursor {

View File

@@ -2,15 +2,11 @@ import React, { Component } from 'react';
import isEqual from 'lodash/isEqual';
import { getAllVariables } from 'utils/collections';
import { defineCodeMirrorBrunoVariablesMode, MaskedEditor } from 'utils/common/codemirror';
import { setupAutoComplete } from 'utils/codemirror/autocomplete';
import StyledWrapper from './StyledWrapper';
import { IconEye, IconEyeOff } from '@tabler/icons';
let CodeMirror;
const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
if (!SERVER_RENDERED) {
CodeMirror = require('codemirror');
}
const CodeMirror = require('codemirror');
class SingleLineEditor extends Component {
constructor(props) {
@@ -26,6 +22,7 @@ class SingleLineEditor extends Component {
maskInput: props.isSecret || false // Always mask the input by default (if it's a secret)
};
}
componentDidMount() {
// Initialize CodeMirror as a single line editor
/** @type {import("codemirror").Editor} */
@@ -44,6 +41,7 @@ class SingleLineEditor extends Component {
const noopHandler = () => {};
this.editor = CodeMirror(this.editorRef.current, {
placeholder: this.props.placeholder ?? '',
lineWrapping: false,
lineNumbers: false,
theme: this.props.theme === 'dark' ? 'monokai' : 'default',
@@ -75,14 +73,21 @@ class SingleLineEditor extends Component {
'Shift-Tab': false
}
});
if (this.props.autocomplete) {
this.editor.on('keyup', (cm, event) => {
if (!cm.state.completionActive /*Enables keyboard navigation in autocomplete list*/ && event.key !== 'Enter') {
/*Enter - do not open autocomplete list just after item has been selected in it*/
CodeMirror.commands.autocomplete(cm, CodeMirror.hint.anyword, { autocomplete: this.props.autocomplete });
}
});
}
// Setup AutoComplete Helper
const autoCompleteOptions = {
showHintsFor: ['variables'],
anywordAutocompleteHints: this.props.autocomplete
};
const getVariables = () => getAllVariables(this.props.collection, this.props.item);
this.brunoAutoCompleteCleanup = setupAutoComplete(
this.editor,
getVariables,
autoCompleteOptions
);
this.editor.setValue(String(this.props.value ?? ''));
this.editor.on('change', this._onEdit);
this.addOverlay(variables);
@@ -94,7 +99,6 @@ class SingleLineEditor extends Component {
_enableMaskedEditor = (enabled) => {
if (typeof enabled !== 'boolean') return;
console.log('Enabling masked editor: ' + enabled);
if (enabled == true) {
if (!this.maskedEditor) this.maskedEditor = new MaskedEditor(this.editor, '*');
this.maskedEditor.enable();
@@ -141,7 +145,14 @@ class SingleLineEditor extends Component {
}
componentWillUnmount() {
this.editor.getWrapperElement().remove();
if (this.editor) {
this.editor.off('change', this._onEdit);
this.editor.getWrapperElement().remove();
this.editor = null;
}
if (this.brunoAutoCompleteCleanup) {
this.brunoAutoCompleteCleanup();
}
}
addOverlay = (variables) => {

View File

@@ -6,12 +6,10 @@ const StyledWrapper = styled.div`
display: grid;
overflow-y: hidden;
overflow-x: auto;
padding: 0 1px;
// for icon hover
position: inherit;
left: -4px;
padding-left: 4px;
padding-right: 4px;
grid-template-columns: ${({ columns }) =>
columns?.[0]?.width

View File

@@ -86,7 +86,7 @@ const Table = ({ minColumnWidth = 1, headers = [], children }) => {
return (
<StyledWrapper columns={columns}>
<div className="relative">
<table ref={tableRef} className="px-4 inherit left-[4px]">
<table ref={tableRef} className="inherit">
<thead>
<tr>
{columns.map(({ ref, name }, i) => (

View File

@@ -96,7 +96,6 @@ const VariablesEditor = ({ collection }) => {
<div className="mt-8 muted text-xs">
Note: As of today, runtime variables can only be set via the API - <span className="font-medium">getVar()</span>{' '}
and <span className="font-medium">setVar()</span>. <br />
In the next release, we will add a UI to set and modify runtime variables.
</div>
</StyledWrapper>
);

View File

@@ -10,40 +10,37 @@ import 'codemirror/theme/material.css';
import 'codemirror/theme/monokai.css';
import 'codemirror/addon/scroll/simplescrollbars.css';
const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
if (!SERVER_RENDERED) {
require('codemirror/mode/javascript/javascript');
require('codemirror/mode/xml/xml');
require('codemirror/mode/sparql/sparql');
require('codemirror/addon/comment/comment');
require('codemirror/addon/dialog/dialog');
require('codemirror/addon/edit/closebrackets');
require('codemirror/addon/edit/matchbrackets');
require('codemirror/addon/fold/brace-fold');
require('codemirror/addon/fold/foldgutter');
require('codemirror/addon/fold/xml-fold');
require('codemirror/addon/hint/javascript-hint');
require('codemirror/addon/hint/show-hint');
require('codemirror/addon/lint/lint');
require('codemirror/addon/lint/json-lint');
require('codemirror/addon/mode/overlay');
require('codemirror/addon/scroll/simplescrollbars');
require('codemirror/addon/search/jump-to-line');
require('codemirror/addon/search/search');
require('codemirror/addon/search/searchcursor');
require('codemirror/addon/display/placeholder');
require('codemirror/keymap/sublime');
require('codemirror/mode/javascript/javascript');
require('codemirror/mode/xml/xml');
require('codemirror/mode/sparql/sparql');
require('codemirror/addon/comment/comment');
require('codemirror/addon/dialog/dialog');
require('codemirror/addon/edit/closebrackets');
require('codemirror/addon/edit/matchbrackets');
require('codemirror/addon/fold/brace-fold');
require('codemirror/addon/fold/foldgutter');
require('codemirror/addon/fold/xml-fold');
require('codemirror/addon/hint/javascript-hint');
require('codemirror/addon/hint/show-hint');
require('codemirror/addon/lint/lint');
require('codemirror/addon/lint/json-lint');
require('codemirror/addon/mode/overlay');
require('codemirror/addon/scroll/simplescrollbars');
require('codemirror/addon/search/jump-to-line');
require('codemirror/addon/search/search');
require('codemirror/addon/search/searchcursor');
require('codemirror/addon/display/placeholder');
require('codemirror/keymap/sublime');
require('codemirror-graphql/hint');
require('codemirror-graphql/info');
require('codemirror-graphql/jump');
require('codemirror-graphql/lint');
require('codemirror-graphql/mode');
require('codemirror-graphql/hint');
require('codemirror-graphql/info');
require('codemirror-graphql/jump');
require('codemirror-graphql/lint');
require('codemirror-graphql/mode');
require('utils/codemirror/brunoVarInfo');
require('utils/codemirror/javascript-lint');
require('utils/codemirror/autocomplete');
}
require('utils/codemirror/brunoVarInfo');
require('utils/codemirror/javascript-lint');
require('utils/codemirror/autocomplete');
export default function Main() {
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);

View File

@@ -1,6 +1,5 @@
import { createSlice } from '@reduxjs/toolkit';
import filter from 'lodash/filter';
import toast from 'react-hot-toast';
const initialState = {
isDragging: false,
@@ -26,6 +25,11 @@ const initialState = {
codeFont: 'default'
}
},
generateCode: {
mainLanguage: 'Shell',
library: 'curl',
shouldInterpolate: true
},
cookies: [],
taskQueue: [],
systemProxyEnvVariables: {}
@@ -76,6 +80,12 @@ export const appSlice = createSlice({
},
updateSystemProxyEnvVariables: (state, action) => {
state.systemProxyEnvVariables = action.payload;
},
updateGenerateCode: (state, action) => {
state.generateCode = {
...state.generateCode,
...action.payload
};
}
}
});
@@ -94,7 +104,8 @@ export const {
insertTaskIntoQueue,
removeTaskFromQueue,
removeAllTasksFromQueue,
updateSystemProxyEnvVariables
updateSystemProxyEnvVariables,
updateGenerateCode
} = appSlice.actions;
export const savePreferences = (preferences) => (dispatch, getState) => {
@@ -103,14 +114,9 @@ export const savePreferences = (preferences) => (dispatch, getState) => {
ipcRenderer
.invoke('renderer:save-preferences', preferences)
.then(() => toast.success('Preferences saved successfully'))
.then(() => dispatch(updatePreferences(preferences)))
.then(resolve)
.catch((err) => {
toast.error('An error occurred while saving preferences');
console.error(err);
reject(err);
});
.catch(reject);
});
};

View File

@@ -579,7 +579,48 @@ export const collectionsSlice = createSlice({
}
}
},
setQueryParams: (state, action) => {
const { collectionUid, itemUid, params } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
if (!collection) {
return;
}
const item = findItemInCollection(collection, itemUid);
if (!item || !isItemARequest(item)) {
return;
}
if (!item.draft) {
item.draft = cloneDeep(item);
}
const existingOtherParams = item.draft.request.params?.filter(p => p.type !== 'query') || [];
const newQueryParams = map(params, ({ name = '', value = '', enabled = true }) => ({
uid: uuid(),
name,
value,
description: '',
type: 'query',
enabled
}));
item.draft.request.params = [...newQueryParams, ...existingOtherParams];
// Update the request URL to reflect the new query params
const parts = splitOnFirst(item.draft.request.url, '?');
const query = stringifyQueryParams(
filter(item.draft.request.params, (p) => p.enabled && p.type === 'query')
);
// If there are enabled query params, append them to the URL
if (query && query.length) {
item.draft.request.url = parts[0] + '?' + query;
} else {
// If no enabled query params, remove the query part from URL
item.draft.request.url = parts[0];
}
},
moveQueryParam: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
@@ -785,6 +826,30 @@ export const collectionsSlice = createSlice({
}
}
},
setRequestHeaders: (state, action) => {
const { collectionUid, itemUid, headers } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
if (!collection) {
return;
}
const item = findItemInCollection(collection, itemUid);
if (!item || !isItemARequest(item)) {
return;
}
if (!item.draft) {
item.draft = cloneDeep(item);
}
item.draft.request.headers = map(action.payload.headers, ({name = '', value = '', enabled = true}) => ({
uid: uuid(),
name: name,
value: value,
description: '',
enabled: enabled
}));
},
addFormUrlEncodedParam: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
@@ -1987,6 +2052,10 @@ export const collectionsSlice = createSlice({
item.postResponseScriptErrorMessage = action.payload.errorMessage;
}
if(type === 'test-script-execution') {
item.testScriptErrorMessage = action.payload.errorMessage;
}
if (type === 'request-queued') {
const { cancelTokenUid } = action.payload;
// ignore if request is already in progress or completed
@@ -2269,6 +2338,7 @@ export const {
requestUrlChanged,
updateAuth,
addQueryParam,
setQueryParams,
moveQueryParam,
updateQueryParam,
deleteQueryParam,
@@ -2277,6 +2347,7 @@ export const {
updateRequestHeader,
deleteRequestHeader,
moveRequestHeader,
setRequestHeaders,
addFormUrlEncodedParam,
updateFormUrlEncodedParam,
deleteFormUrlEncodedParam,

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