mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-03 01:18:32 +00:00
Compare commits
216 Commits
feat/node_
...
feat/close
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9800f1db42 | ||
|
|
ab7ead91d5 | ||
|
|
a186df3ac4 | ||
|
|
3fe5299d8e | ||
|
|
57c08350b6 | ||
|
|
68b2625259 | ||
|
|
1719cee440 | ||
|
|
ab4e9eb3bd | ||
|
|
24a36bc355 | ||
|
|
1ac128e35c | ||
|
|
4510cc3414 | ||
|
|
f3cb0d4bae | ||
|
|
7b183887ce | ||
|
|
fa28ab9b50 | ||
|
|
60b437ef9d | ||
|
|
1ef8852a01 | ||
|
|
17cc70f36e | ||
|
|
2deee11718 | ||
|
|
bdc8f391b7 | ||
|
|
d8adb59d04 | ||
|
|
de05fb6137 | ||
|
|
3e3884a6af | ||
|
|
23843bb621 | ||
|
|
c85a1ec1a5 | ||
|
|
68cbb7d9df | ||
|
|
396ff2b196 | ||
|
|
6826e98945 | ||
|
|
e47d1ed353 | ||
|
|
08c182a875 | ||
|
|
f3afb4bf84 | ||
|
|
21e8615247 | ||
|
|
6e8751a27a | ||
|
|
c9a96ee94f | ||
|
|
b69db7b44b | ||
|
|
73caaef42b | ||
|
|
e68b2ae3b7 | ||
|
|
cc7f1ea58f | ||
|
|
6e8cd55b76 | ||
|
|
384aabf2af | ||
|
|
a15dcdb133 | ||
|
|
18848cdb26 | ||
|
|
29b90a7e0d | ||
|
|
4fbe371eb0 | ||
|
|
6fd2b8be6d | ||
|
|
be7f92d77f | ||
|
|
c5325c732f | ||
|
|
a538b27f24 | ||
|
|
77bb8f40fe | ||
|
|
8f1f5e3861 | ||
|
|
e9251a1f3f | ||
|
|
3a011b2a18 | ||
|
|
fa1498e2a8 | ||
|
|
77681ca51e | ||
|
|
045141efaf | ||
|
|
c997b91698 | ||
|
|
986d5b0b2a | ||
|
|
a2a521477a | ||
|
|
8e70adcbf9 | ||
|
|
87296776fa | ||
|
|
9df70cd759 | ||
|
|
8f9fb3b3c9 | ||
|
|
6d018f5648 | ||
|
|
789d0b23c0 | ||
|
|
81e1e403e4 | ||
|
|
ad2add4026 | ||
|
|
02554c3ad9 | ||
|
|
62815e3429 | ||
|
|
9859b69559 | ||
|
|
440c688bbb | ||
|
|
416eb754b7 | ||
|
|
b85d6efa60 | ||
|
|
19dea18629 | ||
|
|
636901c23d | ||
|
|
a4b1941817 | ||
|
|
7d8fde9180 | ||
|
|
4197304bf9 | ||
|
|
b75422a010 | ||
|
|
e9f03c46c7 | ||
|
|
73e828621f | ||
|
|
2becf49542 | ||
|
|
4c3a9928bc | ||
|
|
b694a41c96 | ||
|
|
ff9a4d97e3 | ||
|
|
6ab6e5ed57 | ||
|
|
3837a7612c | ||
|
|
6589dc51cd | ||
|
|
509f4da667 | ||
|
|
9d2b070ed9 | ||
|
|
d0c524cd9a | ||
|
|
74f0f67795 | ||
|
|
45664bdb65 | ||
|
|
98cb2df3fe | ||
|
|
d478102b30 | ||
|
|
924bc2e79e | ||
|
|
c2d40fe99f | ||
|
|
944674d208 | ||
|
|
0c30357b01 | ||
|
|
ce40949564 | ||
|
|
c6ce40c245 | ||
|
|
6890bbee70 | ||
|
|
4993c61e29 | ||
|
|
a66e849cfb | ||
|
|
9f47200e7b | ||
|
|
10739c32c4 | ||
|
|
c1853e613b | ||
|
|
c393dfe5d6 | ||
|
|
cf17539a47 | ||
|
|
608a9d1954 | ||
|
|
3a04d43ffe | ||
|
|
5c9a391cc6 | ||
|
|
df4b7c1337 | ||
|
|
db6a639c15 | ||
|
|
85319769a5 | ||
|
|
8d2f087206 | ||
|
|
1cc3a6432a | ||
|
|
28907a203f | ||
|
|
6204e90e9c | ||
|
|
1d0ba135ff | ||
|
|
3c72975314 | ||
|
|
3fa9fea6a4 | ||
|
|
239f1dc9f5 | ||
|
|
28e37d8f6f | ||
|
|
8b28070695 | ||
|
|
4ae55b8f1a | ||
|
|
8bad0262c6 | ||
|
|
c7029d1cda | ||
|
|
bb44d9e193 | ||
|
|
14966f6e6c | ||
|
|
56f0741121 | ||
|
|
b1840d189d | ||
|
|
aacb1e0b8e | ||
|
|
ec8dee5935 | ||
|
|
fa0f3b3b7b | ||
|
|
25386a229b | ||
|
|
2a00add966 | ||
|
|
41e0615f77 | ||
|
|
191a997b05 | ||
|
|
123fe7d542 | ||
|
|
187f5ca011 | ||
|
|
e1b4043ca5 | ||
|
|
9c9cfdf0b2 | ||
|
|
daf6a6d5d6 | ||
|
|
95a2ca9558 | ||
|
|
f359303927 | ||
|
|
65f52961c5 | ||
|
|
2a3db96c9b | ||
|
|
a1a7c9a136 | ||
|
|
c15d47c0dc | ||
|
|
e4f8945e89 | ||
|
|
e6c136d2bb | ||
|
|
6f8c543ee3 | ||
|
|
40b44de294 | ||
|
|
f24e1e78fe | ||
|
|
87d8c5ccb7 | ||
|
|
17d5629627 | ||
|
|
4321846dbd | ||
|
|
f3d4ac84d8 | ||
|
|
de52ceea48 | ||
|
|
65e69e77b3 | ||
|
|
fb2ca8937e | ||
|
|
e2da072e8b | ||
|
|
90492d6e79 | ||
|
|
5393e3b496 | ||
|
|
9fc885839f | ||
|
|
dbfbde43cf | ||
|
|
1aa4e27ab5 | ||
|
|
2b6da56c3c | ||
|
|
c08827b0c0 | ||
|
|
841d977725 | ||
|
|
56629663dc | ||
|
|
27cbb194bf | ||
|
|
cfec4a9e1b | ||
|
|
a7f6d669af | ||
|
|
e57162b79a | ||
|
|
03abbc585f | ||
|
|
be730a8c4f | ||
|
|
194d904284 | ||
|
|
86b3c65dcd | ||
|
|
c9fe9813db | ||
|
|
70d65d87c5 | ||
|
|
0bce203851 | ||
|
|
5b716cbe60 | ||
|
|
a6b0b6c117 | ||
|
|
3c656270b3 | ||
|
|
1bc7a1f655 | ||
|
|
5a10322608 | ||
|
|
2864ddaa72 | ||
|
|
c2f3d8e7da | ||
|
|
1fd61f0601 | ||
|
|
033c5cc0f7 | ||
|
|
db35e7059c | ||
|
|
cd80332de9 | ||
|
|
1902329226 | ||
|
|
b25569d29a | ||
|
|
de4674dcc4 | ||
|
|
457a2f83e7 | ||
|
|
ae3d5a5515 | ||
|
|
3b74e0da86 | ||
|
|
985b5ed20c | ||
|
|
188a2e63e3 | ||
|
|
01839c8e5f | ||
|
|
648581ded5 | ||
|
|
bf38cc0f51 | ||
|
|
abddc98767 | ||
|
|
1de9203dd5 | ||
|
|
5dd90e1386 | ||
|
|
da2f2519ec | ||
|
|
7efaa427ca | ||
|
|
cffa37ed50 | ||
|
|
bcf61f507a | ||
|
|
325b573da9 | ||
|
|
4b5c7dcca6 | ||
|
|
d2888daa88 | ||
|
|
ec9d63219f | ||
|
|
9173ffbdee | ||
|
|
5f112a318d |
26
.github/actions/common/setup-node-deps/action.yml
vendored
Normal file
26
.github/actions/common/setup-node-deps/action.yml
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
name: 'Setup Node Dependencies'
|
||||
description: 'Install Node.js and npm dependencies'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: v22.17.0
|
||||
cache: 'npm'
|
||||
cache-dependency-path: './package-lock.json'
|
||||
|
||||
- name: Install node dependencies
|
||||
shell: bash
|
||||
run: npm ci --legacy-peer-deps
|
||||
|
||||
- name: Build libraries
|
||||
shell: bash
|
||||
run: |
|
||||
npm run build:graphql-docs
|
||||
npm run build:bruno-query
|
||||
npm run build:bruno-common
|
||||
npm run sandbox:bundle-libraries --workspace=packages/bruno-js
|
||||
npm run build:bruno-converters
|
||||
npm run build:bruno-requests
|
||||
npm run build:bruno-filestore
|
||||
36
.github/actions/ssl/linux/run-basic-ssl-cli-tests/action.yml
vendored
Normal file
36
.github/actions/ssl/linux/run-basic-ssl-cli-tests/action.yml
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
name: 'Run Basic SSL CLI Tests - Linux'
|
||||
description: 'Run basic SSL CLI tests on Linux'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Run CLI tests
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# navigate to basic SSL test collection directory
|
||||
cd tests/ssl/basic-ssl/collections/badssl
|
||||
|
||||
echo "basic ssl success"
|
||||
# should pass
|
||||
node ../../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit1.xml --insecure --format junit
|
||||
xmllint --xpath 'count(//testsuite[@errors="0"])' junit1.xml | grep -q "^1$" || exit 1
|
||||
|
||||
echo "with default/system ca certs"
|
||||
# should pass
|
||||
node ../../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit2.xml --format junit
|
||||
xmllint --xpath 'count(//testsuite[@errors="0"])' junit2.xml | grep -q "^1$" || exit 1
|
||||
|
||||
# navigate to self-signed SSL test collection directory
|
||||
cd ../self-signed-badssl
|
||||
|
||||
echo "self-signed ssl with validation disabled"
|
||||
# should pass
|
||||
node ../../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit3.xml --insecure --format junit
|
||||
xmllint --xpath 'count(//testsuite[@errors="0"])' junit3.xml | grep -q "^1$" || exit 1
|
||||
|
||||
echo "self-signed ssl with default/system ca certs"
|
||||
echo "request will error"
|
||||
# should fail
|
||||
node ../../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit4.xml --format junit 2>/dev/null || true
|
||||
xmllint --xpath 'count(//testsuite[@errors="1"])' junit4.xml | grep -q "^1$" || exit 1
|
||||
33
.github/actions/ssl/linux/run-custom-ca-certs-cli-tests/action.yml
vendored
Normal file
33
.github/actions/ssl/linux/run-custom-ca-certs-cli-tests/action.yml
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
name: 'Run Custom CA Certs CLI Tests - Linux'
|
||||
description: 'Run custom CA certs CLI tests on Linux'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Run CLI tests
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# navigate to CA certificates test collection directory
|
||||
cd tests/ssl/custom-ca-certs/collection
|
||||
|
||||
echo "custom valid ca cert"
|
||||
# should pass
|
||||
node ../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit1.xml --cacert ../server/certs/ca-cert.pem --ignore-truststore --format junit
|
||||
xmllint --xpath 'count(//testsuite[@errors="0"])' junit1.xml | grep -q "^1$" || exit 1
|
||||
|
||||
echo "custom valid ca cert with defaults"
|
||||
# should pass
|
||||
node ../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit2.xml --cacert ../server/certs/ca-cert.pem --format junit
|
||||
xmllint --xpath 'count(//testsuite[@errors="0"])' junit2.xml | grep -q "^1$" || exit 1
|
||||
|
||||
echo "custom invalid ca cert"
|
||||
echo "request will error"
|
||||
# should fail
|
||||
node ../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit3.xml --cacert ../server/certs/ca-key.pem --ignore-truststore --format junit 2>/dev/null || true
|
||||
xmllint --xpath 'count(//testsuite[@errors="1"])' junit3.xml | grep -q "^1$" || exit 1
|
||||
|
||||
echo "custom invalid ca cert with defaults"
|
||||
# should pass
|
||||
node ../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit4.xml --cacert ../server/certs/ca-key.pem --format junit
|
||||
xmllint --xpath 'count(//testsuite[@errors="0"])' junit4.xml | grep -q "^1$" || exit 1
|
||||
19
.github/actions/ssl/linux/run-ssl-e2e-tests/action.yml
vendored
Normal file
19
.github/actions/ssl/linux/run-ssl-e2e-tests/action.yml
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
name: 'Run SSL E2E Tests - Linux'
|
||||
description: 'Run SSL E2E tests on Linux'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Run E2E tests
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
xvfb-run npm run test:e2e:ssl
|
||||
|
||||
- name: Upload Playwright Report
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report-linux
|
||||
path: playwright-report/
|
||||
retention-days: 30
|
||||
26
.github/actions/ssl/linux/setup-ca-certs/action.yml
vendored
Normal file
26
.github/actions/ssl/linux/setup-ca-certs/action.yml
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
name: 'Setup CA Certificates - Linux'
|
||||
description: 'Setup CA certificates and start test server for custom CA certs tests on Linux'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Setup CA certificates
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
cd tests/ssl/custom-ca-certs/server
|
||||
|
||||
echo "running certificate setup"
|
||||
node scripts/generate-certs.js
|
||||
|
||||
- name: Start test server
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
cd tests/ssl/custom-ca-certs/server
|
||||
|
||||
echo "starting server in background"
|
||||
node index.js &
|
||||
|
||||
echo "server started with PID: $!"
|
||||
15
.github/actions/ssl/linux/setup-feature-specific-deps/action.yml
vendored
Normal file
15
.github/actions/ssl/linux/setup-feature-specific-deps/action.yml
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
name: 'Setup Custom CA Certs Feature Dependencies - Linux'
|
||||
description: 'Setup feature-specific dependencies for custom CA certs tests on Linux'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Install additional OS dependencies for custom CA certs
|
||||
shell: bash
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get --no-install-recommends install -y \
|
||||
libglib2.0-0 libnss3 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libgtk-3-0 libasound2t64 \
|
||||
xvfb libxml2-utils
|
||||
|
||||
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
|
||||
36
.github/actions/ssl/macos/run-basic-ssl-cli-tests/action.yml
vendored
Normal file
36
.github/actions/ssl/macos/run-basic-ssl-cli-tests/action.yml
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
name: 'Run Basic SSL CLI Tests - macOS'
|
||||
description: 'Run basic SSL CLI tests on macOS'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Run CLI tests
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# navigate to basic SSL test collection directory
|
||||
cd tests/ssl/basic-ssl/collections/badssl
|
||||
|
||||
echo "basic ssl success"
|
||||
# should pass
|
||||
node ../../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit1.xml --insecure --format junit
|
||||
xmllint --xpath 'count(//testsuite[@errors="0"])' junit1.xml | grep -q "^1$" || exit 1
|
||||
|
||||
echo "with default/system ca certs"
|
||||
# should pass
|
||||
node ../../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit2.xml --format junit
|
||||
xmllint --xpath 'count(//testsuite[@errors="0"])' junit2.xml | grep -q "^1$" || exit 1
|
||||
|
||||
# navigate to self-signed SSL test collection directory
|
||||
cd ../self-signed-badssl
|
||||
|
||||
echo "self-signed ssl with validation disabled"
|
||||
# should pass
|
||||
node ../../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit3.xml --insecure --format junit
|
||||
xmllint --xpath 'count(//testsuite[@errors="0"])' junit3.xml | grep -q "^1$" || exit 1
|
||||
|
||||
echo "self-signed ssl with default/system ca certs"
|
||||
echo "request will error"
|
||||
# should fail
|
||||
node ../../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit4.xml --format junit 2>/dev/null || true
|
||||
xmllint --xpath 'count(//testsuite[@errors="1"])' junit4.xml | grep -q "^1$" || exit 1
|
||||
33
.github/actions/ssl/macos/run-custom-ca-certs-cli-tests/action.yml
vendored
Normal file
33
.github/actions/ssl/macos/run-custom-ca-certs-cli-tests/action.yml
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
name: 'Run Custom CA Certs CLI Tests - macOS'
|
||||
description: 'Run custom CA certs CLI tests on macOS'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Run CLI tests
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# navigate to CA certificates test collection directory
|
||||
cd tests/ssl/custom-ca-certs/collection
|
||||
|
||||
echo "custom valid ca cert"
|
||||
# should pass
|
||||
node ../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit1.xml --cacert ../server/certs/ca-cert.pem --ignore-truststore --format junit
|
||||
xmllint --xpath 'count(//testsuite[@errors="0"])' junit1.xml | grep -q "^1$" || exit 1
|
||||
|
||||
echo "custom valid ca cert with defaults"
|
||||
# should pass
|
||||
node ../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit2.xml --cacert ../server/certs/ca-cert.pem --format junit
|
||||
xmllint --xpath 'count(//testsuite[@errors="0"])' junit2.xml | grep -q "^1$" || exit 1
|
||||
|
||||
echo "custom invalid ca cert"
|
||||
echo "request will error"
|
||||
# should fail
|
||||
node ../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit3.xml --cacert ../server/certs/ca-key.pem --ignore-truststore --format junit 2>/dev/null || true
|
||||
xmllint --xpath 'count(//testsuite[@errors="1"])' junit3.xml | grep -q "^1$" || exit 1
|
||||
|
||||
echo "custom invalid ca cert with defaults"
|
||||
# should pass
|
||||
node ../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit4.xml --cacert ../server/certs/ca-key.pem --format junit
|
||||
xmllint --xpath 'count(//testsuite[@errors="0"])' junit4.xml | grep -q "^1$" || exit 1
|
||||
17
.github/actions/ssl/macos/run-ssl-e2e-tests/action.yml
vendored
Normal file
17
.github/actions/ssl/macos/run-ssl-e2e-tests/action.yml
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
name: 'Run SSL E2E Tests - macOS'
|
||||
description: 'Run SSL E2E tests on macOS'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Run E2E tests
|
||||
shell: bash
|
||||
run: |
|
||||
npm run test:e2e:ssl
|
||||
|
||||
- name: Upload Playwright Report
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report-macos
|
||||
path: playwright-report/
|
||||
retention-days: 30
|
||||
26
.github/actions/ssl/macos/setup-ca-certs/action.yml
vendored
Normal file
26
.github/actions/ssl/macos/setup-ca-certs/action.yml
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
name: 'Setup CA Certificates - macOS'
|
||||
description: 'Setup CA certificates and start test server for custom CA certs tests on macOS'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Setup CA certificates
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
cd tests/ssl/custom-ca-certs/server
|
||||
|
||||
echo "running certificate setup"
|
||||
node scripts/generate-certs.js
|
||||
|
||||
- name: Start test server
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
cd tests/ssl/custom-ca-certs/server
|
||||
|
||||
echo "starting server in background"
|
||||
node index.js &
|
||||
|
||||
echo "server started with PID: $!"
|
||||
9
.github/actions/ssl/macos/setup-feature-specific-deps/action.yml
vendored
Normal file
9
.github/actions/ssl/macos/setup-feature-specific-deps/action.yml
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
name: 'Setup Custom CA Certs Feature Dependencies - macOS'
|
||||
description: 'Setup feature-specific dependencies for custom CA certs tests on macOS'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Install additional OS dependencies for custom CA certs
|
||||
shell: bash
|
||||
run: |
|
||||
brew install libxml2
|
||||
50
.github/actions/ssl/windows/run-basic-ssl-cli-tests/action.yml
vendored
Normal file
50
.github/actions/ssl/windows/run-basic-ssl-cli-tests/action.yml
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
name: 'Run Basic SSL CLI Tests - Windows'
|
||||
description: 'Run basic SSL CLI tests on Windows'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Run CLI tests
|
||||
shell: pwsh
|
||||
run: |
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# navigate to basic SSL test collection directory
|
||||
Set-Location tests\ssl\basic-ssl\collections\badssl
|
||||
|
||||
Write-Host "basic ssl success"
|
||||
# should pass
|
||||
$process = Start-Process -FilePath "node" -ArgumentList "..\..\..\..\..\packages\bruno-cli\bin\bru.js run .\request.bru --output junit1.xml --insecure --format junit" -NoNewWindow -Wait -PassThru -RedirectStandardError "nul"
|
||||
[xml]$xml1 = Get-Content junit1.xml
|
||||
$testsuites1 = if ($xml1.testsuites) { $xml1.testsuites.testsuite } else { $xml1.testsuite }
|
||||
$errorCount1 = ($testsuites1 | Where-Object { $_.errors -eq "0" } | Measure-Object).Count
|
||||
if ($errorCount1 -ne 1) { exit 1 }
|
||||
|
||||
Write-Host "with default/system ca certs"
|
||||
# should pass
|
||||
$process = Start-Process -FilePath "node" -ArgumentList "..\..\..\..\..\packages\bruno-cli\bin\bru.js run .\request.bru --output junit2.xml --format junit" -NoNewWindow -Wait -PassThru -RedirectStandardError "nul"
|
||||
[xml]$xml2 = Get-Content junit2.xml
|
||||
$testsuites2 = if ($xml2.testsuites) { $xml2.testsuites.testsuite } else { $xml2.testsuite }
|
||||
$errorCount2 = ($testsuites2 | Where-Object { $_.errors -eq "0" } | Measure-Object).Count
|
||||
if ($errorCount2 -ne 1) { exit 1 }
|
||||
|
||||
# navigate to self-signed SSL test collection directory
|
||||
Set-Location ..\self-signed-badssl
|
||||
|
||||
Write-Host "self-signed ssl with validation disabled"
|
||||
# should pass
|
||||
$process = Start-Process -FilePath "node" -ArgumentList "..\..\..\..\..\packages\bruno-cli\bin\bru.js run .\request.bru --output junit3.xml --insecure --format junit" -NoNewWindow -Wait -PassThru -RedirectStandardError "nul"
|
||||
[xml]$xml3 = Get-Content junit3.xml
|
||||
$testsuites3 = if ($xml3.testsuites) { $xml3.testsuites.testsuite } else { $xml3.testsuite }
|
||||
$errorCount3 = ($testsuites3 | Where-Object { $_.errors -eq "0" } | Measure-Object).Count
|
||||
if ($errorCount3 -ne 1) { exit 1 }
|
||||
|
||||
Write-Host "self-signed ssl with default/system ca certs"
|
||||
Write-Host "request will error"
|
||||
# should fail
|
||||
$process = Start-Process -FilePath "node" -ArgumentList "..\..\..\..\..\packages\bruno-cli\bin\bru.js run .\request.bru --output junit4.xml --format junit" -NoNewWindow -Wait -PassThru -RedirectStandardError "nul"
|
||||
# Ignore the exit code - we expect this to fail
|
||||
[xml]$xml4 = Get-Content junit4.xml
|
||||
$testsuites4 = if ($xml4.testsuites) { $xml4.testsuites.testsuite } else { $xml4.testsuite }
|
||||
$errorCount4 = ($testsuites4 | Where-Object { $_.errors -eq "1" } | Measure-Object).Count
|
||||
if ($errorCount4 -ne 1) { exit 1 }
|
||||
47
.github/actions/ssl/windows/run-custom-ca-certs-cli-tests/action.yml
vendored
Normal file
47
.github/actions/ssl/windows/run-custom-ca-certs-cli-tests/action.yml
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
name: 'Run Custom CA Certs CLI Tests - Windows'
|
||||
description: 'Run custom CA certs CLI tests on Windows'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Run CLI tests
|
||||
shell: pwsh
|
||||
run: |
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# navigate to CA certificates test collection directory
|
||||
Set-Location tests\ssl\custom-ca-certs\collection
|
||||
|
||||
Write-Host "custom valid ca cert"
|
||||
# should pass
|
||||
$process = Start-Process -FilePath "node" -ArgumentList "..\..\..\..\packages\bruno-cli\bin\bru.js run .\request.bru --output junit1.xml --cacert ..\server\certs\ca-cert.pem --ignore-truststore --format junit" -NoNewWindow -Wait -PassThru -RedirectStandardError "nul"
|
||||
[xml]$xml1 = Get-Content junit1.xml
|
||||
$testsuites1 = if ($xml1.testsuites) { $xml1.testsuites.testsuite } else { $xml1.testsuite }
|
||||
$errorCount1 = ($testsuites1 | Where-Object { $_.errors -eq "0" } | Measure-Object).Count
|
||||
if ($errorCount1 -ne 1) { exit 1 }
|
||||
|
||||
Write-Host "custom valid ca cert with defaults"
|
||||
# should pass
|
||||
$process = Start-Process -FilePath "node" -ArgumentList "..\..\..\..\packages\bruno-cli\bin\bru.js run .\request.bru --output junit2.xml --cacert ..\server\certs\ca-cert.pem --format junit" -NoNewWindow -Wait -PassThru -RedirectStandardError "nul"
|
||||
[xml]$xml2 = Get-Content junit2.xml
|
||||
$testsuites2 = if ($xml2.testsuites) { $xml2.testsuites.testsuite } else { $xml2.testsuite }
|
||||
$errorCount2 = ($testsuites2 | Where-Object { $_.errors -eq "0" } | Measure-Object).Count
|
||||
if ($errorCount2 -ne 1) { exit 1 }
|
||||
|
||||
Write-Host "custom invalid ca cert"
|
||||
Write-Host "request will error"
|
||||
# should fail
|
||||
$process = Start-Process -FilePath "node" -ArgumentList "..\..\..\..\packages\bruno-cli\bin\bru.js run .\request.bru --output junit3.xml --cacert ..\server\certs\ca-key.pem --ignore-truststore --format junit" -NoNewWindow -Wait -PassThru -RedirectStandardError "nul"
|
||||
# Ignore the exit code - we expect this to fail
|
||||
[xml]$xml3 = Get-Content junit3.xml
|
||||
$testsuites3 = if ($xml3.testsuites) { $xml3.testsuites.testsuite } else { $xml3.testsuite }
|
||||
$errorCount3 = ($testsuites3 | Where-Object { $_.errors -eq "1" } | Measure-Object).Count
|
||||
if ($errorCount3 -ne 1) { exit 1 }
|
||||
|
||||
Write-Host "custom invalid ca cert with defaults"
|
||||
# should pass
|
||||
$process = Start-Process -FilePath "node" -ArgumentList "..\..\..\..\packages\bruno-cli\bin\bru.js run .\request.bru --output junit4.xml --cacert ..\server\certs\ca-key.pem --format junit" -NoNewWindow -Wait -PassThru -RedirectStandardError "nul"
|
||||
[xml]$xml4 = Get-Content junit4.xml
|
||||
$testsuites4 = if ($xml4.testsuites) { $xml4.testsuites.testsuite } else { $xml4.testsuite }
|
||||
$errorCount4 = ($testsuites4 | Where-Object { $_.errors -eq "0" } | Measure-Object).Count
|
||||
if ($errorCount4 -ne 1) { exit 1 }
|
||||
17
.github/actions/ssl/windows/run-ssl-e2e-tests/action.yml
vendored
Normal file
17
.github/actions/ssl/windows/run-ssl-e2e-tests/action.yml
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
name: 'Run SSL E2E Tests - Windows'
|
||||
description: 'Run SSL E2E tests on Windows'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Run E2E tests
|
||||
shell: pwsh
|
||||
run: |
|
||||
npm run test:e2e:ssl
|
||||
|
||||
- name: Upload Playwright Report
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report-windows
|
||||
path: playwright-report/
|
||||
retention-days: 30
|
||||
25
.github/actions/ssl/windows/setup-ca-certs/action.yml
vendored
Normal file
25
.github/actions/ssl/windows/setup-ca-certs/action.yml
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
name: 'Setup CA Certificates - Windows'
|
||||
description: 'Setup CA certificates and start test server for custom CA certs tests on Windows'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Setup CA certificates
|
||||
shell: pwsh
|
||||
run: |
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
Set-Location tests\ssl\custom-ca-certs\server
|
||||
|
||||
Write-Host "running certificate setup"
|
||||
node scripts/generate-certs.js
|
||||
|
||||
- name: Start test server
|
||||
shell: pwsh
|
||||
run: |
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
Set-Location tests\ssl\custom-ca-certs\server
|
||||
|
||||
Write-Host "starting server in background"
|
||||
Start-Process -FilePath "node" -ArgumentList "index.js" -PassThru -WindowStyle Hidden
|
||||
4
.github/workflows/npm-bru-cli.yml
vendored
4
.github/workflows/npm-bru-cli.yml
vendored
@@ -25,8 +25,8 @@ jobs:
|
||||
os: [ubuntu-latest, macos-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
|
||||
|
||||
91
.github/workflows/ssl-tests.yml
vendored
Normal file
91
.github/workflows/ssl-tests.yml
vendored
Normal file
@@ -0,0 +1,91 @@
|
||||
name: SSL Tests
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
tests-for-linux:
|
||||
name: SSL Tests - Linux
|
||||
timeout-minutes: 60
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
checks: write
|
||||
pull-requests: write
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node Dependencies
|
||||
uses: ./.github/actions/common/setup-node-deps
|
||||
|
||||
- name: Setup Feature Dependencies
|
||||
uses: ./.github/actions/ssl/linux/setup-feature-specific-deps
|
||||
|
||||
- name: Setup CA Certificates
|
||||
uses: ./.github/actions/ssl/linux/setup-ca-certs
|
||||
|
||||
- name: Run Basic SSL CLI Tests
|
||||
uses: ./.github/actions/ssl/linux/run-basic-ssl-cli-tests
|
||||
|
||||
- name: Run Custom CA Certs CLI Tests
|
||||
uses: ./.github/actions/ssl/linux/run-custom-ca-certs-cli-tests
|
||||
|
||||
- name: Run Custom CA Certs E2E Tests
|
||||
uses: ./.github/actions/ssl/linux/run-ssl-e2e-tests
|
||||
|
||||
tests-for-macos:
|
||||
name: SSL Tests - macOS
|
||||
timeout-minutes: 60
|
||||
runs-on: macos-latest
|
||||
permissions:
|
||||
checks: write
|
||||
pull-requests: write
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node Dependencies
|
||||
uses: ./.github/actions/common/setup-node-deps
|
||||
|
||||
- name: Setup Feature Dependencies
|
||||
uses: ./.github/actions/ssl/macos/setup-feature-specific-deps
|
||||
|
||||
- name: Setup CA Certificates
|
||||
uses: ./.github/actions/ssl/macos/setup-ca-certs
|
||||
|
||||
- name: Run Basic SSL CLI Tests
|
||||
uses: ./.github/actions/ssl/macos/run-basic-ssl-cli-tests
|
||||
|
||||
- name: Run Custom CA Certs CLI Tests
|
||||
uses: ./.github/actions/ssl/macos/run-custom-ca-certs-cli-tests
|
||||
|
||||
- name: Run Custom CA Certs E2E Tests
|
||||
uses: ./.github/actions/ssl/macos/run-ssl-e2e-tests
|
||||
|
||||
tests-for-windows:
|
||||
name: SSL Tests - Windows
|
||||
timeout-minutes: 60
|
||||
runs-on: windows-latest
|
||||
permissions:
|
||||
checks: write
|
||||
pull-requests: write
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node Dependencies
|
||||
uses: ./.github/actions/common/setup-node-deps
|
||||
|
||||
- name: Setup CA Certificates
|
||||
uses: ./.github/actions/ssl/windows/setup-ca-certs
|
||||
|
||||
- name: Run Basic SSL CLI Tests
|
||||
uses: ./.github/actions/ssl/windows/run-basic-ssl-cli-tests
|
||||
|
||||
- name: Run Custom CA Certs CLI Tests
|
||||
uses: ./.github/actions/ssl/windows/run-custom-ca-certs-cli-tests
|
||||
|
||||
- name: Run Custom CA Certs E2E Tests
|
||||
uses: ./.github/actions/ssl/windows/run-ssl-e2e-tests
|
||||
19
.github/workflows/tests.yml
vendored
19
.github/workflows/tests.yml
vendored
@@ -13,8 +13,8 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'npm'
|
||||
@@ -34,6 +34,8 @@ jobs:
|
||||
|
||||
- name: Lint Check
|
||||
run: npm run lint
|
||||
env:
|
||||
ESLINT_PLUGIN_DIFF_COMMIT: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.ref || 'main' }}
|
||||
|
||||
# tests
|
||||
- name: Test Package bruno-js
|
||||
@@ -64,8 +66,8 @@ jobs:
|
||||
pull-requests: write
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'npm'
|
||||
@@ -83,6 +85,11 @@ jobs:
|
||||
npm run build --workspace=packages/bruno-requests
|
||||
npm run build --workspace=packages/bruno-filestore
|
||||
|
||||
- name: Run Local Testbench
|
||||
run: |
|
||||
npm start --workspace=packages/bruno-tests &
|
||||
sleep 5
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
cd packages/bruno-tests/collection
|
||||
@@ -101,8 +108,8 @@ jobs:
|
||||
timeout-minutes: 60
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: v22.11.x
|
||||
- name: Install dependencies
|
||||
|
||||
1
.husky/pre-commit
Normal file
1
.husky/pre-commit
Normal file
@@ -0,0 +1 @@
|
||||
npx nano-staged
|
||||
BIN
assets/images/vscode-demo.png
Normal file
BIN
assets/images/vscode-demo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 409 KiB |
470
docs/playwright-testing-guide.md
Normal file
470
docs/playwright-testing-guide.md
Normal file
@@ -0,0 +1,470 @@
|
||||
# Playwright Testing Guide for Bruno
|
||||
|
||||
This guide explains how to create and run Playwright test cases for the Bruno application using the UI.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Creating Tests Using Codegen](#creating-tests-using-codegen)
|
||||
- [Manual Test Creation](#manual-test-creation)
|
||||
- [Test Structure and Organization](#test-structure-and-organization)
|
||||
- [Available Test Fixtures](#available-test-fixtures)
|
||||
- [Running Tests](#running-tests)
|
||||
- [Best Practices](#best-practices)
|
||||
- [Examples](#examples)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
|
||||
## Overview
|
||||
|
||||
Bruno uses Playwright for end-to-end testing of its Electron application. The testing setup includes custom fixtures for Electron app testing and utilities for managing test data.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js installed
|
||||
- All dependencies installed (`npm install`)
|
||||
- Electron app can be built and run
|
||||
|
||||
## Creating Tests Using Codegen
|
||||
|
||||
The easiest way to create tests is using Playwright's codegen feature, which records your UI interactions and generates test code.
|
||||
|
||||
### Using the Built-in Codegen Script
|
||||
|
||||
```bash
|
||||
# Generate a test with a specific name
|
||||
npm run test:codegen my-new-test
|
||||
|
||||
# Generate a test without specifying a name (will prompt for input)
|
||||
npm run test:codegen
|
||||
```
|
||||
|
||||
### What Happens During Codegen
|
||||
|
||||
1. The Electron app launches automatically
|
||||
2. Playwright Inspector opens in a separate window
|
||||
3. You interact with the Bruno UI
|
||||
4. Actions are recorded and converted to test code
|
||||
5. The generated test file is saved in `e2e-tests/`
|
||||
|
||||
### Codegen Workflow
|
||||
|
||||
1. **Start Recording**: Run the codegen command
|
||||
2. **Interact with UI**: Perform the actions you want to test
|
||||
3. **Add Assertions**: Use the inspector to add assertions
|
||||
4. **Save Test**: The test file is automatically generated
|
||||
5. **Review and Refine**: Edit the generated test as needed
|
||||
|
||||
## Manual Test Creation
|
||||
|
||||
You can also create tests manually by following the established patterns.
|
||||
|
||||
### Basic Test Structure
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '../../playwright';
|
||||
|
||||
test('Test description', async ({ page }) => {
|
||||
// Test steps here
|
||||
await page.getByLabel('Some Label').click();
|
||||
|
||||
// Assertions
|
||||
await expect(page.getByText('Expected Text')).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Test with Temporary Data
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '../../playwright';
|
||||
|
||||
test('Test with temporary data', async ({ page, createTmpDir }) => {
|
||||
// Create temporary directory for test data
|
||||
const testDir = await createTmpDir('test-collection');
|
||||
|
||||
// Test steps
|
||||
await page.getByLabel('Create Collection').click();
|
||||
await page.getByLabel('Name').fill('test-collection');
|
||||
await page.getByLabel('Location').fill(testDir);
|
||||
|
||||
// Assertions
|
||||
await expect(page.getByText('test-collection')).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
## Test Structure and Organization
|
||||
|
||||
### Directory Structure
|
||||
|
||||
```
|
||||
e2e-tests/
|
||||
├── 001-sanity-tests/ # Basic functionality tests
|
||||
│ ├── 001-home-screen.spec.ts
|
||||
│ └── 002-create-new-collection-and-new-request.spec.ts
|
||||
├── 002-feature-tests/ # Specific feature tests
|
||||
├── 003-integration-tests/ # Complex workflow tests
|
||||
└── bruno-testbench/ # Test utilities and helpers
|
||||
```
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
- **Files**: Use descriptive names with `.spec.ts` extension
|
||||
- **Tests**: Use clear, descriptive test names
|
||||
- **Folders**: Use numbered prefixes for ordering
|
||||
|
||||
### Test File Template
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '../../playwright';
|
||||
|
||||
test.describe('Feature Name', () => {
|
||||
test('should perform specific action', async ({ page }) => {
|
||||
// Arrange
|
||||
// Act
|
||||
// Assert
|
||||
});
|
||||
|
||||
test('should handle error case', async ({ page }) => {
|
||||
// Test error scenarios
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Available Test Fixtures
|
||||
|
||||
The Bruno Playwright setup provides several custom fixtures:
|
||||
|
||||
### Core Fixtures
|
||||
|
||||
- `page`: Main page for testing
|
||||
- `context`: Browser context
|
||||
- `electronApp`: Electron application instance
|
||||
|
||||
### Utility Fixtures
|
||||
|
||||
- `createTmpDir`: Creates temporary directories for test data
|
||||
- `newPage`: Creates a new page instance
|
||||
- `pageWithUserData`: Page with custom user data
|
||||
- `launchElectronApp`: Launches a new Electron app instance
|
||||
- `reuseOrLaunchElectronApp`: Reuses existing app or launches new one
|
||||
|
||||
### Using Fixtures
|
||||
|
||||
```typescript
|
||||
test('Test with multiple fixtures', async ({ page, createTmpDir, electronApp }) => {
|
||||
const testDir = await createTmpDir('test-data');
|
||||
|
||||
// Your test logic here
|
||||
});
|
||||
```
|
||||
|
||||
## Running Tests
|
||||
|
||||
### Basic Commands
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
npm run test:e2e
|
||||
|
||||
# Run specific test file
|
||||
npx playwright test e2e-tests/001-sanity-tests/001-home-screen.spec.ts
|
||||
|
||||
# Run tests in a specific folder
|
||||
npx playwright test e2e-tests/001-sanity-tests/
|
||||
```
|
||||
|
||||
### Advanced Options
|
||||
|
||||
```bash
|
||||
# Run with UI mode (for debugging)
|
||||
npx playwright test --ui
|
||||
|
||||
# Run in headed mode (see browser)
|
||||
npx playwright test --headed
|
||||
|
||||
# Run with specific browser
|
||||
npx playwright test --project="Bruno Electron App"
|
||||
|
||||
# Run with debugging
|
||||
npx playwright test --debug
|
||||
|
||||
# Run with trace recording
|
||||
npx playwright test --trace on
|
||||
```
|
||||
|
||||
### CI/CD Integration
|
||||
|
||||
```bash
|
||||
# Install browsers for CI
|
||||
npx playwright install
|
||||
|
||||
# Run tests in CI mode
|
||||
npm run test:e2e
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Use Semantic Selectors
|
||||
|
||||
**Preferred:**
|
||||
|
||||
```typescript
|
||||
await page.getByRole('button', { name: 'Create' }).click();
|
||||
await page.getByLabel('Collection Name').fill('test');
|
||||
await page.getByText('Success message').toBeVisible();
|
||||
```
|
||||
|
||||
**Avoid:**
|
||||
|
||||
```typescript
|
||||
await page.locator('.btn-primary').click();
|
||||
await page.locator('#collection-name').fill('test');
|
||||
```
|
||||
|
||||
### 2. Create Isolated Tests
|
||||
|
||||
Each test should be independent and not rely on other tests:
|
||||
|
||||
```typescript
|
||||
test('should create collection', async ({ page, createTmpDir }) => {
|
||||
const testDir = await createTmpDir('collection-test');
|
||||
|
||||
// Test creates its own data
|
||||
await page.getByLabel('Create Collection').click();
|
||||
await page.getByLabel('Name').fill('test-collection');
|
||||
await page.getByLabel('Location').fill(testDir);
|
||||
|
||||
// Clean up happens automatically via createTmpDir
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Add Meaningful Assertions
|
||||
|
||||
Always verify the expected outcomes:
|
||||
|
||||
```typescript
|
||||
test('should save request successfully', async ({ page }) => {
|
||||
// Arrange
|
||||
await page.getByLabel('Create Collection').click();
|
||||
|
||||
// Act
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
// Assert
|
||||
await expect(page.getByText('Request saved successfully')).toBeVisible();
|
||||
await expect(page.getByRole('tab', { name: 'GET request' })).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### 4. Handle Async Operations
|
||||
|
||||
```typescript
|
||||
test('should wait for network requests', async ({ page }) => {
|
||||
// Wait for specific network request
|
||||
await page.waitForResponse((response) => response.url().includes('/api/endpoint'));
|
||||
|
||||
// Or wait for element to be stable
|
||||
await page.waitForSelector('[data-testid="loading"]', { state: 'hidden' });
|
||||
});
|
||||
```
|
||||
|
||||
### 5. Use Test Data Management
|
||||
|
||||
```typescript
|
||||
test('should work with test data', async ({ page, createTmpDir }) => {
|
||||
const testDir = await createTmpDir('test-data');
|
||||
|
||||
// Create test files
|
||||
await fs.writeFile(path.join(testDir, 'test.bru'), testContent);
|
||||
|
||||
// Use in test
|
||||
await page.getByLabel('Open Collection').click();
|
||||
await page.getByText(testDir).click();
|
||||
});
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Example 1: Basic Collection Creation
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '../../playwright';
|
||||
|
||||
test('should create a new collection', async ({ page, createTmpDir }) => {
|
||||
const testDir = await createTmpDir('new-collection');
|
||||
|
||||
await page.getByLabel('Create Collection').click();
|
||||
await page.getByLabel('Name').fill('My Test Collection');
|
||||
await page.getByLabel('Location').fill(testDir);
|
||||
await page.getByRole('button', { name: 'Create' }).click();
|
||||
|
||||
await expect(page.getByText('My Test Collection')).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Example 2: Request Creation and Execution
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '../../playwright';
|
||||
|
||||
test('should create and execute HTTP request', async ({ page, createTmpDir }) => {
|
||||
const testDir = await createTmpDir('request-test');
|
||||
|
||||
// Create collection
|
||||
await page.getByLabel('Create Collection').click();
|
||||
await page.getByLabel('Name').fill('Request Test');
|
||||
await page.getByLabel('Location').fill(testDir);
|
||||
await page.getByRole('button', { name: 'Create' }).click();
|
||||
|
||||
// Create request
|
||||
await page.locator('#create-new-tab').getByRole('img').click();
|
||||
await page.getByPlaceholder('Request Name').fill('Test Request');
|
||||
await page.locator('#new-request-url .CodeMirror').click();
|
||||
await page.locator('textarea').fill('http://localhost:8081/ping');
|
||||
await page.getByRole('button', { name: 'Create' }).click();
|
||||
|
||||
// Execute request
|
||||
await page.locator('#send-request').getByRole('img').nth(2).click();
|
||||
|
||||
// Verify response
|
||||
await expect(page.getByRole('main')).toContainText('200 OK');
|
||||
});
|
||||
```
|
||||
|
||||
### Example 3: Environment Management
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '../../playwright';
|
||||
|
||||
test('should create and use environment variables', async ({ page, createTmpDir }) => {
|
||||
const testDir = await createTmpDir('env-test');
|
||||
|
||||
// Setup collection
|
||||
await page.getByLabel('Create Collection').click();
|
||||
await page.getByLabel('Name').fill('Environment Test');
|
||||
await page.getByLabel('Location').fill(testDir);
|
||||
await page.getByRole('button', { name: 'Create' }).click();
|
||||
|
||||
// Create environment
|
||||
await page.getByRole('button', { name: 'Environments' }).click();
|
||||
await page.getByRole('button', { name: 'Add Environment' }).click();
|
||||
await page.getByLabel('Environment Name').fill('Development');
|
||||
await page.getByRole('button', { name: 'Create' }).click();
|
||||
|
||||
// Add variable
|
||||
await page.getByRole('button', { name: 'Add Variable' }).click();
|
||||
await page.getByLabel('Variable Name').fill('API_URL');
|
||||
await page.getByLabel('Variable Value').fill('http://localhost:3000');
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
await expect(page.getByText('API_URL')).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Electron App Not Starting**
|
||||
|
||||
```bash
|
||||
# Ensure dependencies are installed
|
||||
npm install
|
||||
|
||||
# Try running the app manually first
|
||||
npm run dev:electron
|
||||
```
|
||||
|
||||
2. **Tests Timing Out**
|
||||
|
||||
```typescript
|
||||
// Increase timeout for specific test
|
||||
test('slow test', async ({ page }) => {
|
||||
test.setTimeout(60000); // 60 seconds
|
||||
// Test steps
|
||||
});
|
||||
```
|
||||
|
||||
3. **Element Not Found**
|
||||
|
||||
```typescript
|
||||
// Wait for element to be present
|
||||
await page.waitForSelector('[data-testid="element"]');
|
||||
|
||||
// Or use more specific selectors
|
||||
await page.getByRole('button', { name: 'Exact Button Text' }).click();
|
||||
```
|
||||
|
||||
4. **Flaky Tests**
|
||||
|
||||
```typescript
|
||||
// Use stable selectors
|
||||
await page.getByTestId('stable-id').click();
|
||||
|
||||
// Wait for state changes
|
||||
await page.waitForLoadState('networkidle');
|
||||
```
|
||||
|
||||
### Debug Mode
|
||||
|
||||
```bash
|
||||
# Run with debug mode
|
||||
npx playwright test --debug
|
||||
|
||||
# Run specific test in debug mode
|
||||
npx playwright test --debug e2e-tests/001-sanity-tests/001-home-screen.spec.ts
|
||||
```
|
||||
|
||||
### Trace Analysis
|
||||
|
||||
```bash
|
||||
# Run with trace recording
|
||||
npx playwright test --trace on
|
||||
|
||||
# View trace in browser
|
||||
npx playwright show-trace test-results/trace-*.zip
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
The Playwright configuration is in `playwright.config.ts`:
|
||||
|
||||
```typescript
|
||||
export default defineConfig({
|
||||
testDir: './e2e-tests',
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
workers: process.env.CI ? undefined : 1,
|
||||
|
||||
projects: [
|
||||
{
|
||||
name: 'Bruno Electron App'
|
||||
}
|
||||
],
|
||||
|
||||
webServer: [
|
||||
{
|
||||
command: 'npm run dev:web',
|
||||
url: 'http://localhost:3000',
|
||||
reuseExistingServer: !process.env.CI
|
||||
},
|
||||
{
|
||||
command: 'npm start --workspace=packages/bruno-tests',
|
||||
url: 'http://localhost:8081/ping',
|
||||
reuseExistingServer: !process.env.CI
|
||||
}
|
||||
]
|
||||
});
|
||||
```
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [Playwright Documentation](https://playwright.dev/)
|
||||
- [Playwright Test API](https://playwright.dev/docs/api/class-test)
|
||||
- [Electron Testing with Playwright](https://playwright.dev/docs/api/class-electronapplication)
|
||||
- [Bruno Project Structure](../readme.md)
|
||||
|
||||
---
|
||||
|
||||
For questions or issues with testing, please refer to the project's contributing guidelines or create an issue in the repository.
|
||||
@@ -41,13 +41,6 @@
|
||||
|
||||
 <br /><br />
|
||||
|
||||
### الطبعة الذهبية ✨
|
||||
|
||||
غالبية ميزاتنا مجانية ومفتوحة المصدر.
|
||||
نحن نسعى لتحقيق توازن متناغم بين [مبادئ الشفافية والاستدامة](https://github.com/usebruno/bruno/discussions/269)
|
||||
|
||||
طلبات الشراء لـ [الطبعة الذهبية](https://www.usebruno.com/pricing) ستطلق قريبًا بسعر ~~$19~~ **$9** ! <br/>
|
||||
[اشترك هنا](https://usebruno.ck.page/4c65576bd4) لتصلك إشعارات عند الإطلاق.
|
||||
|
||||
### التثبيت
|
||||
|
||||
@@ -74,10 +67,13 @@ 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 [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 gpg curl
|
||||
curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x9FA6017ECABE0266" \
|
||||
| gpg --dearmor \
|
||||
| sudo tee /etc/apt/keyrings/bruno.gpg > /dev/null
|
||||
sudo chmod 644 /etc/apt/keyrings/bruno.gpg
|
||||
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
|
||||
```
|
||||
|
||||
|
||||
@@ -59,10 +59,13 @@ 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 [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 gpg curl
|
||||
curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x9FA6017ECABE0266" \
|
||||
| gpg --dearmor \
|
||||
| sudo tee /etc/apt/keyrings/bruno.gpg > /dev/null
|
||||
sudo chmod 644 /etc/apt/keyrings/bruno.gpg
|
||||
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
|
||||
```
|
||||
|
||||
|
||||
@@ -37,13 +37,37 @@ Bruno 直接在您的电脑文件夹中存储您的 API 信息。我们使用纯
|
||||
|
||||
Bruno 仅限离线使用。我们计划永不向 Bruno 添加云同步功能。我们重视您的数据隐私,并认为它应该留在您的设备上。阅读我们的长期愿景 [点击查看](https://github.com/usebruno/bruno/discussions/269)
|
||||
|
||||
[下载 Bruno](https://www.usebruno.com/downloads)
|
||||
|
||||
📢 观看我们在印度 FOSS 3.0 会议上的最新演讲 [点击查看](https://www.youtube.com/watch?v=7bSMFpbcPiY)
|
||||
|
||||
 <br /><br />
|
||||
|
||||
### 安装
|
||||
## 商业版本 ✨
|
||||
|
||||
Bruno 可以在我们的 [网站上下载](https://www.usebruno.com/downloads) Mac、Windows 和 Linux 的可执行文件。
|
||||
我们的大多数功能都是免费且开源的。
|
||||
我们致力于在 [开源与可持续性发展](https://github.com/usebruno/bruno/discussions/269) 之间取得和谐的平衡
|
||||
|
||||
欢迎使用我们的 [付费版本](https://www.usebruno.com/pricing) ,看看附加的功能是否对您或团队有所帮助! <br/>
|
||||
|
||||
## 目录
|
||||
- [安装](#安装)
|
||||
- [特性](#特性)
|
||||
- [跨平台使用 🖥️](#跨平台使用-)
|
||||
- [通过Git协作 👩💻🧑💻](#通过git协作-)
|
||||
- [重要链接 📌](#重要链接-)
|
||||
- [展示 🎥](#展示-)
|
||||
- [分享评价 📣](#分享评价-)
|
||||
- [发布到新的包管理器](#发布到新的包管理器)
|
||||
- [联系方式 🌐](#联系方式-)
|
||||
- [商标](#商标)
|
||||
- [贡献 👩💻🧑💻](#贡献-)
|
||||
- [作者](#作者)
|
||||
- [许可证 📄](#许可证-)
|
||||
|
||||
## 安装
|
||||
|
||||
Bruno 可以在我们的 [网站上下载](https://www.usebruno.com/downloads) 适用于Mac、Windows 和 Linux 的可执行文件。
|
||||
|
||||
您也可以通过包管理器如 Homebrew、Chocolatey、Scoop、Snap 和 Apt 安装 Bruno。
|
||||
|
||||
@@ -58,79 +82,71 @@ choco install bruno
|
||||
scoop bucket add extras
|
||||
scoop install bruno
|
||||
|
||||
# 在 Windows 上用 winget 安装
|
||||
winget install Bruno.Bruno
|
||||
|
||||
# 在 Linux 上用 Snap 安装
|
||||
snap install bruno
|
||||
|
||||
# 在 Linux 上用 Flatpak 安装
|
||||
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 [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 gpg curl
|
||||
curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x9FA6017ECABE0266" \
|
||||
| gpg --dearmor \
|
||||
| sudo tee /etc/apt/keyrings/bruno.gpg > /dev/null
|
||||
sudo chmod 644 /etc/apt/keyrings/bruno.gpg
|
||||
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 安装 🖥️
|
||||
## 特性
|
||||
|
||||
### 跨平台使用 🖥️
|
||||
|
||||
 <br /><br />
|
||||
|
||||
### Collaborate 安装 👩💻🧑💻
|
||||
### 通过Git协作 👩💻🧑💻
|
||||
|
||||
或者任何您选择的版本控制系统
|
||||
|
||||
 <br /><br />
|
||||
|
||||
### 重要链接 📌
|
||||
## 重要链接 📌
|
||||
|
||||
- [我们的愿景](https://github.com/usebruno/bruno/discussions/269)
|
||||
- [路线图](https://github.com/usebruno/bruno/discussions/384)
|
||||
- [路线图](https://www.usebruno.com/roadmap)
|
||||
- [文档](https://docs.usebruno.com)
|
||||
- [Stack Overflow](https://stackoverflow.com/questions/tagged/bruno)
|
||||
- [网站](https://www.usebruno.com)
|
||||
- [价格](https://www.usebruno.com/pricing)
|
||||
- [下载](https://www.usebruno.com/downloads)
|
||||
- [GitHub 赞助](https://github.com/sponsors/helloanoop).
|
||||
|
||||
### 展示 🎥
|
||||
## 展示 🎥
|
||||
|
||||
- [Testimonials](https://github.com/usebruno/bruno/discussions/343)
|
||||
- [Knowledge Hub](https://github.com/usebruno/bruno/discussions/386)
|
||||
- [Scriptmania](https://github.com/usebruno/bruno/discussions/385)
|
||||
|
||||
### 支持 ❤️
|
||||
|
||||
如果您喜欢 Bruno 并想支持我们的开源工作,请考虑通过 [GitHub Sponsors](https://github.com/sponsors/helloanoop) 来赞助我们。
|
||||
|
||||
### 分享评价 📣
|
||||
## 分享评价 📣
|
||||
|
||||
如果 Bruno 在您的工作和团队中帮助了您,请不要忘记在我们的 GitHub 讨论上分享您的 [评价](https://github.com/usebruno/bruno/discussions/343)
|
||||
|
||||
### 发布到新的包管理器
|
||||
## 发布到新的包管理器
|
||||
|
||||
有关更多信息,请参见 [此处](../publishing/publishing_cn.md) 。
|
||||
如需了解更多信息,请参见 [此处](../publishing/publishing_cn.md) 。
|
||||
|
||||
### 贡献 👩💻🧑💻
|
||||
|
||||
我很高兴您希望改进 bruno。请查看 [贡献指南](../contributing/contributing_cn.md)。
|
||||
|
||||
即使您无法通过代码做出贡献,我们仍然欢迎您提出 BUG 和新的功能需求。
|
||||
|
||||
### 作者
|
||||
|
||||
<div align="center">
|
||||
<a href="https://github.com/usebruno/bruno/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=usebruno/bruno" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
### 联系方式 🌐
|
||||
## 联系方式 🌐
|
||||
|
||||
[𝕏 (Twitter)](https://twitter.com/use_bruno) <br />
|
||||
[Website](https://www.usebruno.com) <br />
|
||||
[Discord](https://discord.com/invite/KgcZUncpjq) <br />
|
||||
[LinkedIn](https://www.linkedin.com/company/usebruno)
|
||||
|
||||
### 商标
|
||||
## 商标
|
||||
|
||||
**名称**
|
||||
|
||||
@@ -140,6 +156,20 @@ sudo apt update && sudo apt install bruno
|
||||
|
||||
Logo 源自 [OpenMoji](https://openmoji.org/library/emoji-1F436/). License: CC [BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/)
|
||||
|
||||
### 许可证 📄
|
||||
## 贡献 👩💻🧑💻
|
||||
|
||||
很高兴您希望改进 bruno。请查看 [贡献指南](../contributing/contributing_cn.md)。
|
||||
|
||||
即使您无法通过代码做出贡献,我们仍然欢迎您提出 BUG 和新的功能需求。
|
||||
|
||||
## 作者
|
||||
|
||||
<div align="center">
|
||||
<a href="https://github.com/usebruno/bruno/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=usebruno/bruno" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
## 许可证 📄
|
||||
|
||||
[MIT](../../license.md)
|
||||
|
||||
@@ -43,13 +43,6 @@ Bruno ist ein reines Offline-Tool. Es gibt keine Pläne, Bruno um eine Cloud-Syn
|
||||
|
||||
 <br /><br />
|
||||
|
||||
### Golden Edition ✨
|
||||
|
||||
Die meisten unserer Funktionen sind kostenlos und quelloffen.
|
||||
Wir bemühen uns um ein Gleichgewicht zwischen [Open-Source-Prinzipien und Nachhaltigkeit](https://github.com/usebruno/bruno/discussions/269)
|
||||
|
||||
Du kannst die [Golden Edition](https://www.usebruno.com/pricing) bestellen **$19**! <br/>
|
||||
|
||||
### Installation
|
||||
|
||||
Bruno ist als Download [auf unserer Website](https://www.usebruno.com/downloads) für Mac, Windows und Linux verfügbar.
|
||||
@@ -78,10 +71,13 @@ 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 [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 gpg curl
|
||||
curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x9FA6017ECABE0266" \
|
||||
| gpg --dearmor \
|
||||
| sudo tee /etc/apt/keyrings/bruno.gpg > /dev/null
|
||||
sudo chmod 644 /etc/apt/keyrings/bruno.gpg
|
||||
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
|
||||
```
|
||||
|
||||
|
||||
@@ -43,13 +43,6 @@ Bruno funciona sin conexión a internet. No tenemos intenciones de añadir sincr
|
||||
|
||||
 <br /><br />
|
||||
|
||||
### Golden Edition ✨
|
||||
|
||||
La mayoría de nuestras funcionalidades son gratis y de código abierto.
|
||||
Queremos alcanzar un equilibrio en armonía entre los [principios open-source y la sostenibilidad](https://github.com/usebruno/bruno/discussions/269).
|
||||
|
||||
¡Puedes reservar la [Golden Edition](https://www.usebruno.com/pricing) por ~~$19~~ **$9**! <br/>
|
||||
|
||||
### Instalación
|
||||
|
||||
Bruno está disponible para su descarga [en nuestro sitio web](https://www.usebruno.com/downloads) para Mac, Windows y Linux.
|
||||
@@ -75,10 +68,13 @@ 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 [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 gpg curl
|
||||
curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x9FA6017ECABE0266" \
|
||||
| gpg --dearmor \
|
||||
| sudo tee /etc/apt/keyrings/bruno.gpg > /dev/null
|
||||
sudo chmod 644 /etc/apt/keyrings/bruno.gpg
|
||||
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
|
||||
```
|
||||
|
||||
|
||||
@@ -63,10 +63,13 @@ 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 [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 gpg curl
|
||||
curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x9FA6017ECABE0266" \
|
||||
| gpg --dearmor \
|
||||
| sudo tee /etc/apt/keyrings/bruno.gpg > /dev/null
|
||||
sudo chmod 644 /etc/apt/keyrings/bruno.gpg
|
||||
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
|
||||
```
|
||||
|
||||
|
||||
@@ -75,12 +75,14 @@ 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
|
||||
sudo apt update && sudo apt install gpg curl
|
||||
curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x9FA6017ECABE0266" \
|
||||
| gpg --dearmor \
|
||||
| sudo tee /etc/apt/keyrings/bruno.gpg > /dev/null
|
||||
sudo chmod 644 /etc/apt/keyrings/bruno.gpg
|
||||
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
|
||||
|
||||
कई प्लेटफार्मों पर चलाएं 🖥️
|
||||
<br /><br />
|
||||
@@ -148,4 +150,3 @@ Scriptmania
|
||||
|
||||
लाइसेंस 📄
|
||||
MIT
|
||||
|
||||
|
||||
@@ -59,10 +59,13 @@ 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 [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 gpg curl
|
||||
curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x9FA6017ECABE0266" \
|
||||
| gpg --dearmor \
|
||||
| sudo tee /etc/apt/keyrings/bruno.gpg > /dev/null
|
||||
sudo chmod 644 /etc/apt/keyrings/bruno.gpg
|
||||
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
|
||||
```
|
||||
|
||||
|
||||
@@ -43,13 +43,6 @@ Bruno はオフラインのみで利用できます。Bruno にクラウド同
|
||||
|
||||
 <br /><br />
|
||||
|
||||
### ゴールデンエディション ✨
|
||||
|
||||
機能のほとんどが無料で使用でき、オープンソースとなっています。
|
||||
私たちは[オープンソースの原則と長期的な維持](https://github.com/usebruno/bruno/discussions/269)の間でうまくバランスを取ろうと努力しています。
|
||||
|
||||
[ゴールデンエディション](https://www.usebruno.com/pricing)を **19 ドル** (買い切り)で購入できます!
|
||||
|
||||
### インストール方法
|
||||
|
||||
Bruno は[私たちのウェブサイト](https://www.usebruno.com/downloads)からバイナリをダウンロードできます。Mac, Windows, Linux に対応しています。
|
||||
@@ -78,10 +71,13 @@ 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 [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 gpg curl
|
||||
curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x9FA6017ECABE0266" \
|
||||
| gpg --dearmor \
|
||||
| sudo tee /etc/apt/keyrings/bruno.gpg > /dev/null
|
||||
sudo chmod 644 /etc/apt/keyrings/bruno.gpg
|
||||
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
|
||||
```
|
||||
|
||||
|
||||
@@ -43,12 +43,6 @@
|
||||
|
||||
 <br /><br />
|
||||
|
||||
### ოქროს გამოცემა ✨
|
||||
|
||||
მთავარი ფუნქციების უმეტესობა უფასოა და ღია წყაროა. ჩვენ ვცდილობთ ჰარმონიული ბალანსის დაცვას [ღია წყაროების პრინციპებსა და მდგრადობას შორის](https://github.com/usebruno/bruno/discussions/269)
|
||||
|
||||
თქვენ შეგიძლიათ შეიძინოთ [ოქროს გამოცემა](https://www.usebruno.com/pricing) ერთჯერადი გადახდით **19 დოლარად**! <br/>
|
||||
|
||||
### ინსტალაცია
|
||||
|
||||
ბრუნო ხელმისაწვდომია როგორც ბინარული ჩამოტვირთვა [ჩვენ的网站上](https://www.usebruno.com/downloads) Mac-ის, Windows-ისა და Linux-ისთვის.
|
||||
@@ -77,12 +71,14 @@ 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 [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 gpg curl
|
||||
curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x9FA6017ECABE0266" \
|
||||
| gpg --dearmor \
|
||||
| sudo tee /etc/apt/keyrings/bruno.gpg > /dev/null
|
||||
sudo chmod 644 /etc/apt/keyrings/bruno.gpg
|
||||
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
|
||||
```
|
||||
|
||||
### პლატფორმებს შორის მუშაობა 🖥️
|
||||
|
||||
@@ -59,10 +59,13 @@ 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 [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 gpg curl
|
||||
curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x9FA6017ECABE0266" \
|
||||
| gpg --dearmor \
|
||||
| sudo tee /etc/apt/keyrings/bruno.gpg > /dev/null
|
||||
sudo chmod 644 /etc/apt/keyrings/bruno.gpg
|
||||
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
|
||||
```
|
||||
|
||||
|
||||
@@ -26,13 +26,6 @@ Bruno is uitsluitend offline. Er zijn geen plannen om ooit cloud-synchronisatie
|
||||
|
||||
 <br /><br />
|
||||
|
||||
### Golden Edition ✨
|
||||
|
||||
De meeste van onze functies zijn gratis en open source.
|
||||
We streven naar een harmonieuze balans tussen [open-source principes en duurzaamheid](https://github.com/usebruno/bruno/discussions/269).
|
||||
|
||||
Je kunt de [Golden Edition](https://www.usebruno.com/pricing) kopen voor een eenmalige betaling van **$19**! <br/>
|
||||
|
||||
### Installatie
|
||||
|
||||
Bruno is beschikbaar als binaire download [op onze website](https://www.usebruno.com/downloads) voor Mac, Windows en Linux.
|
||||
@@ -61,12 +54,14 @@ flatpak install com.usebruno.Bruno
|
||||
|
||||
# Op Linux via 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 [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 gpg curl
|
||||
curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x9FA6017ECABE0266" \
|
||||
| gpg --dearmor \
|
||||
| sudo tee /etc/apt/keyrings/bruno.gpg > /dev/null
|
||||
sudo chmod 644 /etc/apt/keyrings/bruno.gpg
|
||||
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
|
||||
```
|
||||
|
||||
### Draai op meerdere platformen 🖥️
|
||||
|
||||
@@ -69,10 +69,13 @@ 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 gpg curl
|
||||
curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x9FA6017ECABE0266" \
|
||||
| gpg --dearmor \
|
||||
| sudo tee /etc/apt/keyrings/bruno.gpg > /dev/null
|
||||
sudo chmod 644 /etc/apt/keyrings/bruno.gpg
|
||||
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
|
||||
```
|
||||
|
||||
|
||||
@@ -41,13 +41,6 @@ Bruno é totalmente offline. Não há planos de adicionar sincronização em nuv
|
||||
|
||||
 <br /><br />
|
||||
|
||||
### Golden Edition ✨
|
||||
|
||||
A grande maioria dos nossos recursos são gratuitos e de código aberto.
|
||||
Nós nos esforçamos para encontrar um equilíbrio harmônico entre [princípios de código aberto e sustentabilidade](https://github.com/usebruno/bruno/discussions/269)
|
||||
|
||||
Você pode pré encomendar o plano [Golden Edition](https://www.usebruno.com/pricing) por ~~USD $19~~ **USD $9**! <br/>
|
||||
|
||||
### Instalação
|
||||
|
||||
Bruno está disponível para download como binário [em nosso site](https://www.usebruno.com/downloads) para Mac, Windows e Linux.
|
||||
@@ -76,10 +69,13 @@ 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 [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 gpg curl
|
||||
curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x9FA6017ECABE0266" \
|
||||
| gpg --dearmor \
|
||||
| sudo tee /etc/apt/keyrings/bruno.gpg > /dev/null
|
||||
sudo chmod 644 /etc/apt/keyrings/bruno.gpg
|
||||
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
|
||||
```
|
||||
|
||||
|
||||
@@ -59,10 +59,13 @@ 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 [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 gpg curl
|
||||
curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x9FA6017ECABE0266" \
|
||||
| gpg --dearmor \
|
||||
| sudo tee /etc/apt/keyrings/bruno.gpg > /dev/null
|
||||
sudo chmod 644 /etc/apt/keyrings/bruno.gpg
|
||||
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
|
||||
```
|
||||
|
||||
|
||||
@@ -63,10 +63,13 @@ 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 [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 gpg curl
|
||||
curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x9FA6017ECABE0266" \
|
||||
| gpg --dearmor \
|
||||
| sudo tee /etc/apt/keyrings/bruno.gpg > /dev/null
|
||||
sudo chmod 644 /etc/apt/keyrings/bruno.gpg
|
||||
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
|
||||
```
|
||||
|
||||
|
||||
@@ -63,10 +63,13 @@ 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 [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 gpg curl
|
||||
curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x9FA6017ECABE0266" \
|
||||
| gpg --dearmor \
|
||||
| sudo tee /etc/apt/keyrings/bruno.gpg > /dev/null
|
||||
sudo chmod 644 /etc/apt/keyrings/bruno.gpg
|
||||
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
|
||||
```
|
||||
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import { test, expect } from '../../playwright';
|
||||
|
||||
test('Create new collection and add a simple HTTP request', async ({ page, createTmpDir }) => {
|
||||
await page.getByLabel('Create Collection').click();
|
||||
await page.getByLabel('Name').click();
|
||||
await page.getByLabel('Name').fill('test-collection');
|
||||
await page.getByLabel('Name').press('Tab');
|
||||
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 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');
|
||||
await page.locator('#new-request-url .CodeMirror').click();
|
||||
await page.locator('textarea').fill('http://localhost:8081');
|
||||
await page.getByRole('button', { name: 'Create' }).click();
|
||||
await page.locator('#request-url .CodeMirror').click();
|
||||
await page.locator('textarea').fill('/ping');
|
||||
await page.locator('#send-request').getByRole('img').nth(2).click();
|
||||
|
||||
await expect(page.getByRole('main')).toContainText('200 OK');
|
||||
|
||||
await page.getByRole('tab', { name: 'GET r1' }).locator('circle').click();
|
||||
await page.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await page.getByText('GETr1').click();
|
||||
await page.getByRole('button', { name: 'Clear response' }).click();
|
||||
await page.locator('body').press('ControlOrMeta+Enter');
|
||||
|
||||
await expect(page.getByRole('main')).toContainText('200 OK');
|
||||
});
|
||||
@@ -1,33 +0,0 @@
|
||||
import { test, expect } from '../../playwright';
|
||||
|
||||
test.describe.serial('Persistent Environment Test', () => {
|
||||
test.setTimeout(2 * 10 * 1000);
|
||||
|
||||
test('add env using script', async ({ pageWithUserData: page, restartApp }) => {
|
||||
await page.locator('#sidebar-collection-name').click();
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
await page.getByText('ping', { exact: true }).click();
|
||||
await page.getByText('No Environment').click();
|
||||
await page.getByRole('tooltip').locator('div').filter({ hasText: 'Env' }).nth(3).click();
|
||||
await page.locator('#send-request').getByRole('img').nth(2).click();
|
||||
await page.waitForTimeout(1000);
|
||||
await page.locator('div').filter({ hasText: /^Env$/ }).nth(3).click();
|
||||
await page.getByText('Configure', { exact: true }).click();
|
||||
await expect(page.getByRole('row', { name: 'persistent-env-test' }).getByRole('cell').nth(3)).toBeVisible();
|
||||
await page.getByText('×').click();
|
||||
|
||||
const newApp = await restartApp();
|
||||
const newPage = await newApp.firstWindow();
|
||||
await newPage.locator('#sidebar-collection-name').click();
|
||||
await newPage.getByRole('button', { name: 'Save' }).click();
|
||||
await newPage.getByText('ping', { exact: true }).click();
|
||||
await newPage.getByText('No Environment').click();
|
||||
await newPage.getByRole('tooltip').locator('div').filter({ hasText: 'Env' }).nth(3).click();
|
||||
await newPage.locator('div').filter({ hasText: /^Env$/ }).nth(3).click();
|
||||
await newPage.getByText('Configure', { exact: true }).click();
|
||||
await expect(newPage.getByRole('row', { name: 'persistent-env-test' }).getByRole('cell').nth(3)).toBeVisible();
|
||||
await newPage.getByText('×').click();
|
||||
await newPage.waitForTimeout(1000);
|
||||
await newPage.close();
|
||||
});
|
||||
});
|
||||
@@ -1,40 +0,0 @@
|
||||
import { test, expect } from '../../playwright';
|
||||
|
||||
test.describe.serial('Persistent Environment Test', () => {
|
||||
test.setTimeout(2 * 10 * 1000);
|
||||
|
||||
test('add env using script', async ({ pageWithUserData: page, restartApp }) => {
|
||||
await page.locator('#sidebar-collection-name').click();
|
||||
await page.getByText('ping2', { exact: true }).click();
|
||||
await page.getByText('Env', { exact: true }).click();
|
||||
await page.getByText('Stage', { exact: true }).click();
|
||||
await page.locator('#send-request').getByRole('img').nth(2).click();
|
||||
await page.waitForTimeout(1000);
|
||||
await page
|
||||
.locator('div')
|
||||
.filter({ hasText: /^Stage$/ })
|
||||
.nth(3)
|
||||
.click();
|
||||
await page.getByText('Configure', { exact: true }).click();
|
||||
await expect(page.getByRole('row', { name: 'persistent-env-test' }).getByRole('cell').nth(3)).toBeVisible();
|
||||
await page.getByText('×').click();
|
||||
|
||||
const newApp = await restartApp();
|
||||
const newPage = await newApp.firstWindow();
|
||||
await newPage.locator('#sidebar-collection-name').click();
|
||||
await newPage.getByRole('button', { name: 'Save' }).click();
|
||||
await newPage.getByText('ping2', { exact: true }).click();
|
||||
await newPage.getByText('No Environment').click();
|
||||
await newPage.getByText('Stage').click();
|
||||
await newPage
|
||||
.locator('div')
|
||||
.filter({ hasText: /^Stage$/ })
|
||||
.nth(3)
|
||||
.click();
|
||||
await newPage.getByText('Configure', { exact: true }).click();
|
||||
await expect(newPage.getByRole('row', { name: 'persistent-env-test' }).getByRole('cell').nth(3)).not.toBeVisible();
|
||||
await newPage.getByText('×').click();
|
||||
await newPage.waitForTimeout(1000);
|
||||
await newPage.close();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +0,0 @@
|
||||
vars {
|
||||
host: https://testbench-sanity.usebruno.com
|
||||
persistent-env-test: persistent-env-test-value
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
vars {
|
||||
host: https://testbench-sanity.usebruno.com
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"maximized": true,
|
||||
"lastOpenedCollections": [
|
||||
"{{projectRoot}}/e2e-tests/persistent-env-tests/collection"
|
||||
]
|
||||
}
|
||||
@@ -1,8 +1,72 @@
|
||||
// eslint.config.js
|
||||
const { defineConfig } = require("eslint/config");
|
||||
const globals = require("globals");
|
||||
const { fixupPluginRules } = require('@eslint/compat');
|
||||
const eslintPluginDiff = require('eslint-plugin-diff');
|
||||
|
||||
module.exports = defineConfig([
|
||||
let stylistic;
|
||||
|
||||
const runESMImports = async () => {
|
||||
stylistic = await import('@stylistic/eslint-plugin').then((d) => d.default);
|
||||
};
|
||||
|
||||
module.exports = runESMImports().then(() => defineConfig([
|
||||
{
|
||||
plugins: {
|
||||
'diff': fixupPluginRules(eslintPluginDiff),
|
||||
'@stylistic': stylistic
|
||||
},
|
||||
languageOptions: {
|
||||
parser: require('@typescript-eslint/parser'),
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module'
|
||||
}
|
||||
},
|
||||
files: [
|
||||
'./eslint.config.js',
|
||||
'tests/**/*.{ts,js}',
|
||||
'playwright/**/*.{js,ts}',
|
||||
'packages/bruno-app/**/*.{js,jsx,ts}',
|
||||
'packages/bruno-app/src/test-utils/mocks/codemirror.js',
|
||||
'packages/bruno-cli/**/*.js',
|
||||
'packages/bruno-common/**/*.ts',
|
||||
'packages/bruno-converters/**/*.js',
|
||||
'packages/bruno-electron/**/*.js',
|
||||
'packages/bruno-filestore/**/*.ts',
|
||||
'packages/bruno-js/**/*.js',
|
||||
'packages/bruno-lang/**/*.js',
|
||||
'packages/bruno-requests/**/*.ts',
|
||||
'packages/bruno-requests/**/*.js',
|
||||
'packages/bruno-tests/**/*.{js,ts}'
|
||||
],
|
||||
processor: 'diff/diff',
|
||||
rules: {
|
||||
...stylistic.configs.customize({
|
||||
indent: 2,
|
||||
quotes: 'single',
|
||||
semi: true,
|
||||
jsx: true
|
||||
}).rules,
|
||||
'@stylistic/comma-dangle': ['error', 'never'],
|
||||
'@stylistic/brace-style': ['error', '1tbs', { allowSingleLine: true }],
|
||||
'@stylistic/arrow-parens': ['error', 'always'],
|
||||
'@stylistic/curly-newline': ['error', {
|
||||
multiline: true,
|
||||
minElements: 2,
|
||||
consistent: true
|
||||
}],
|
||||
'@stylistic/function-paren-newline': ['error', 'never'],
|
||||
'@stylistic/array-bracket-spacing': ['error', 'never'],
|
||||
'@stylistic/arrow-spacing': ['error', { before: true, after: true }],
|
||||
'@stylistic/function-call-spacing': ['error', 'never'],
|
||||
'@stylistic/multiline-ternary': ['off'],
|
||||
'@stylistic/padding-line-between-statements': ['off'],
|
||||
'@stylistic/semi-style': ['error', 'last'],
|
||||
'@stylistic/max-len': ['off'],
|
||||
'@stylistic/jsx-one-expression-per-line': ['off']
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ["packages/bruno-app/**/*.{js,jsx,ts}"],
|
||||
ignores: ["**/*.config.js", "**/public/**/*"],
|
||||
@@ -197,4 +261,4 @@ module.exports = defineConfig([
|
||||
"no-undef": "error",
|
||||
},
|
||||
},
|
||||
]);
|
||||
]));
|
||||
|
||||
1163
package-lock.json
generated
1163
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
17
package.json
17
package.json
@@ -19,20 +19,25 @@
|
||||
],
|
||||
"homepage": "https://usebruno.com",
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^1.3.2",
|
||||
"@faker-js/faker": "^7.6.0",
|
||||
"@jest/globals": "^29.2.0",
|
||||
"@playwright/test": "^1.51.1",
|
||||
"@rollup/plugin-json": "^6.1.0",
|
||||
"@stylistic/eslint-plugin": "^5.3.1",
|
||||
"@types/jest": "^29.5.11",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "^22.14.1",
|
||||
"@typescript-eslint/parser": "^8.39.0",
|
||||
"concurrently": "^8.2.2",
|
||||
"eslint": "^9.26.0",
|
||||
"eslint-plugin-diff": "^2.0.3",
|
||||
"fs-extra": "^11.1.1",
|
||||
"globals": "^16.1.0",
|
||||
"husky": "^9.1.7",
|
||||
"jest": "^29.2.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"nano-staged": "^0.8.0",
|
||||
"playwright": "^1.51.1",
|
||||
"pretty-quick": "^3.1.3",
|
||||
"randomstring": "^1.2.2",
|
||||
@@ -65,9 +70,17 @@
|
||||
"build:electron:snap": "./scripts/build-electron.sh snap",
|
||||
"watch:common": "npm run watch --workspace=packages/bruno-common",
|
||||
"test:codegen": "node playwright/codegen.ts",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e": "playwright test --project=default",
|
||||
"test:e2e:ssl": "playwright test --project=ssl",
|
||||
"test:prettier:web": "npm run test:prettier --workspace=packages/bruno-app",
|
||||
"lint": "node --max_old_space_size=4096 $(npx which eslint)"
|
||||
"lint": "node --max_old_space_size=4096 $(npx which eslint)",
|
||||
"lint:fix": "node --max_old_space_size=4096 $(npx which eslint) --fix",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"nano-staged": {
|
||||
"*.{js,ts,jsx}": [
|
||||
"npm run lint:fix"
|
||||
]
|
||||
},
|
||||
"overrides": {
|
||||
"rollup": "3.29.5",
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"@prantlf/jsonlint": "^16.0.0",
|
||||
"@reduxjs/toolkit": "^1.8.0",
|
||||
"@tabler/icons": "^1.46.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@tippyjs/react": "^4.2.6",
|
||||
"@usebruno/common": "0.1.0",
|
||||
"@usebruno/graphql-docs": "0.1.0",
|
||||
@@ -26,6 +27,8 @@
|
||||
"cookie": "0.7.1",
|
||||
"dompurify": "^3.2.4",
|
||||
"escape-html": "^1.0.3",
|
||||
"fast-fuzzy": "^1.12.0",
|
||||
"fast-json-format": "~0.2.0",
|
||||
"file": "^0.2.2",
|
||||
"file-dialog": "^0.0.8",
|
||||
"file-saver": "^2.0.5",
|
||||
@@ -36,7 +39,6 @@
|
||||
"graphql-request": "^3.7.0",
|
||||
"httpsnippet": "^3.0.9",
|
||||
"i18next": "24.1.2",
|
||||
"iconv-lite": "^0.6.3",
|
||||
"idb": "^7.0.0",
|
||||
"immer": "^9.0.15",
|
||||
"jsesc": "^3.0.2",
|
||||
@@ -92,7 +94,7 @@
|
||||
"@rsbuild/plugin-react": "^1.0.7",
|
||||
"@rsbuild/plugin-sass": "^1.1.0",
|
||||
"@rsbuild/plugin-styled-components": "1.1.0",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"autoprefixer": "10.4.20",
|
||||
|
||||
83
packages/bruno-app/src/components/BodyModeSelector/index.js
Normal file
83
packages/bruno-app/src/components/BodyModeSelector/index.js
Normal file
@@ -0,0 +1,83 @@
|
||||
import React, { useRef, forwardRef } from 'react';
|
||||
import { IconCaretDown } from '@tabler/icons';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import { humanizeRequestBodyMode } from 'utils/collections';
|
||||
|
||||
const DEFAULT_MODES = [
|
||||
{ key: 'multipartForm', label: 'Multipart Form', category: 'Form' },
|
||||
{ key: 'formUrlEncoded', label: 'Form URL Encoded', category: 'Form' },
|
||||
{ key: 'json', label: 'JSON', category: 'Raw' },
|
||||
{ key: 'xml', label: 'XML', category: 'Raw' },
|
||||
{ key: 'text', label: 'TEXT', category: 'Raw' },
|
||||
{ key: 'sparql', label: 'SPARQL', category: 'Raw' },
|
||||
{ key: 'file', label: 'File / Binary', category: 'Other' },
|
||||
{ key: 'none', label: 'None', category: 'Other' }
|
||||
];
|
||||
|
||||
const BodyModeSelector = ({
|
||||
currentMode,
|
||||
onModeChange,
|
||||
modes = DEFAULT_MODES,
|
||||
disabled = false,
|
||||
className = '',
|
||||
wrapperClassName = '',
|
||||
showCategories = true,
|
||||
placement = 'bottom-end'
|
||||
}) => {
|
||||
const dropdownTippyRef = useRef();
|
||||
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
|
||||
|
||||
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(currentMode)}
|
||||
{' '}
|
||||
<IconCaretDown className="caret ml-2" size={14} strokeWidth={2} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const onModeSelect = (mode) => {
|
||||
dropdownTippyRef.current.hide();
|
||||
onModeChange(mode);
|
||||
};
|
||||
|
||||
// Group modes by category for rendering
|
||||
const groupedModes = modes.reduce((acc, mode) => {
|
||||
const category = mode.category || 'Other';
|
||||
if (!acc[category]) {
|
||||
acc[category] = [];
|
||||
}
|
||||
acc[category].push(mode);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return (
|
||||
<div className={`inline-flex items-center body-mode-selector ${disabled ? 'cursor-default' : 'cursor-pointer'} ${wrapperClassName}`}>
|
||||
<Dropdown
|
||||
onCreate={onDropdownCreate}
|
||||
icon={<Icon />}
|
||||
placement={placement}
|
||||
disabled={disabled}
|
||||
className={className}
|
||||
>
|
||||
{Object.entries(groupedModes).map(([category, categoryModes]) => (
|
||||
<React.Fragment key={category}>
|
||||
{showCategories && <div className="label-item font-medium">{category}</div>}
|
||||
{categoryModes.map((mode) => (
|
||||
<div
|
||||
key={mode.key}
|
||||
className="dropdown-item"
|
||||
onClick={() => onModeSelect(mode.key)}
|
||||
>
|
||||
{mode.label}
|
||||
</div>
|
||||
))}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Dropdown>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BodyModeSelector;
|
||||
79
packages/bruno-app/src/components/Checkbox/StyledWrapper.js
Normal file
79
packages/bruno-app/src/components/Checkbox/StyledWrapper.js
Normal file
@@ -0,0 +1,79 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.checkbox-container {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.checkbox-checkmark {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
visibility: ${(props) => props.checked ? 'visible' : 'hidden'};
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.checkbox-input {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border: 2px solid ${(props) => {
|
||||
if (props.checked && props.disabled) {
|
||||
return props.theme.colors.text.muted;
|
||||
}
|
||||
|
||||
if (props.checked && !props.disabled) {
|
||||
return props.theme.colors.text.yellow;
|
||||
}
|
||||
|
||||
return props.theme.colors.text.muted;
|
||||
}};
|
||||
border-radius: 4px;
|
||||
background-color: ${(props) => {
|
||||
if (props.checked && !props.disabled) {
|
||||
return props.theme.colors.text.yellow;
|
||||
}
|
||||
|
||||
if (props.checked && props.disabled) {
|
||||
return props.theme.colors.text.muted;
|
||||
}
|
||||
|
||||
return 'transparent';
|
||||
}};
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: all 0.2s ease;
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px ${(props) => props.theme.colors.text.yellow}40;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
45
packages/bruno-app/src/components/Checkbox/index.js
Normal file
45
packages/bruno-app/src/components/Checkbox/index.js
Normal file
@@ -0,0 +1,45 @@
|
||||
import React from 'react';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import IconCheckMark from 'components/Icons/IconCheckMark';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
|
||||
const Checkbox = ({
|
||||
checked = false,
|
||||
disabled = false,
|
||||
onChange,
|
||||
className = '',
|
||||
id,
|
||||
name,
|
||||
value,
|
||||
dataTestId = 'checkbox'
|
||||
}) => {
|
||||
const { theme } = useTheme();
|
||||
|
||||
const handleChange = (e) => {
|
||||
if (!disabled && onChange) {
|
||||
onChange(e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper checked={checked} disabled={disabled} className={className}>
|
||||
<div className="checkbox-container">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={id}
|
||||
name={name}
|
||||
value={value}
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={handleChange}
|
||||
className="checkbox-input"
|
||||
data-testid={dataTestId}
|
||||
/>
|
||||
<IconCheckMark className="checkbox-checkmark" color={theme.examples.checkbox.color} size={14} />
|
||||
</div>
|
||||
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default Checkbox;
|
||||
@@ -1,6 +1,12 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
&.read-only {
|
||||
div.CodeMirror .CodeMirror-cursor {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
div.CodeMirror {
|
||||
background: ${(props) => props.theme.codemirror.bg};
|
||||
border: solid 1px ${(props) => props.theme.codemirror.border};
|
||||
@@ -109,6 +115,17 @@ const StyledWrapper = styled.div`
|
||||
text-decoration:unset;
|
||||
}
|
||||
|
||||
.cm-search-line-highlight {
|
||||
background: ${(props) => props.theme.codemirror.searchLineHighlightCurrent};
|
||||
}
|
||||
|
||||
.cm-search-match {
|
||||
background: rgba(255, 193, 7, 0.25);
|
||||
}
|
||||
|
||||
.cm-search-current {
|
||||
background: rgba(255, 193, 7, 0.4);
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
|
||||
@@ -14,6 +14,7 @@ import * as jsonlint from '@prantlf/jsonlint';
|
||||
import { JSHINT } from 'jshint';
|
||||
import stripJsonComments from 'strip-json-comments';
|
||||
import { getAllVariables } from 'utils/collections';
|
||||
import CodeMirrorSearch from 'components/CodeMirrorSearch';
|
||||
|
||||
const CodeMirror = require('codemirror');
|
||||
window.jsonlint = jsonlint;
|
||||
@@ -37,6 +38,10 @@ export default class CodeEditor extends React.Component {
|
||||
expr: true,
|
||||
asi: true
|
||||
};
|
||||
|
||||
this.state = {
|
||||
searchBarVisible: false
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
@@ -45,7 +50,7 @@ export default class CodeEditor extends React.Component {
|
||||
const editor = (this.editor = CodeMirror(this._node, {
|
||||
value: this.props.value || '',
|
||||
lineNumbers: true,
|
||||
lineWrapping: true,
|
||||
lineWrapping: this.props.enableLineWrapping ?? true,
|
||||
tabSize: TAB_SIZE,
|
||||
mode: this.props.mode || 'application/ld+json',
|
||||
brunoVarInfo: {
|
||||
@@ -83,24 +88,14 @@ export default class CodeEditor extends React.Component {
|
||||
}
|
||||
},
|
||||
'Cmd-F': (cm) => {
|
||||
if (this._isSearchOpen()) {
|
||||
// replace the older search component with the new one
|
||||
const search = document.querySelector('.CodeMirror-dialog.CodeMirror-dialog-top');
|
||||
search && search.remove();
|
||||
if (!this.state.searchBarVisible) {
|
||||
this.setState({ searchBarVisible: true });
|
||||
}
|
||||
cm.execCommand('findPersistent');
|
||||
this._bindSearchHandler();
|
||||
this._appendSearchResultsCount();
|
||||
},
|
||||
'Ctrl-F': (cm) => {
|
||||
if (this._isSearchOpen()) {
|
||||
// replace the older search component with the new one
|
||||
const search = document.querySelector('.CodeMirror-dialog.CodeMirror-dialog-top');
|
||||
search && search.remove();
|
||||
if (!this.state.searchBarVisible) {
|
||||
this.setState({ searchBarVisible: true });
|
||||
}
|
||||
cm.execCommand('findPersistent');
|
||||
this._bindSearchHandler();
|
||||
this._appendSearchResultsCount();
|
||||
},
|
||||
'Cmd-H': 'replace',
|
||||
'Ctrl-H': 'replace',
|
||||
@@ -129,6 +124,11 @@ export default class CodeEditor extends React.Component {
|
||||
} else {
|
||||
this.editor.toggleComment();
|
||||
}
|
||||
},
|
||||
'Esc': () => {
|
||||
if (this.state.searchBarVisible) {
|
||||
this.setState({ searchBarVisible: false });
|
||||
}
|
||||
}
|
||||
},
|
||||
foldOptions: {
|
||||
@@ -237,6 +237,18 @@ export default class CodeEditor extends React.Component {
|
||||
this.editor.scrollTo(null, this.props.initialScroll);
|
||||
}
|
||||
|
||||
if (this.props.enableLineWrapping !== prevProps.enableLineWrapping) {
|
||||
this.editor.setOption('lineWrapping', this.props.enableLineWrapping);
|
||||
}
|
||||
|
||||
if (this.props.mode !== prevProps.mode) {
|
||||
this.editor.setOption('mode', this.props.mode);
|
||||
}
|
||||
|
||||
if (this.props.readOnly !== prevProps.readOnly && this.editor) {
|
||||
this.editor.setOption('readOnly', this.props.readOnly);
|
||||
}
|
||||
|
||||
this.ignoreChangeEvent = false;
|
||||
}
|
||||
|
||||
@@ -246,11 +258,6 @@ export default class CodeEditor extends React.Component {
|
||||
this.editor.off('scroll', this.onScroll);
|
||||
this.editor = null;
|
||||
}
|
||||
|
||||
this._unbindSearchHandler();
|
||||
if (this.brunoAutoCompleteCleanup) {
|
||||
this.brunoAutoCompleteCleanup();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
@@ -259,14 +266,22 @@ export default class CodeEditor extends React.Component {
|
||||
}
|
||||
return (
|
||||
<StyledWrapper
|
||||
className="h-full w-full flex flex-col relative graphiql-container"
|
||||
className={`h-full w-full flex flex-col relative graphiql-container ${this.props.readOnly ? 'read-only' : ''}`}
|
||||
aria-label="Code Editor"
|
||||
font={this.props.font}
|
||||
fontSize={this.props.fontSize}
|
||||
ref={(node) => {
|
||||
this._node = node;
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<CodeMirrorSearch
|
||||
visible={this.state.searchBarVisible}
|
||||
editor={this.editor}
|
||||
onClose={() => this.setState({ searchBarVisible: false })}
|
||||
/>
|
||||
<div
|
||||
className={`editor-container${this.state.searchBarVisible ? ' search-bar-visible' : ''}`}
|
||||
ref={(node) => { this._node = node; }}
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
/>
|
||||
</StyledWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -290,67 +305,4 @@ export default class CodeEditor extends React.Component {
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_isSearchOpen = () => {
|
||||
return document.querySelector('.CodeMirror-dialog.CodeMirror-dialog-top');
|
||||
};
|
||||
|
||||
/**
|
||||
* Bind handler to search input to count number of search results
|
||||
*/
|
||||
_bindSearchHandler = () => {
|
||||
const searchInput = document.querySelector('.CodeMirror-search-field');
|
||||
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('input', this._countSearchResults);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Unbind handler to search input to count number of search results
|
||||
*/
|
||||
_unbindSearchHandler = () => {
|
||||
const searchInput = document.querySelector('.CodeMirror-search-field');
|
||||
|
||||
if (searchInput) {
|
||||
searchInput.removeEventListener('input', this._countSearchResults);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Append search results count to search dialog
|
||||
*/
|
||||
_appendSearchResultsCount = () => {
|
||||
const dialog = document.querySelector('.CodeMirror-dialog.CodeMirror-dialog-top');
|
||||
|
||||
if (dialog) {
|
||||
const searchResultsCount = document.createElement('span');
|
||||
searchResultsCount.id = this.searchResultsCountElementId;
|
||||
dialog.appendChild(searchResultsCount);
|
||||
|
||||
this._countSearchResults();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Count search results and update state
|
||||
*/
|
||||
_countSearchResults = () => {
|
||||
let count = 0;
|
||||
|
||||
const searchInput = document.querySelector('.CodeMirror-search-field');
|
||||
|
||||
if (searchInput && searchInput.value.length > 0) {
|
||||
// Escape special characters in search input to prevent RegExp crashes. Fixes #3051
|
||||
const text = new RegExp(escapeRegExp(searchInput.value), 'gi');
|
||||
const matches = this.editor.getValue().match(text);
|
||||
count = matches ? matches.length : 0;
|
||||
}
|
||||
|
||||
const searchResultsCountElement = document.querySelector(`#${this.searchResultsCountElementId}`);
|
||||
|
||||
if (searchResultsCountElement) {
|
||||
searchResultsCountElement.innerText = `${count} results`;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.bruno-search-bar {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
padding: 0 2px;
|
||||
min-height: 36px;
|
||||
background: ${(props) => props.theme.sidebar.search.bg} !important;
|
||||
border-radius: 4px;
|
||||
border: 1px solid ${(props) => props.theme.sidebar.search.bg} !important;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
||||
width: auto;
|
||||
min-width: 180px;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.bruno-search-bar input {
|
||||
min-width: 80px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
border: none;
|
||||
outline: none;
|
||||
padding: 1px 2px;
|
||||
font-size: 13px;
|
||||
margin: 0 1px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.searchbar-icon-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0 1px;
|
||||
margin: 0 1px;
|
||||
cursor: pointer;
|
||||
color: #aaa;
|
||||
border-radius: 3px;
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.searchbar-result-count {
|
||||
min-width: 28px;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
color: #aaa;
|
||||
margin: 0 8px 0 1px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bruno-search-bar.compact {
|
||||
background: ${(props) => props.theme.codemirror.bg};
|
||||
color: ${(props) => props.theme.codemirror.text || props.theme.text};
|
||||
border: none;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
||||
border-radius: 4px;
|
||||
padding: 1px 3px;
|
||||
min-height: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.bruno-search-bar input {
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: 13px;
|
||||
padding: 1px 2px;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.searchbar-icon-btn:focus {
|
||||
outline: 1px solid ${(props) => props.theme.codemirror.border};
|
||||
}
|
||||
|
||||
.bruno-search-bar, .bruno-search-bar input {
|
||||
font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif !important;
|
||||
}
|
||||
|
||||
.cm-search-line-highlight {
|
||||
background: ${(props) => props.theme.codemirror.searchLineHighlightCurrent};
|
||||
}
|
||||
|
||||
.searchbar-icon-btn.active {
|
||||
color: #f39c12 !important;
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
201
packages/bruno-app/src/components/CodeMirrorSearch/index.js
Normal file
201
packages/bruno-app/src/components/CodeMirrorSearch/index.js
Normal file
@@ -0,0 +1,201 @@
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { IconRegex, IconArrowUp, IconArrowDown, IconX, IconLetterCase, IconLetterW } from '@tabler/icons';
|
||||
import ToolHint from 'components/ToolHint';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import useDebounce from 'hooks/useDebounce';
|
||||
|
||||
function escapeRegExp(string) {
|
||||
return string.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');
|
||||
}
|
||||
|
||||
const CodeMirrorSearch = ({ visible, editor, onClose }) => {
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [regex, setRegex] = useState(false);
|
||||
const [caseSensitive, setCaseSensitive] = useState(false);
|
||||
const [wholeWord, setWholeWord] = useState(false);
|
||||
const [matchIndex, setMatchIndex] = useState(0);
|
||||
const [matchCount, setMatchCount] = useState(0);
|
||||
|
||||
const searchMarks = useRef([]);
|
||||
const searchLineHighlight = useRef(null);
|
||||
const searchMatches = useRef([]);
|
||||
|
||||
const debouncedSearchText = useDebounce(searchText, 150);
|
||||
|
||||
const memoizedMatches = useMemo(() => {
|
||||
if (!editor || !visible) return [];
|
||||
if (!debouncedSearchText) return [];
|
||||
|
||||
try {
|
||||
let query, options = {};
|
||||
if (regex) {
|
||||
try {
|
||||
query = new RegExp(debouncedSearchText, caseSensitive ? 'g' : 'gi');
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
} else if (wholeWord) {
|
||||
const escaped = escapeRegExp(debouncedSearchText);
|
||||
query = new RegExp(`\\b${escaped}\\b`, caseSensitive ? 'g' : 'gi');
|
||||
} else {
|
||||
query = debouncedSearchText;
|
||||
options = { caseFold: !caseSensitive };
|
||||
}
|
||||
|
||||
const cursor = editor.getSearchCursor(query, { line: 0, ch: 0 }, options);
|
||||
const out = [];
|
||||
while (cursor.findNext()) {
|
||||
out.push({ from: cursor.from(), to: cursor.to() });
|
||||
}
|
||||
return out;
|
||||
} catch (e) {
|
||||
console.error('Search error:', e);
|
||||
return [];
|
||||
}
|
||||
}, [editor, visible, debouncedSearchText, regex, caseSensitive, wholeWord]);
|
||||
|
||||
const doSearch = useCallback((newIndex = 0) => {
|
||||
if (!editor) return;
|
||||
|
||||
// Clear previous marks
|
||||
searchMarks.current.forEach((mark) => mark.clear());
|
||||
searchMarks.current = [];
|
||||
// Clear previous line highlight
|
||||
if (searchLineHighlight.current !== null) {
|
||||
editor.removeLineClass(searchLineHighlight.current, 'wrap', 'cm-search-line-highlight');
|
||||
searchLineHighlight.current = null;
|
||||
}
|
||||
|
||||
if (!debouncedSearchText) {
|
||||
setMatchCount(0);
|
||||
setMatchIndex(0);
|
||||
searchMatches.current = [];
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const matches = memoizedMatches;
|
||||
let matchIndex = matches.length ? Math.max(0, Math.min(newIndex, matches.length - 1)) : 0;
|
||||
matches.forEach((m, i) => {
|
||||
const mark = editor.markText(m.from, m.to, {
|
||||
className: i === matchIndex ? 'cm-search-current' : 'cm-search-match',
|
||||
clearOnEnter: true
|
||||
});
|
||||
searchMarks.current.push(mark);
|
||||
});
|
||||
|
||||
if (matches.length) {
|
||||
const currentLine = matches[matchIndex].from.line;
|
||||
editor.addLineClass(currentLine, 'wrap', 'cm-search-line-highlight');
|
||||
searchLineHighlight.current = currentLine;
|
||||
|
||||
editor.scrollIntoView(matches[matchIndex].from, 100);
|
||||
editor.setSelection(matches[matchIndex].from, matches[matchIndex].to);
|
||||
} else {
|
||||
searchLineHighlight.current = null;
|
||||
}
|
||||
|
||||
setMatchCount(matches.length);
|
||||
setMatchIndex(matchIndex);
|
||||
searchMatches.current = matches;
|
||||
} catch (e) {
|
||||
console.error('Search error:', e);
|
||||
setMatchCount(0);
|
||||
setMatchIndex(0);
|
||||
searchMatches.current = [];
|
||||
}
|
||||
}, [debouncedSearchText, regex, caseSensitive, wholeWord, editor, memoizedMatches]);
|
||||
|
||||
useEffect(() => {
|
||||
doSearch(0, debouncedSearchText);
|
||||
}, [debouncedSearchText, doSearch]);
|
||||
|
||||
const handleSearchBarClose = useCallback(() => {
|
||||
searchMarks.current.forEach((mark) => mark.clear());
|
||||
searchMarks.current = [];
|
||||
if (searchLineHighlight.current !== null && editor) {
|
||||
editor.removeLineClass(searchLineHighlight.current, 'wrap', 'cm-search-line-highlight');
|
||||
searchLineHighlight.current = null;
|
||||
}
|
||||
searchMatches.current = [];
|
||||
if (onClose) onClose();
|
||||
// Focus the editor after closing the search bar
|
||||
if (editor) {
|
||||
setTimeout(() => editor.focus(), 0);
|
||||
}
|
||||
}, [editor, onClose]);
|
||||
|
||||
const handleSearchTextChange = (text) => {
|
||||
setSearchText(text);
|
||||
setMatchIndex(0);
|
||||
};
|
||||
|
||||
const handleToggleRegex = () => {
|
||||
setRegex((prev) => !prev);
|
||||
setMatchIndex(0);
|
||||
doSearch(0);
|
||||
};
|
||||
|
||||
const handleToggleCase = () => {
|
||||
setCaseSensitive((prev) => !prev);
|
||||
setMatchIndex(0);
|
||||
doSearch(0);
|
||||
};
|
||||
|
||||
const handleToggleWholeWord = () => {
|
||||
setWholeWord((prev) => !prev);
|
||||
setMatchIndex(0);
|
||||
doSearch(0);
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
if (!searchMatches.current || !searchMatches.current.length) return;
|
||||
let next = (matchIndex + 1) % searchMatches.current.length;
|
||||
setMatchIndex(next);
|
||||
doSearch(next);
|
||||
};
|
||||
|
||||
const handlePrev = () => {
|
||||
if (!searchMatches.current || !searchMatches.current.length) return;
|
||||
let prev = (matchIndex - 1 + searchMatches.current.length) % searchMatches.current.length;
|
||||
setMatchIndex(prev);
|
||||
doSearch(prev);
|
||||
};
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="bruno-search-bar compact">
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={searchText}
|
||||
onChange={(e) => handleSearchTextChange(e.target.value)}
|
||||
placeholder="Search..."
|
||||
spellCheck={false}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) handleNext();
|
||||
if (e.key === 'Enter' && e.shiftKey) handlePrev();
|
||||
if (e.key === 'Escape') handleSearchBarClose();
|
||||
}}
|
||||
/>
|
||||
<span className="searchbar-result-count">{matchCount > 0 ? `${matchIndex + 1} / ${matchCount}` : '0 results'}</span>
|
||||
<ToolHint text="Regex search" toolhintId="searchbar-regex-toolhint" place="top">
|
||||
<button className={`searchbar-icon-btn ${regex ? 'active' : ''}`} onClick={handleToggleRegex}><IconRegex size={16} /></button>
|
||||
</ToolHint>
|
||||
<ToolHint text="Case sensitive" toolhintId="searchbar-case-toolhint" place="top">
|
||||
<button className={`searchbar-icon-btn ${caseSensitive ? 'active' : ''}`} onClick={handleToggleCase}><IconLetterCase size={14} /></button>
|
||||
</ToolHint>
|
||||
<ToolHint text="Whole word" toolhintId="searchbar-wholeword-toolhint" place="top">
|
||||
<button className={`searchbar-icon-btn ${wholeWord ? 'active' : ''}`} onClick={handleToggleWholeWord}><IconLetterW size={14} /></button>
|
||||
</ToolHint>
|
||||
<button className="searchbar-icon-btn" title="Previous" onClick={handlePrev}><IconArrowUp size={14} /></button>
|
||||
<button className="searchbar-icon-btn" title="Next" onClick={handleNext}><IconArrowDown size={14} /></button>
|
||||
<button className="searchbar-icon-btn" title="Close" onClick={handleSearchBarClose}><IconX size={14} /></button>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default CodeMirrorSearch;
|
||||
@@ -1,19 +1,20 @@
|
||||
import React from 'react';
|
||||
import { IconCertificate, IconTrash, IconWorld } from '@tabler/icons';
|
||||
import { useFormik } from 'formik';
|
||||
import { uuid } from 'utils/common';
|
||||
import * as Yup from 'yup';
|
||||
import { IconEye, IconEyeOff } from '@tabler/icons';
|
||||
import { useState } from 'react';
|
||||
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { useRef } from 'react';
|
||||
import path from 'utils/common/path';
|
||||
import SensitiveFieldWarning from 'components/SensitiveFieldWarning/index';
|
||||
import SingleLineEditor from 'components/SingleLineEditor/index';
|
||||
import { useDetectSensitiveField } from 'hooks/useDetectSensitiveField/index';
|
||||
import { useTheme } from 'styled-components';
|
||||
|
||||
const ClientCertSettings = ({ root, clientCertConfig, onUpdate, onRemove }) => {
|
||||
const ClientCertSettings = ({ collection, clientCertConfig, onUpdate, onRemove }) => {
|
||||
const certFilePathInputRef = useRef();
|
||||
const keyFilePathInputRef = useRef();
|
||||
const pfxFilePathInputRef = useRef();
|
||||
const { storedTheme } = useTheme();
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
@@ -68,10 +69,13 @@ const ClientCertSettings = ({ root, clientCertConfig, onUpdate, onRemove }) => {
|
||||
}
|
||||
});
|
||||
|
||||
const { isSensitive } = useDetectSensitiveField(collection);
|
||||
const { showWarning, warningMessage } = isSensitive(formik.values.passphrase);
|
||||
|
||||
const getFile = (e) => {
|
||||
const filePath = window?.ipcRenderer?.getFilePath(e?.files?.[0]);
|
||||
if (filePath) {
|
||||
let relativePath = path.relative(root, filePath);
|
||||
let relativePath = path.relative(collection.pathname, filePath);
|
||||
formik.setFieldValue(e.name, relativePath);
|
||||
}
|
||||
};
|
||||
@@ -82,8 +86,6 @@ const ClientCertSettings = ({ root, clientCertConfig, onUpdate, onRemove }) => {
|
||||
pfxFilePathInputRef.current.value = '';
|
||||
};
|
||||
|
||||
const [passwordVisible, setPasswordVisible] = useState(false);
|
||||
|
||||
const handleTypeChange = (e) => {
|
||||
formik.setFieldValue('type', e.target.value);
|
||||
if (e.target.value === 'cert') {
|
||||
@@ -314,21 +316,14 @@ const ClientCertSettings = ({ root, clientCertConfig, onUpdate, onRemove }) => {
|
||||
Passphrase
|
||||
</label>
|
||||
<div className="textbox flex flex-row items-center w-[300px] h-[1.70rem] relative">
|
||||
<input
|
||||
id="passphrase"
|
||||
type={passwordVisible ? 'text' : 'password'}
|
||||
name="passphrase"
|
||||
className="outline-none w-64 bg-transparent"
|
||||
onChange={formik.handleChange}
|
||||
<SingleLineEditor
|
||||
value={formik.values.passphrase || ''}
|
||||
theme={storedTheme}
|
||||
onChange={(val) => formik.setFieldValue('passphrase', val)}
|
||||
collection={collection}
|
||||
isSecret={true}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm absolute right-0 l"
|
||||
onClick={() => setPasswordVisible(!passwordVisible)}
|
||||
>
|
||||
{passwordVisible ? <IconEyeOff size={18} strokeWidth={1.5} /> : <IconEye size={18} strokeWidth={1.5} />}
|
||||
</button>
|
||||
{showWarning && <SensitiveFieldWarning fieldName="basic-password" warningMessage={warningMessage} />}
|
||||
</div>
|
||||
{formik.touched.passphrase && formik.errors.passphrase ? (
|
||||
<div className="ml-1 text-red-500">{formik.errors.passphrase}</div>
|
||||
|
||||
@@ -1,263 +0,0 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { useFormik } from 'formik';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import toast from 'react-hot-toast';
|
||||
import { updateBrunoConfig } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import { IconTrash, IconFile, IconFileImport, IconAlertCircle } from '@tabler/icons';
|
||||
import { getRelativePath, getBasename, getDirPath } from 'utils/common/path';
|
||||
import { Tooltip } from 'react-tooltip';
|
||||
import { existsSync, resolvePath } from '../../../utils/filesystem';
|
||||
|
||||
const GrpcSettings = ({ collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const {
|
||||
brunoConfig: { grpc: grpcConfig = {} }
|
||||
} = collection;
|
||||
|
||||
const fileInputRef = useRef(null);
|
||||
const [protoFileValidity, setProtoFileValidity] = useState({});
|
||||
|
||||
const formik = useFormik({
|
||||
enableReinitialize: true,
|
||||
initialValues: {
|
||||
protoFiles: grpcConfig.protoFiles || []
|
||||
},
|
||||
onSubmit: (newGrpcConfig) => {
|
||||
const brunoConfig = cloneDeep(collection.brunoConfig);
|
||||
brunoConfig.grpc = newGrpcConfig;
|
||||
dispatch(updateBrunoConfig(brunoConfig, collection.uid));
|
||||
toast.success('gRPC settings updated');
|
||||
}
|
||||
});
|
||||
|
||||
// Get file path using the ipcRenderer
|
||||
const getProtoFile = (event) => {
|
||||
const files = event?.files;
|
||||
if (files && files.length > 0) {
|
||||
const newProtoFiles = [...formik.values.protoFiles];
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const filePath = window?.ipcRenderer?.getFilePath(files[i]);
|
||||
if (filePath) {
|
||||
const relativePath = getRelativePath(filePath, collection.pathname);
|
||||
const protoFileObj = {
|
||||
path: relativePath,
|
||||
type: 'file'
|
||||
};
|
||||
|
||||
// Check if this path already exists
|
||||
const exists = newProtoFiles.some(pf => pf.path === protoFileObj.path);
|
||||
if (!exists) {
|
||||
newProtoFiles.push(protoFileObj);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
formik.setFieldValue('protoFiles', newProtoFiles);
|
||||
// Reset the file input
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Handler for removing a proto file
|
||||
const handleRemoveProtoFile = (index) => {
|
||||
const updatedProtoFiles = [...formik.values.protoFiles];
|
||||
updatedProtoFiles.splice(index, 1);
|
||||
formik.setFieldValue('protoFiles', updatedProtoFiles);
|
||||
};
|
||||
|
||||
// Handle the browse button click
|
||||
const handleBrowseClick = () => {
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.click();
|
||||
}
|
||||
};
|
||||
|
||||
// Check if a proto file path is valid
|
||||
const isProtoFileValid = async (protoFile) => {
|
||||
try {
|
||||
const absolutePath = await resolvePath(protoFile.path, collection.pathname);
|
||||
return await existsSync(absolutePath);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Validate all proto files and update state
|
||||
useEffect(() => {
|
||||
const validateProtoFiles = async () => {
|
||||
const validityMap = {};
|
||||
for (const file of formik.values.protoFiles) {
|
||||
validityMap[file.path] = await isProtoFileValid(file);
|
||||
}
|
||||
setProtoFileValidity(validityMap);
|
||||
};
|
||||
|
||||
validateProtoFiles();
|
||||
}, [formik.values.protoFiles, collection.pathname]);
|
||||
|
||||
// Handle replacing an invalid proto file
|
||||
const handleReplaceProtoFile = (index) => {
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.click();
|
||||
// Store the index to replace after file selection
|
||||
fileInputRef.current.dataset.replaceIndex = index;
|
||||
}
|
||||
};
|
||||
|
||||
// Handle file input change
|
||||
const handleFileInputChange = (e) => {
|
||||
const replaceIndex = e.target.dataset.replaceIndex;
|
||||
if (replaceIndex !== undefined) {
|
||||
// Handle replacement
|
||||
const files = e.target.files;
|
||||
if (files && files.length > 0) {
|
||||
const filePath = window?.ipcRenderer?.getFilePath(files[0]);
|
||||
if (filePath) {
|
||||
const relativePath = getRelativePath(filePath, collection.pathname);
|
||||
const updatedProtoFiles = [...formik.values.protoFiles];
|
||||
updatedProtoFiles[replaceIndex] = {
|
||||
path: relativePath,
|
||||
type: 'file'
|
||||
};
|
||||
formik.setFieldValue('protoFiles', updatedProtoFiles);
|
||||
}
|
||||
}
|
||||
delete e.target.dataset.replaceIndex;
|
||||
} else {
|
||||
getProtoFile(e.target);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper className="h-full w-full">
|
||||
<form className="bruno-form" onSubmit={formik.handleSubmit}>
|
||||
<div className="mb-3">
|
||||
<label className="font-semibold text-sm mb-3 flex items-center" htmlFor="protoFiles">
|
||||
Add Proto Files
|
||||
<span id="proto-files-tooltip" className="ml-2">
|
||||
<IconAlertCircle size={16} className="text-gray-500 cursor-pointer" />
|
||||
</span>
|
||||
<Tooltip
|
||||
anchorId="proto-files-tooltip"
|
||||
className="tooltip-mod font-normal"
|
||||
html="Keep your proto files within the collection folder or the corresponding git repository to ensure paths remain valid when sharing the collection."
|
||||
/>
|
||||
</label>
|
||||
<div className="flex flex-col">
|
||||
{/* Hidden file input for file selection */}
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
style={{ display: 'none' }}
|
||||
accept=".proto"
|
||||
multiple
|
||||
onChange={handleFileInputChange}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* File selection options */}
|
||||
<div className="flex flex-col space-y-3">
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-secondary flex items-center"
|
||||
onClick={handleBrowseClick}
|
||||
>
|
||||
<IconFileImport size={16} strokeWidth={1.5} className="mr-1" />
|
||||
Browse for proto files
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="border-t border-neutral-600 my-2"></div>
|
||||
|
||||
{/* List of added proto files */}
|
||||
<div>
|
||||
<div className="text-sm font-semibold mb-2 flex items-center">
|
||||
<IconFile size={16} strokeWidth={1.5} className="mr-1" />
|
||||
Added Proto Files ({formik.values.protoFiles.length})
|
||||
</div>
|
||||
|
||||
{formik.values.protoFiles.length === 0 ? (
|
||||
<div className="text-neutral-500 text-sm italic">No proto files added yet</div>
|
||||
) : (
|
||||
<>
|
||||
{formik.values.protoFiles.some(file => !protoFileValidity[file.path]) && (
|
||||
<div className="text-xs text-red-500 mb-2 flex items-center bg-red-50 dark:bg-red-900/20 p-2 rounded">
|
||||
<IconAlertCircle size={14} className="mr-1" />
|
||||
Some proto files cannot be found at their specified paths. Use the "Replace" option to update their locations.
|
||||
</div>
|
||||
)}
|
||||
<ul className="mt-4">
|
||||
{formik.values.protoFiles.map((file, index) => {
|
||||
const isValid = protoFileValidity[file.path];
|
||||
return (
|
||||
<li key={index} className="flex items-center available-certificates p-2 rounded-lg mb-2">
|
||||
<div className="flex items-center w-full justify-between">
|
||||
<div className="flex w-full items-center">
|
||||
<IconFile className="mr-2" size={18} strokeWidth={1.5} />
|
||||
<div
|
||||
className="overflow-hidden text-ellipsis whitespace-nowrap max-w-[300px] text-sm"
|
||||
title={file.path}
|
||||
>
|
||||
{getBasename(file.path)}
|
||||
<span className="text-xs text-neutral-500 ml-2">
|
||||
{getDirPath(file.path)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full items-center justify-end">
|
||||
{!isValid && (
|
||||
<div className="flex items-center mr-2">
|
||||
<IconAlertCircle
|
||||
size={16}
|
||||
className="text-red-500"
|
||||
title="Proto file not found. Click to replace."
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-red-500 ml-1 hover:underline"
|
||||
onClick={() => handleReplaceProtoFile(index)}
|
||||
>
|
||||
Replace
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="remove-certificate ml-2"
|
||||
onClick={() => handleRemoveProtoFile(index)}
|
||||
title="Remove file"
|
||||
>
|
||||
<IconTrash size={18} strokeWidth={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<button type="submit" className="submit btn btn-sm btn-secondary">
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default GrpcSettings;
|
||||
@@ -5,11 +5,9 @@ import StyledWrapper from './StyledWrapper';
|
||||
import toast from 'react-hot-toast';
|
||||
import { updateBrunoConfig } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import { useBetaFeature, BETA_FEATURES } from 'utils/beta-features';
|
||||
|
||||
const PresetsSettings = ({ collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const isGrpcEnabled = useBetaFeature(BETA_FEATURES.GRPC);
|
||||
const {
|
||||
brunoConfig: { presets: presets = {} }
|
||||
} = collection;
|
||||
@@ -17,15 +15,10 @@ const PresetsSettings = ({ collection }) => {
|
||||
const formik = useFormik({
|
||||
enableReinitialize: true,
|
||||
initialValues: {
|
||||
requestType: presets.requestType === 'grpc' && !isGrpcEnabled ? 'http' : presets.requestType || 'http',
|
||||
requestType: presets.requestType || 'http',
|
||||
requestUrl: presets.requestUrl || ''
|
||||
},
|
||||
onSubmit: (newPresets) => {
|
||||
// If gRPC is disabled but the preset is set to grpc, change it to http
|
||||
if (!isGrpcEnabled && newPresets.requestType === 'grpc') {
|
||||
newPresets.requestType = 'http';
|
||||
}
|
||||
|
||||
const brunoConfig = cloneDeep(collection.brunoConfig);
|
||||
brunoConfig.presets = newPresets;
|
||||
dispatch(updateBrunoConfig(brunoConfig, collection.uid));
|
||||
@@ -70,22 +63,18 @@ const PresetsSettings = ({ collection }) => {
|
||||
GraphQL
|
||||
</label>
|
||||
|
||||
{isGrpcEnabled && (
|
||||
<>
|
||||
<input
|
||||
id="grpc"
|
||||
className="ml-4 cursor-pointer"
|
||||
type="radio"
|
||||
name="requestType"
|
||||
onChange={formik.handleChange}
|
||||
value="grpc"
|
||||
checked={formik.values.requestType === 'grpc'}
|
||||
/>
|
||||
<label htmlFor="grpc" className="ml-1 cursor-pointer select-none">
|
||||
gRPC
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
<input
|
||||
id="grpc"
|
||||
className="ml-4 cursor-pointer"
|
||||
type="radio"
|
||||
name="requestType"
|
||||
onChange={formik.handleChange}
|
||||
value="grpc"
|
||||
checked={formik.values.requestType === 'grpc'}
|
||||
/>
|
||||
<label htmlFor="grpc" className="ml-1 cursor-pointer select-none">
|
||||
gRPC
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-3 flex items-center">
|
||||
|
||||
@@ -10,4 +10,4 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,336 @@
|
||||
import React, { useRef } from 'react';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import {
|
||||
IconTrash,
|
||||
IconFile,
|
||||
IconFileImport,
|
||||
IconAlertCircle,
|
||||
IconFolder
|
||||
} from '@tabler/icons';
|
||||
import { getBasename } from 'utils/common/path';
|
||||
import { Tooltip } from 'react-tooltip';
|
||||
import useProtoFileManagement from '../../../hooks/useProtoFileManagement';
|
||||
|
||||
const ProtobufSettings = ({ collection }) => {
|
||||
const {
|
||||
protoFiles,
|
||||
importPaths,
|
||||
addProtoFileToCollection,
|
||||
addImportPathToCollection,
|
||||
toggleImportPath,
|
||||
browseForProtoFile,
|
||||
browseForImportDirectory,
|
||||
removeProtoFileFromCollection,
|
||||
removeImportPathFromCollection,
|
||||
replaceImportPathInCollection,
|
||||
replaceProtoFileInCollection
|
||||
} = useProtoFileManagement(collection);
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
// Get file path using the ipcRenderer
|
||||
const getProtoFile = async (event) => {
|
||||
const files = event?.files;
|
||||
if (files && files.length > 0) {
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const filePath = window?.ipcRenderer?.getFilePath(files[i]);
|
||||
if (filePath) {
|
||||
await addProtoFileToCollection(filePath);
|
||||
}
|
||||
}
|
||||
// Reset the file input
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveProtoFile = async (index) => {
|
||||
await removeProtoFileFromCollection(index);
|
||||
};
|
||||
|
||||
const handleBrowseClick = () => {
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.click();
|
||||
}
|
||||
};
|
||||
|
||||
const handleReplaceProtoFile = async (index) => {
|
||||
const result = await browseForProtoFile();
|
||||
if (result.success) {
|
||||
await replaceProtoFileInCollection(index, result.filePath);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReplaceImportPath = async (index) => {
|
||||
const result = await browseForImportDirectory();
|
||||
if (result.success) {
|
||||
await replaceImportPathInCollection(index, result.directoryPath);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileInputChange = (e) => {
|
||||
getProtoFile(e.target);
|
||||
};
|
||||
|
||||
const getImportPath = async () => {
|
||||
const result = await browseForImportDirectory();
|
||||
if (result.success) {
|
||||
await addImportPathToCollection(result.directoryPath);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveImportPath = async (index) => {
|
||||
await removeImportPathFromCollection(index);
|
||||
};
|
||||
|
||||
const handleToggleImportPath = async (index) => {
|
||||
await toggleImportPath(index);
|
||||
};
|
||||
|
||||
const handleBrowseImportPathClick = () => {
|
||||
getImportPath();
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper className="h-full w-full">
|
||||
{/* Hidden file input for file selection */}
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
style={{ display: 'none' }}
|
||||
accept=".proto"
|
||||
multiple
|
||||
onChange={handleFileInputChange}
|
||||
/>
|
||||
|
||||
{/* Proto Files Section */}
|
||||
<div className="mb-6" data-testid="protobuf-proto-files-section">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center">
|
||||
<label className="font-semibold text-sm flex items-center" htmlFor="protoFiles">
|
||||
Proto Files (
|
||||
{protoFiles.length}
|
||||
)
|
||||
<span id="proto-files-tooltip" className="ml-2">
|
||||
<IconAlertCircle size={16} className="text-gray-500 cursor-pointer" />
|
||||
</span>
|
||||
<Tooltip
|
||||
anchorId="proto-files-tooltip"
|
||||
className="tooltip-mod font-normal"
|
||||
html="Keep your proto files within the collection folder or the corresponding git repository to ensure paths remain valid when sharing the collection."
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{protoFiles.some((file) => !file.exists) && (
|
||||
<div className="text-xs text-red-600 dark:text-red-400 mb-2 flex items-center p-2 rounded" data-testid="protobuf-invalid-files-message">
|
||||
<IconAlertCircle size={14} className="mr-1" />
|
||||
Some proto files cannot be found. Use the replace option to update their locations.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<table className="w-full border-collapse" data-testid="protobuf-proto-files-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider border border-gray-200 dark:border-gray-700 px-3 py-2">
|
||||
File
|
||||
</th>
|
||||
<th className="text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider border border-gray-200 dark:border-gray-700 px-3 py-2">
|
||||
Path
|
||||
</th>
|
||||
<th className="text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider border border-gray-200 dark:border-gray-700 px-3 py-2">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{protoFiles.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan="3" className="border border-gray-200 dark:border-gray-700 px-3 py-8 text-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<IconFile size={24} className="text-gray-400 mb-2" />
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">No proto files added</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
protoFiles.map((file, index) => {
|
||||
const isValid = file.exists;
|
||||
|
||||
return (
|
||||
<tr key={index}>
|
||||
<td className="border border-gray-200 dark:border-gray-700 px-3 py-2">
|
||||
<div className="flex items-center">
|
||||
<IconFile size={16} className="text-gray-500 dark:text-gray-400 mr-2" />
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{getBasename(collection.pathname, file.path)}
|
||||
</span>
|
||||
{!isValid && <IconAlertCircle size={12} className="text-red-600 dark:text-red-400 ml-2" />}
|
||||
</div>
|
||||
</td>
|
||||
<td className="border border-gray-200 dark:border-gray-700 px-3 py-2">
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400 font-mono">
|
||||
{file.path}
|
||||
</div>
|
||||
</td>
|
||||
<td className="border border-gray-200 dark:border-gray-700 px-3 py-2 text-right">
|
||||
<div className="flex items-center justify-end space-x-1">
|
||||
{!isValid && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleReplaceProtoFile(index)}
|
||||
className="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300 p-1 rounded"
|
||||
title="Replace file"
|
||||
>
|
||||
<IconFileImport size={14} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveProtoFile(index)}
|
||||
className="text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-300 p-1 rounded"
|
||||
title="Remove file"
|
||||
data-testid="protobuf-remove-file-button"
|
||||
>
|
||||
<IconTrash size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
<button type="button" className="btn-add-param text-link pr-2 py-3 mt-2 select-none" onClick={handleBrowseClick} data-testid="protobuf-add-file-button">
|
||||
+ Add Proto File
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Import Paths Section */}
|
||||
<div className="mb-6" data-testid="protobuf-import-paths-section">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center">
|
||||
<label className="font-semibold text-sm flex items-center" htmlFor="importPaths">
|
||||
Import Paths (
|
||||
{importPaths.length}
|
||||
)
|
||||
<span id="import-paths-tooltip" className="ml-2">
|
||||
<IconAlertCircle size={16} className="text-gray-500 cursor-pointer" />
|
||||
</span>
|
||||
<Tooltip
|
||||
anchorId="import-paths-tooltip"
|
||||
className="tooltip-mod font-normal"
|
||||
html="Add directories that contain proto files to be imported. These paths help resolve import statements in your proto files."
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{importPaths.some((path) => !path.exists) && (
|
||||
<div className="text-xs text-red-600 dark:text-red-400 mb-2 flex items-center p-2 rounded" data-testid="protobuf-invalid-import-paths-message">
|
||||
<IconAlertCircle size={14} className="mr-1" />
|
||||
Some import paths cannot be found at their specified locations.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<table className="w-full border-collapse" data-testid="protobuf-import-paths-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider border border-gray-200 dark:border-gray-700 px-3 py-2">
|
||||
</th>
|
||||
<th className="text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider border border-gray-200 dark:border-gray-700 px-3 py-2">
|
||||
Directory
|
||||
</th>
|
||||
<th className="text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider border border-gray-200 dark:border-gray-700 px-3 py-2">
|
||||
Path
|
||||
</th>
|
||||
<th className="text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider border border-gray-200 dark:border-gray-700 px-3 py-2">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{importPaths.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan="4" className="border border-gray-200 dark:border-gray-700 px-3 py-8 text-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<IconFolder size={24} className="text-gray-400 mb-2" />
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">No import paths added</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
importPaths.map((importPath, index) => {
|
||||
const isValid = importPath.exists;
|
||||
|
||||
return (
|
||||
<tr key={index}>
|
||||
<td className="border border-gray-200 dark:border-gray-700 px-3 py-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={importPath.enabled}
|
||||
onChange={() => handleToggleImportPath(index)}
|
||||
className="h-4 w-4 text-gray-600 focus:ring-gray-500 border-gray-300 dark:border-gray-600 rounded"
|
||||
title={importPath.enabled ? 'Disable this import path' : 'Enable this import path'}
|
||||
data-testid="protobuf-import-path-checkbox"
|
||||
/>
|
||||
</td>
|
||||
<td className="border border-gray-200 dark:border-gray-700 px-3 py-2">
|
||||
<div className="flex items-center">
|
||||
<IconFolder size={16} className="text-gray-500 dark:text-gray-400 mr-2" />
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{getBasename(collection.pathname, importPath.path)}
|
||||
</span>
|
||||
{!isValid && <IconAlertCircle size={12} className="text-red-600 dark:text-red-400 ml-2" />}
|
||||
</div>
|
||||
</td>
|
||||
<td className="border border-gray-200 dark:border-gray-700 px-3 py-2">
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400 font-mono">
|
||||
{importPath.path}
|
||||
</div>
|
||||
</td>
|
||||
<td className="border border-gray-200 dark:border-gray-700 px-3 py-2 text-right">
|
||||
<div className="flex items-center justify-end space-x-1">
|
||||
{!isValid && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleReplaceImportPath(index)}
|
||||
className="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300 p-1 rounded"
|
||||
title="Replace directory"
|
||||
>
|
||||
<IconFileImport size={14} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveImportPath(index)}
|
||||
className="text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-300 p-1 rounded"
|
||||
title="Remove import path"
|
||||
data-testid="protobuf-remove-import-path-button"
|
||||
>
|
||||
<IconTrash size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
<button type="button" className="btn-add-param text-link pr-2 py-3 mt-2 select-none" onClick={handleBrowseImportPathClick} data-testid="protobuf-add-import-path-button">
|
||||
+ Add Import Path
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProtobufSettings;
|
||||
@@ -13,16 +13,14 @@ import Auth from './Auth';
|
||||
import Script from './Script';
|
||||
import Test from './Tests';
|
||||
import Presets from './Presets';
|
||||
import Grpc from './Grpc';
|
||||
import Protobuf from './Protobuf';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import Vars from './Vars/index';
|
||||
import StatusDot from 'components/StatusDot';
|
||||
import Overview from './Overview/index';
|
||||
import { useBetaFeature, BETA_FEATURES } from 'utils/beta-features';
|
||||
|
||||
const CollectionSettings = ({ collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const isGrpcEnabled = useBetaFeature(BETA_FEATURES.GRPC);
|
||||
const tab = collection.settingsSelectedTab;
|
||||
const setTab = (tab) => {
|
||||
dispatch(
|
||||
@@ -46,9 +44,13 @@ const CollectionSettings = ({ collection }) => {
|
||||
const activeVarsCount = requestVars.filter((v) => v.enabled).length + responseVars.filter((v) => v.enabled).length;
|
||||
const authMode = get(collection, 'root.request.auth', {}).mode || 'none';
|
||||
|
||||
const presets = get(collection, 'brunoConfig.presets', []);
|
||||
const hasPresets = presets && presets.requestUrl !== '';
|
||||
|
||||
const proxyConfig = get(collection, 'brunoConfig.proxy', {});
|
||||
const proxyEnabled = proxyConfig.hostname ? true : false;
|
||||
const clientCertConfig = get(collection, 'brunoConfig.clientCertificates.certs', []);
|
||||
const grpcConfig = get(collection, 'brunoConfig.grpc', {});
|
||||
const protobufConfig = get(collection, 'brunoConfig.protobuf', {});
|
||||
|
||||
const onProxySettingsUpdate = (config) => {
|
||||
const brunoConfig = cloneDeep(collection.brunoConfig);
|
||||
@@ -118,15 +120,15 @@ const CollectionSettings = ({ collection }) => {
|
||||
case 'clientCert': {
|
||||
return (
|
||||
<ClientCertSettings
|
||||
root={collection.pathname}
|
||||
collection={collection}
|
||||
clientCertConfig={clientCertConfig}
|
||||
onUpdate={onClientCertSettingsUpdate}
|
||||
onRemove={onClientCertSettingsRemove}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case 'grpc': {
|
||||
return <Grpc collection={collection} />;
|
||||
case 'protobuf': {
|
||||
return <Protobuf collection={collection} />;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -165,21 +167,20 @@ const CollectionSettings = ({ collection }) => {
|
||||
</div>
|
||||
<div className={getTabClassname('presets')} role="tab" onClick={() => setTab('presets')}>
|
||||
Presets
|
||||
{hasPresets && <StatusDot />}
|
||||
</div>
|
||||
<div className={getTabClassname('proxy')} role="tab" onClick={() => setTab('proxy')}>
|
||||
Proxy
|
||||
{Object.keys(proxyConfig).length > 0 && <StatusDot />}
|
||||
{Object.keys(proxyConfig).length > 0 && proxyEnabled && <StatusDot />}
|
||||
</div>
|
||||
<div className={getTabClassname('clientCert')} role="tab" onClick={() => setTab('clientCert')}>
|
||||
Client Certificates
|
||||
{clientCertConfig.length > 0 && <StatusDot />}
|
||||
</div>
|
||||
{isGrpcEnabled && (
|
||||
<div className={getTabClassname('grpc')} role="tab" onClick={() => setTab('grpc')}>
|
||||
gRPC
|
||||
{grpcConfig.protoFiles && grpcConfig.protoFiles.length > 0 && <StatusDot />}
|
||||
</div>
|
||||
)}
|
||||
<div className={getTabClassname('protobuf')} role="tab" onClick={() => setTab('protobuf')}>
|
||||
Protobuf
|
||||
{protobufConfig.protoFiles && protobufConfig.protoFiles.length > 0 && <StatusDot />}
|
||||
</div>
|
||||
</div>
|
||||
<section className="mt-4 h-full overflow-auto">{getTabPanel(tab)}</section>
|
||||
</StyledWrapper>
|
||||
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
IconCode,
|
||||
IconChevronDown,
|
||||
IconTerminal2,
|
||||
IconNetwork
|
||||
IconNetwork,
|
||||
IconDashboard
|
||||
} from '@tabler/icons';
|
||||
import {
|
||||
closeConsole,
|
||||
@@ -24,10 +25,12 @@ import {
|
||||
updateNetworkFilter,
|
||||
toggleAllNetworkFilters
|
||||
} from 'providers/ReduxStore/slices/logs';
|
||||
|
||||
import NetworkTab from './NetworkTab';
|
||||
import RequestDetailsPanel from './RequestDetailsPanel';
|
||||
// import DebugTab from './DebugTab';
|
||||
import ErrorDetailsPanel from './ErrorDetailsPanel';
|
||||
import Performance from '../Performance';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const LogIcon = ({ type }) => {
|
||||
@@ -384,6 +387,8 @@ const Console = () => {
|
||||
);
|
||||
case 'network':
|
||||
return <NetworkTab />;
|
||||
case 'performance':
|
||||
return <Performance />;
|
||||
// case 'debug':
|
||||
// return <DebugTab />;
|
||||
default:
|
||||
@@ -484,6 +489,14 @@ const Console = () => {
|
||||
<span>Network</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`console-tab ${activeTab === 'performance' ? 'active' : ''}`}
|
||||
onClick={() => handleTabChange('performance')}
|
||||
>
|
||||
<IconDashboard size={16} strokeWidth={1.5} />
|
||||
<span>Performance</span>
|
||||
</button>
|
||||
|
||||
{/* <button
|
||||
className={`console-tab ${activeTab === 'debug' ? 'active' : ''}`}
|
||||
onClick={() => handleTabChange('debug')}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.tab-content {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: ${(props) => props.theme.console.bg};
|
||||
}
|
||||
|
||||
.tab-content-area {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.overview-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.overview-section {
|
||||
margin-bottom: 32px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.section-header {
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid ${(props) => props.theme.console.border};
|
||||
|
||||
h3 {
|
||||
margin: 0 0 4px 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: ${(props) => props.theme.console.titleColor};
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: ${(props) => props.theme.console.textMuted};
|
||||
}
|
||||
}
|
||||
|
||||
.system-resources {
|
||||
margin-bottom: 16px;
|
||||
|
||||
h2 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: ${(props) => props.theme.console.titleColor};
|
||||
}
|
||||
}
|
||||
|
||||
.resource-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.resource-card {
|
||||
background: ${(props) => props.theme.console.headerBg};
|
||||
border: 1px solid ${(props) => props.theme.console.border};
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.resource-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 6px;
|
||||
color: ${(props) => props.theme.console.titleColor};
|
||||
}
|
||||
|
||||
.resource-title {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.resource-value {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: ${(props) => props.theme.console.titleColor};
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.resource-subtitle {
|
||||
font-size: 11px;
|
||||
color: ${(props) => props.theme.console.buttonColor};
|
||||
}
|
||||
|
||||
.resource-trend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
margin-top: 8px;
|
||||
|
||||
&.up {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
&.down {
|
||||
color: #e81123;
|
||||
}
|
||||
|
||||
&.stable {
|
||||
color: ${(props) => props.theme.console.buttonColor};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
131
packages/bruno-app/src/components/Devtools/Performance/index.js
Normal file
131
packages/bruno-app/src/components/Devtools/Performance/index.js
Normal file
@@ -0,0 +1,131 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import {
|
||||
IconCpu,
|
||||
IconDatabase,
|
||||
IconClock,
|
||||
IconServer,
|
||||
IconChartLine
|
||||
} from '@tabler/icons';
|
||||
|
||||
const Performance = () => {
|
||||
const { systemResources } = useSelector((state) => state.performance);
|
||||
|
||||
useEffect(() => {
|
||||
const { ipcRenderer } = window;
|
||||
|
||||
if (!ipcRenderer) {
|
||||
console.warn('IPC Renderer not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const startMonitoring = async () => {
|
||||
try {
|
||||
await ipcRenderer.invoke('renderer:start-system-monitoring', 2000);
|
||||
} catch (error) {
|
||||
console.error('Failed to start system monitoring:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const stopMonitoring = async () => {
|
||||
try {
|
||||
await ipcRenderer.invoke('renderer:stop-system-monitoring');
|
||||
} catch (error) {
|
||||
console.error('Failed to stop system monitoring:', error);
|
||||
}
|
||||
};
|
||||
|
||||
startMonitoring();
|
||||
|
||||
return () => {
|
||||
stopMonitoring();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const formatBytes = (bytes) => {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
const formatUptime = (seconds) => {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
|
||||
if (hours > 0) return `${hours}h ${minutes}m ${secs}s`;
|
||||
if (minutes > 0) return `${minutes}m ${secs}s`;
|
||||
return `${secs}s`;
|
||||
};
|
||||
|
||||
const SystemResourceCard = ({ icon: Icon, title, value, subtitle, color = 'default', trend }) => (
|
||||
<div className={`resource-card ${color}`}>
|
||||
<div className="resource-header">
|
||||
<Icon size={20} strokeWidth={1.5} />
|
||||
<span className="resource-title">{title}</span>
|
||||
</div>
|
||||
<div className="resource-value">{value}</div>
|
||||
{subtitle && <div className="resource-subtitle">{subtitle}</div>}
|
||||
{trend && (
|
||||
<div className={`resource-trend ${trend > 0 ? 'up' : trend < 0 ? 'down' : 'stable'}`}>
|
||||
<IconChartLine size={12} strokeWidth={1.5} />
|
||||
<span>
|
||||
{trend > 0 ? '+' : ''}
|
||||
{trend.toFixed(1)}
|
||||
%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="tab-content">
|
||||
<div className="tab-content-area">
|
||||
<div className="system-resources">
|
||||
<h2>System Resources</h2>
|
||||
<div className="resource-cards">
|
||||
<SystemResourceCard
|
||||
icon={IconCpu}
|
||||
title="CPU Usage"
|
||||
value={`${systemResources.cpu.toFixed(1)}%`}
|
||||
subtitle="Current process"
|
||||
color={systemResources.cpu > 80 ? 'danger' : systemResources.cpu > 60 ? 'warning' : 'success'}
|
||||
/>
|
||||
|
||||
<SystemResourceCard
|
||||
icon={IconDatabase}
|
||||
title="Memory Usage"
|
||||
value={formatBytes(systemResources.memory)}
|
||||
subtitle="Current process"
|
||||
color={systemResources.memory > 500 * 1024 * 1024 ? 'danger' : 'default'}
|
||||
/>
|
||||
|
||||
<SystemResourceCard
|
||||
icon={IconClock}
|
||||
title="Uptime"
|
||||
value={formatUptime(systemResources.uptime)}
|
||||
subtitle="Process runtime"
|
||||
color="info"
|
||||
/>
|
||||
|
||||
<SystemResourceCard
|
||||
icon={IconServer}
|
||||
title="Process ID"
|
||||
value={systemResources.pid || 'N/A'}
|
||||
subtitle="Current PID"
|
||||
color="default"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default Performance;
|
||||
@@ -2,7 +2,12 @@ import React from 'react';
|
||||
import Tippy from '@tippyjs/react';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const Dropdown = ({ icon, children, onCreate, placement, transparent, ...props }) => {
|
||||
const Dropdown = ({ icon, children, onCreate, placement, transparent, visible, ...props }) => {
|
||||
// When in controlled mode (visible prop is provided), don't use trigger prop
|
||||
const tippyProps = visible !== undefined
|
||||
? { ...props, visible, interactive: true, appendTo: 'parent' }
|
||||
: { ...props, trigger: 'click', interactive: true, appendTo: 'parent' };
|
||||
|
||||
return (
|
||||
<StyledWrapper className="dropdown" transparent={transparent}>
|
||||
<Tippy
|
||||
@@ -11,10 +16,7 @@ const Dropdown = ({ icon, children, onCreate, placement, transparent, ...props }
|
||||
animation={false}
|
||||
arrow={false}
|
||||
onCreate={onCreate}
|
||||
interactive={true}
|
||||
trigger="click"
|
||||
appendTo="parent"
|
||||
{...props}
|
||||
{...tippyProps}
|
||||
>
|
||||
{icon}
|
||||
</Tippy>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
/* Environment item styling */
|
||||
.environment-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
padding: 0.375rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
.environment-name {
|
||||
color: ${(props) => props.theme.text};
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,269 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import Portal from 'components/Portal/index';
|
||||
import Modal from 'components/Modal';
|
||||
import { exportBrunoEnvironment } from 'utils/exporters/bruno-environment';
|
||||
import { browseDirectory } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import toast from 'react-hot-toast';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const ExportEnvironmentModal = ({ onClose, environments = [], environmentType }) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// Helper function to truncate environment names
|
||||
const truncateEnvName = (name) => {
|
||||
if (name.length > 40) {
|
||||
return name.substring(0, 40) + '...';
|
||||
}
|
||||
return name;
|
||||
};
|
||||
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const [filePath, setFilePath] = useState('');
|
||||
const [selectedEnvironments, setSelectedEnvironments] = useState({});
|
||||
const [exportFormat, setExportFormat] = useState(environments.length > 1 ? 'single-file' : 'single-object');
|
||||
|
||||
// Initialize selected environments
|
||||
useEffect(() => {
|
||||
const initialSelection = {};
|
||||
|
||||
// Add all environments and select them by default
|
||||
environments.forEach((env) => {
|
||||
initialSelection[env.uid] = true;
|
||||
});
|
||||
|
||||
setSelectedEnvironments(initialSelection);
|
||||
}, [environments]);
|
||||
|
||||
useEffect(() => {
|
||||
const selectedCount = Object.values(selectedEnvironments).filter(Boolean).length;
|
||||
if (selectedCount <= 1) {
|
||||
setExportFormat('single-object');
|
||||
}
|
||||
if (exportFormat === 'single-object' && selectedCount > 1) {
|
||||
setExportFormat('single-file');
|
||||
}
|
||||
}, [selectedEnvironments]);
|
||||
|
||||
const browse = () => {
|
||||
dispatch(browseDirectory())
|
||||
.then((dirPath) => {
|
||||
if (typeof dirPath === 'string') {
|
||||
setFilePath(dirPath);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
setFilePath('');
|
||||
console.error(error);
|
||||
});
|
||||
};
|
||||
|
||||
const handleEnvironmentToggle = (envUid) => {
|
||||
setSelectedEnvironments((prev) => {
|
||||
const newSelection = {
|
||||
...prev,
|
||||
[envUid]: !prev[envUid]
|
||||
};
|
||||
return newSelection;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
const allSelected = environments.every((env) => selectedEnvironments[env.uid]) || false;
|
||||
|
||||
const newSelection = environments.reduce((acc, env) => ({
|
||||
...acc,
|
||||
[env.uid]: !allSelected
|
||||
}), {}) || {};
|
||||
|
||||
setSelectedEnvironments(newSelection);
|
||||
};
|
||||
|
||||
// Memoized selected environments and count
|
||||
const selectedEnvs = useMemo(() => {
|
||||
return environments.filter((env) => selectedEnvironments[env.uid]) || [];
|
||||
}, [environments, selectedEnvironments]);
|
||||
|
||||
const selectedCount = selectedEnvs.length;
|
||||
|
||||
const exportFormatOptions = useMemo(() => {
|
||||
const isMultiple = selectedCount > 1;
|
||||
|
||||
if (isMultiple) {
|
||||
return [
|
||||
{ value: 'single-file', label: 'Single JSON file', description: 'All environments in one JSON array' },
|
||||
{ value: 'folder', label: 'Separate files in folder', description: 'Each environment as a separate JSON file', disabled: false }
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{ value: 'single-object', label: 'Single JSON file', description: 'Export as a single environment JSON object' },
|
||||
{ value: 'folder', label: 'Separate files in folder', description: 'Each environment as a separate JSON file', disabled: true }
|
||||
];
|
||||
}, [selectedCount, exportFormat]);
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
setIsExporting(true);
|
||||
|
||||
if (!filePath) {
|
||||
toast.error('Please select a location to save the files');
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedCount === 0) {
|
||||
toast.error('Please select at least one environment to export');
|
||||
return;
|
||||
}
|
||||
|
||||
await exportBrunoEnvironment({ environments: selectedEnvs, environmentType, filePath, exportFormat });
|
||||
|
||||
const successMessage = exportFormat === 'folder'
|
||||
? `Environments exported successfully to bruno-${environmentType}-environments folder`
|
||||
: 'Environment(s) exported successfully';
|
||||
toast.success(successMessage);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Export error:', error);
|
||||
toast.error(error.message || 'Failed to export environments');
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<StyledWrapper>
|
||||
<Modal
|
||||
size="md"
|
||||
title="Export Environments"
|
||||
hideFooter={true}
|
||||
handleCancel={onClose}
|
||||
>
|
||||
<div className="py-2">
|
||||
{/* Environments Section */}
|
||||
<div className="mb-4">
|
||||
{environments && environments.length > 0 ? (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex justify-between items-center mb-2 pb-1">
|
||||
<h3 className="font-semibold text-sm text-theme">
|
||||
{environmentType === 'global' ? 'Global Environments' : 'Collection Environments'}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSelectAll}
|
||||
className="text-xs text-link px-1 py-0.5 rounded transition-colors"
|
||||
>
|
||||
{environments.every((env) => selectedEnvironments[env.uid]) ? 'Deselect All' : 'Select All'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 flex-1 overflow-y-auto">
|
||||
{environments.map((env) => (
|
||||
<label key={env.uid} className="environment-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedEnvironments[env.uid] || false}
|
||||
onChange={() => handleEnvironmentToggle(env.uid)}
|
||||
disabled={isExporting}
|
||||
className="w-3.5 h-3.5 flex-shrink-0"
|
||||
/>
|
||||
<span className="environment-name">{truncateEnvName(env.name)}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex justify-between items-center mb-2 pb-1">
|
||||
<h3 className="font-semibold text-sm text-theme">
|
||||
{environmentType === 'global' ? 'Global Environments' : 'Collection Environments'}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex items-center justify-center flex-1 p-4 text-center">
|
||||
<span className="text-xs text-muted">
|
||||
No {environmentType === 'global' ? 'global' : 'collection'} environments
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Export Format Section */}
|
||||
{selectedCount > 0 && (
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium mb-2 text-theme">
|
||||
Export Format
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
{exportFormatOptions.map((option) => (
|
||||
<label key={option.value} className={`flex items-start p-2 rounded transition-colors ${option.disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}`}>
|
||||
<input
|
||||
type="radio"
|
||||
name="exportFormat"
|
||||
value={option.value}
|
||||
checked={exportFormat === option.value}
|
||||
onChange={(e) => setExportFormat(e.target.value)}
|
||||
disabled={isExporting || option.disabled}
|
||||
className={`mt-0.5 mr-3 w-4 h-4 ${option.disabled ? 'cursor-not-allowed' : 'cursor-pointer'}`}
|
||||
/>
|
||||
<div>
|
||||
<div className={`text-sm font-medium ${option.disabled ? 'text-muted' : 'text-theme'}`}>{option.label}</div>
|
||||
<div className="text-xs text-muted">{option.description}</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Location Input Section */}
|
||||
<div className="mb-4">
|
||||
<label htmlFor="export-location" className="block text-sm font-medium mb-2 text-theme">
|
||||
Location
|
||||
</label>
|
||||
<div className="flex flex-col relative items-center">
|
||||
<input
|
||||
id="export-location"
|
||||
type="text"
|
||||
className={`flex-1 textbox w-full ${isExporting || selectedCount <= 0 ? '' : 'cursor-pointer'}`}
|
||||
title={filePath}
|
||||
value={filePath}
|
||||
onClick={browse}
|
||||
onChange={(e) => setFilePath(e.target.value)}
|
||||
disabled={isExporting || selectedCount <= 0}
|
||||
placeholder="Select a target location"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Export Actions */}
|
||||
<div className="flex justify-end gap-2 mt-4 pt-3 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-cancel mt-2 flex items-center"
|
||||
onClick={onClose}
|
||||
disabled={isExporting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-secondary mt-2 flex items-center"
|
||||
onClick={handleExport}
|
||||
disabled={isExporting || selectedCount === 0}
|
||||
>
|
||||
{isExporting ? 'Exporting...' : `Export ${selectedCount || ''} Environment${selectedCount !== 1 ? 's' : ''}`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</StyledWrapper>
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExportEnvironmentModal;
|
||||
@@ -0,0 +1,166 @@
|
||||
import React, { useState } from 'react';
|
||||
import Portal from 'components/Portal';
|
||||
import Modal from 'components/Modal';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import importPostmanEnvironment from 'utils/importers/postman-environment';
|
||||
import importBrunoEnvironment from 'utils/importers/bruno-environment';
|
||||
import { readMultipleFiles } from 'utils/importers/file-reader';
|
||||
import { importEnvironment } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { addGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
|
||||
import { toastError } from 'utils/common/error';
|
||||
import { IconFileImport } from '@tabler/icons';
|
||||
|
||||
const ImportEnvironmentModal = ({ type = 'collection', collection, onClose, onEnvironmentCreated }) => {
|
||||
const dispatch = useDispatch();
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
|
||||
const isGlobal = type === 'global';
|
||||
|
||||
// Validate required props
|
||||
if (!isGlobal && !collection) {
|
||||
console.error('ImportEnvironmentModal: collection prop is required when type is "collection"');
|
||||
return null;
|
||||
}
|
||||
const modalTitle = isGlobal ? 'Import Global Environment' : 'Import Environment';
|
||||
const modalTestId = isGlobal ? 'import-global-environment-modal' : 'import-environment-modal';
|
||||
const importTestId = isGlobal ? 'import-global-environment' : 'import-environment';
|
||||
|
||||
const processEnvironments = async (environments, successMessage) => {
|
||||
const validEnvironments = environments.filter((env) => {
|
||||
if (env.name && env.name !== 'undefined') {
|
||||
return true;
|
||||
} else {
|
||||
toast.error('Failed to import environment: env has no name');
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (validEnvironments.length === 0) {
|
||||
toast.error('No valid environments found to import');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Process environments sequentially to ensure unique name checking considers previously imported environments
|
||||
let importedCount = 0;
|
||||
for (const environment of validEnvironments) {
|
||||
const action = isGlobal
|
||||
? addGlobalEnvironment({ name: environment.name, variables: environment.variables })
|
||||
: importEnvironment({ name: environment.name, variables: environment.variables, collectionUid: collection?.uid });
|
||||
|
||||
await dispatch(action);
|
||||
importedCount++;
|
||||
}
|
||||
|
||||
toast.success(`${importedCount > 1 ? `${importedCount} environments` : 'Environment'} imported successfully`);
|
||||
} catch (error) {
|
||||
toast.error('An error occurred while importing the environment(s)');
|
||||
console.error(error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const detectEnvironmentFormat = (data) => {
|
||||
// bruno environment `single-object` export type
|
||||
if (data.info && data.info.type === 'bruno-environment') {
|
||||
return 'bruno';
|
||||
} else if (Array.isArray(data)) {
|
||||
// bruno environment`single-file` export type
|
||||
return data.some((env) => env.info && env.info.type === 'bruno-environment') ? 'bruno' : 'postman';
|
||||
} else if (data.id && data.values) {
|
||||
// postman environment
|
||||
return 'postman';
|
||||
}
|
||||
return 'bruno';
|
||||
};
|
||||
|
||||
const handleImportEnvironment = async (files) => {
|
||||
try {
|
||||
// Read and parse all files
|
||||
const parsedFiles = await readMultipleFiles(Array.from(files));
|
||||
|
||||
// Detect format from first file's content
|
||||
const format = detectEnvironmentFormat(parsedFiles[0].content);
|
||||
let environments;
|
||||
|
||||
if (format === 'postman') {
|
||||
environments = await importPostmanEnvironment(parsedFiles);
|
||||
} else {
|
||||
environments = await importBrunoEnvironment(parsedFiles);
|
||||
}
|
||||
|
||||
await processEnvironments(environments);
|
||||
onClose();
|
||||
if (onEnvironmentCreated) {
|
||||
onEnvironmentCreated();
|
||||
}
|
||||
} catch (err) {
|
||||
toastError(err, 'Import environment failed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = () => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.multiple = true;
|
||||
input.accept = '.json';
|
||||
input.onchange = (e) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
handleImportEnvironment(e.target.files);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
};
|
||||
|
||||
const handleDragOver = (e) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
if (files.length > 0) {
|
||||
handleImportEnvironment(files);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Modal size="md" title={modalTitle} hideFooter={true} handleConfirm={onClose} handleCancel={onClose} dataTestId={modalTestId}>
|
||||
<div className="py-2">
|
||||
<div
|
||||
className={`flex justify-center flex-col items-center w-full dark:bg-zinc-700 rounded-lg border-2 border-dashed p-12 text-center cursor-pointer transition-colors focus:outline-none focus:ring-2 focus:ring-amber-500 focus:ring-offset-2 ${
|
||||
isDragOver
|
||||
? 'border-amber-400 bg-amber-50 dark:bg-amber-900/20'
|
||||
: 'border-zinc-300 dark:border-zinc-400 hover:border-zinc-400'
|
||||
}`}
|
||||
onClick={handleFileSelect}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
data-testid={importTestId}
|
||||
>
|
||||
<IconFileImport size={64} />
|
||||
<span className="mt-2 block text-sm font-semibold">
|
||||
{isDragOver ? 'Drop your environment files here' : 'Import your environments'}
|
||||
</span>
|
||||
<span className="mt-1 block text-xs text-muted">
|
||||
Drag & drop JSON files/folders or click to browse. Supports both Bruno and Postman formats.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImportEnvironmentModal;
|
||||
@@ -0,0 +1,74 @@
|
||||
import React from 'react';
|
||||
import { IconPlus, IconDownload, IconSettings } from '@tabler/icons';
|
||||
import ToolHint from 'components/ToolHint';
|
||||
|
||||
const EnvironmentListContent = ({
|
||||
environments,
|
||||
activeEnvironmentUid,
|
||||
description,
|
||||
onEnvironmentSelect,
|
||||
onSettingsClick,
|
||||
onCreateClick,
|
||||
onImportClick
|
||||
}) => {
|
||||
return (
|
||||
<div>
|
||||
{environments && environments.length > 0 ? (
|
||||
<>
|
||||
<div className="environment-list">
|
||||
<div className="dropdown-item no-environment" onClick={() => onEnvironmentSelect(null)}>
|
||||
<span>No Environment</span>
|
||||
</div>
|
||||
<ToolHint
|
||||
anchorSelect="[data-tooltip-content]"
|
||||
place="right"
|
||||
positionStrategy="fixed"
|
||||
tooltipStyle={{
|
||||
maxWidth: '200px',
|
||||
wordWrap: 'break-word'
|
||||
}}
|
||||
delayShow={1000}
|
||||
>
|
||||
<div>
|
||||
{environments.map((env) => (
|
||||
<div
|
||||
key={env.uid}
|
||||
className={`dropdown-item ${env.uid === activeEnvironmentUid ? 'active' : ''}`}
|
||||
onClick={() => onEnvironmentSelect(env)}
|
||||
data-tooltip-content={env.name}
|
||||
data-tooltip-hidden={env.name?.length < 90}
|
||||
>
|
||||
<span className="max-w-100% truncate no-wrap">{env.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ToolHint>
|
||||
<div className="dropdown-item configure-button">
|
||||
<button onClick={onSettingsClick} id="configure-env">
|
||||
<IconSettings size={16} strokeWidth={1.5} />
|
||||
<span>Configure</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="empty-state">
|
||||
<h3>Ready to get started?</h3>
|
||||
<p>{description}</p>
|
||||
<div className="space-y-2">
|
||||
<button onClick={onCreateClick} id="create-env">
|
||||
<IconPlus size={16} strokeWidth={1.5} />
|
||||
Create
|
||||
</button>
|
||||
<button onClick={onImportClick} id="import-env">
|
||||
<IconDownload size={16} strokeWidth={1.5} />
|
||||
Import
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EnvironmentListContent;
|
||||
@@ -2,14 +2,230 @@ import styled from 'styled-components';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
.current-environment {
|
||||
background-color: ${(props) => props.theme.sidebar.badge.bg};
|
||||
border-radius: 15px;
|
||||
border-radius: 0.9375rem;
|
||||
padding: 0.25rem 0.5rem 0.25rem 0.75rem;
|
||||
user-select: none;
|
||||
background-color: transparent;
|
||||
border: 1px solid ${(props) => props.theme.dropdown.selectedColor};
|
||||
line-height: 1rem;
|
||||
|
||||
.caret {
|
||||
margin-left: 0.25rem;
|
||||
color: rgb(140, 140, 140);
|
||||
fill: rgb(140, 140, 140);
|
||||
}
|
||||
|
||||
.env-icon {
|
||||
margin-right: 0.25rem;
|
||||
color: ${(props) => props.theme.dropdown.selectedColor};
|
||||
}
|
||||
|
||||
.env-text {
|
||||
color: ${(props) => props.theme.dropdown.selectedColor};
|
||||
font-size: 0.875rem;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.env-separator {
|
||||
color: #8c8c8c;
|
||||
margin: 0 0.25rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.env-text-inactive {
|
||||
color: ${(props) => props.theme.dropdown.color};
|
||||
font-size: 0.875rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
&.no-environments {
|
||||
background-color: ${(props) => props.theme.sidebar.badge.bg};
|
||||
border: 1px solid transparent;
|
||||
color: ${(props) => props.theme.dropdown.secondaryText};
|
||||
}
|
||||
}
|
||||
|
||||
.tippy-box {
|
||||
width: ${(props) => props.width}px;
|
||||
min-width: 12rem;
|
||||
max-width: 650px !important;
|
||||
min-height: 15.5rem;
|
||||
max-height: 75vh;
|
||||
font-size: 0.8125rem;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tippy-box .tippy-content {
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
|
||||
.dropdown-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.35rem 0.6rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.8125rem;
|
||||
color: ${(props) => props.theme.dropdown.primaryText};
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: ${(props) => props.theme.dropdown.hoverBg};
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: ${(props) => props.theme.dropdown.selectedBg};
|
||||
color: ${(props) => props.theme.dropdown.selectedColor};
|
||||
}
|
||||
|
||||
&.no-environment {
|
||||
color: ${(props) => props.theme.dropdown.mutedText};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.configure-button {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background-color: ${(props) => props.theme.dropdown.bg};
|
||||
border-top: 0.0625rem solid ${(props) => props.theme.dropdown.separator};
|
||||
z-index: 10;
|
||||
margin: 0;
|
||||
|
||||
&:hover {
|
||||
background-color: ${(props) => props.theme.dropdown.bg + ' !important'};
|
||||
}
|
||||
|
||||
button {
|
||||
color: ${(props) => props.theme.dropdown.primaryText};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
color: var(--color-tab-inactive);
|
||||
font-size: 0.8125rem;
|
||||
|
||||
.tab-content-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: ${(props) => props.theme.tabs.active.color};
|
||||
border-bottom-color: ${(props) => props.theme.tabs.active.border};
|
||||
}
|
||||
|
||||
&.inactive {
|
||||
border-bottom-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.environment-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
max-height: calc(75vh - 8rem);
|
||||
padding-bottom: 2.625rem;
|
||||
}
|
||||
|
||||
.dropdown-item-list {
|
||||
max-height: 75vh;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
max-width: 20rem;
|
||||
margin: 0 auto;
|
||||
padding: 0.35rem 0.6rem;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 12.5rem;
|
||||
|
||||
h3 {
|
||||
color: ${(props) => props.theme.dropdown.primaryText};
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
p {
|
||||
color: ${(props) => props.theme.dropdown.primaryText};
|
||||
opacity: 0.75;
|
||||
font-size: 0.6875rem;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 1rem;
|
||||
max-width: 11.875rem;
|
||||
margin: 0 auto;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.space-y-2 {
|
||||
width: 100%;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.space-y-2 > button {
|
||||
border: 0.0625rem solid ${(props) => props.theme.dropdown.primaryText};
|
||||
background: transparent;
|
||||
color: ${(props) => props.theme.dropdown.primaryText};
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
width: 100%;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
|
||||
&:hover {
|
||||
background-color: ${(props) => props.theme.dropdown.hoverBg};
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.no-collection-message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem 1rem;
|
||||
color: ${(props) => props.theme.dropdown.primaryText};
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
opacity: 0.75;
|
||||
|
||||
svg {
|
||||
margin: 0 auto 1rem auto;
|
||||
color: ${(props) => props.theme.dropdown.primaryText};
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,95 +1,275 @@
|
||||
import React, { useRef, forwardRef, useState } from 'react';
|
||||
import React, { useMemo, useState, useRef, forwardRef } from 'react';
|
||||
import find from 'lodash/find';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import { selectEnvironment } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { IconWorld, IconDatabase, IconCaretDown, IconSettings, IconPlus, IconDownload } from '@tabler/icons';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { updateEnvironmentSettingsModalVisibility } from 'providers/ReduxStore/slices/app';
|
||||
import { IconSettings, IconCaretDown, IconDatabase, IconDatabaseOff } from '@tabler/icons';
|
||||
import EnvironmentSettings from '../EnvironmentSettings';
|
||||
import { selectEnvironment } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { selectGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import EnvironmentListContent from './EnvironmentListContent/index';
|
||||
import EnvironmentSettings from '../EnvironmentSettings';
|
||||
import GlobalEnvironmentSettings from 'components/GlobalEnvironments/EnvironmentSettings';
|
||||
import CreateEnvironment from '../EnvironmentSettings/CreateEnvironment';
|
||||
import ImportEnvironmentModal from 'components/Environments/Common/ImportEnvironmentModal';
|
||||
import CreateGlobalEnvironment from 'components/GlobalEnvironments/EnvironmentSettings/CreateEnvironment';
|
||||
import ToolHint from 'components/ToolHint';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const EnvironmentSelector = ({ collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const dropdownTippyRef = useRef();
|
||||
const [openSettingsModal, setOpenSettingsModal] = useState(false);
|
||||
const { environments, activeEnvironmentUid } = collection;
|
||||
const activeEnvironment = activeEnvironmentUid ? find(environments, (e) => e.uid === activeEnvironmentUid) : null;
|
||||
const [activeTab, setActiveTab] = useState('collection');
|
||||
const [showGlobalSettings, setShowGlobalSettings] = useState(false);
|
||||
const [showCollectionSettings, setShowCollectionSettings] = useState(false);
|
||||
const [showCreateGlobalModal, setShowCreateGlobalModal] = useState(false);
|
||||
const [showImportGlobalModal, setShowImportGlobalModal] = useState(false);
|
||||
const [showCreateCollectionModal, setShowCreateCollectionModal] = useState(false);
|
||||
const [showImportCollectionModal, setShowImportCollectionModal] = useState(false);
|
||||
|
||||
const globalEnvironments = useSelector((state) => state.globalEnvironments.globalEnvironments);
|
||||
const activeGlobalEnvironmentUid = useSelector((state) => state.globalEnvironments.activeGlobalEnvironmentUid);
|
||||
const activeGlobalEnvironment = activeGlobalEnvironmentUid
|
||||
? find(globalEnvironments, (e) => e.uid === activeGlobalEnvironmentUid)
|
||||
: null;
|
||||
|
||||
const environments = collection?.environments || [];
|
||||
const activeEnvironmentUid = collection?.activeEnvironmentUid;
|
||||
const activeCollectionEnvironment = activeEnvironmentUid
|
||||
? find(environments, (e) => e.uid === activeEnvironmentUid)
|
||||
: null;
|
||||
|
||||
const tabs = [
|
||||
{ id: 'collection', label: 'Collection', icon: <IconDatabase size={16} strokeWidth={1.5} /> },
|
||||
{ id: 'global', label: 'Global', icon: <IconWorld size={16} strokeWidth={1.5} /> }
|
||||
];
|
||||
|
||||
const onDropdownCreate = (ref) => {
|
||||
dropdownTippyRef.current = ref;
|
||||
};
|
||||
|
||||
// Get description based on active tab
|
||||
const description =
|
||||
activeTab === 'collection'
|
||||
? 'Create your first environment to begin working with your collection.'
|
||||
: 'Create your first global environment to begin working across collections.';
|
||||
|
||||
// Environment selection handler
|
||||
const handleEnvironmentSelect = (environment) => {
|
||||
const action =
|
||||
activeTab === 'collection'
|
||||
? selectEnvironment(environment ? environment.uid : null, collection.uid)
|
||||
: selectGlobalEnvironment({ environmentUid: environment ? environment.uid : null });
|
||||
|
||||
dispatch(action)
|
||||
.then(() => {
|
||||
if (environment) {
|
||||
toast.success(`Environment changed to ${environment.name}`);
|
||||
} else {
|
||||
toast.success('No Environments are active now');
|
||||
}
|
||||
dropdownTippyRef.current.hide();
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error('An error occurred while selecting the environment');
|
||||
});
|
||||
};
|
||||
|
||||
// Settings handler
|
||||
const handleSettingsClick = () => {
|
||||
if (activeTab === 'collection') {
|
||||
dispatch(updateEnvironmentSettingsModalVisibility(true));
|
||||
setShowCollectionSettings(true);
|
||||
} else {
|
||||
setShowGlobalSettings(true);
|
||||
}
|
||||
dropdownTippyRef.current.hide();
|
||||
};
|
||||
|
||||
// Create handler
|
||||
const handleCreateClick = () => {
|
||||
if (activeTab === 'collection') {
|
||||
setShowCreateCollectionModal(true);
|
||||
} else {
|
||||
setShowCreateGlobalModal(true);
|
||||
}
|
||||
dropdownTippyRef.current.hide();
|
||||
};
|
||||
|
||||
// Import handler
|
||||
const handleImportClick = () => {
|
||||
if (activeTab === 'collection') {
|
||||
setShowImportCollectionModal(true);
|
||||
} else {
|
||||
setShowImportGlobalModal(true);
|
||||
}
|
||||
dropdownTippyRef.current.hide();
|
||||
};
|
||||
|
||||
// Modal handlers
|
||||
const handleCloseSettings = () => {
|
||||
setShowGlobalSettings(false);
|
||||
setShowCollectionSettings(false);
|
||||
dispatch(updateEnvironmentSettingsModalVisibility(false));
|
||||
};
|
||||
|
||||
// Calculate dropdown width based on the longest environment name.
|
||||
// To prevent resizing while switching between collection and global environments.
|
||||
const dropdownWidth = useMemo(() => {
|
||||
const allEnvironments = [...environments, ...globalEnvironments];
|
||||
if (allEnvironments.length === 0) return 0;
|
||||
|
||||
const maxCharLength = Math.max(...allEnvironments.map((env) => env.name?.length || 0));
|
||||
// 8 pixels per character: This is a rough estimate for the average character width in most fonts
|
||||
// (monospace fonts are typically 8-10px, proportional fonts vary but 8px is a safe average)
|
||||
return maxCharLength * 8;
|
||||
}, [environments, globalEnvironments]);
|
||||
|
||||
// Create icon component for dropdown trigger
|
||||
const Icon = forwardRef((props, ref) => {
|
||||
const hasAnyEnv = activeGlobalEnvironment || activeCollectionEnvironment;
|
||||
|
||||
const displayContent = hasAnyEnv ? (
|
||||
<>
|
||||
{activeCollectionEnvironment && (
|
||||
<>
|
||||
<div className="flex items-center">
|
||||
<IconDatabase size={14} strokeWidth={1.5} className="env-icon" />
|
||||
<ToolHint
|
||||
text={activeCollectionEnvironment.name}
|
||||
toolhintId={`collection-env-${activeCollectionEnvironment.uid}`}
|
||||
place="bottom-start"
|
||||
delayShow={1000}
|
||||
hidden={activeCollectionEnvironment.name?.length < 7}
|
||||
>
|
||||
<span className="env-text max-w-24 truncate overflow-hidden">{activeCollectionEnvironment.name}</span>
|
||||
</ToolHint>
|
||||
</div>
|
||||
{activeGlobalEnvironment && <span className="env-separator">|</span>}
|
||||
</>
|
||||
)}
|
||||
{activeGlobalEnvironment && (
|
||||
<div className="flex items-center">
|
||||
<IconWorld size={14} strokeWidth={1.5} className="env-icon" />
|
||||
<ToolHint
|
||||
text={activeGlobalEnvironment.name}
|
||||
toolhintId={`global-env-${activeGlobalEnvironment.uid}`}
|
||||
place="bottom-start"
|
||||
delayShow={1000}
|
||||
hidden={activeGlobalEnvironment.name?.length < 7}
|
||||
>
|
||||
<span className="env-text max-w-24 truncate overflow-hidden">{activeGlobalEnvironment.name}</span>
|
||||
</ToolHint>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="env-text-inactive max-w-36 truncate no-wrap">No environments</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="current-environment flex items-center justify-center pl-3 pr-2 py-1 select-none">
|
||||
<p className="text-nowrap truncate max-w-32">{activeEnvironment ? activeEnvironment.name : 'No Environment'}</p>
|
||||
<div
|
||||
ref={ref}
|
||||
className={`current-environment flex align-center justify-center cursor-pointer bg-transparent ${
|
||||
!hasAnyEnv ? 'no-environments' : ''
|
||||
}`}
|
||||
data-testid="environment-selector-trigger"
|
||||
>
|
||||
{displayContent}
|
||||
<IconCaretDown className="caret" size={14} strokeWidth={2} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const handleSettingsIconClick = () => {
|
||||
setOpenSettingsModal(true);
|
||||
dispatch(updateEnvironmentSettingsModalVisibility(true));
|
||||
};
|
||||
|
||||
const handleModalClose = () => {
|
||||
setOpenSettingsModal(false);
|
||||
dispatch(updateEnvironmentSettingsModalVisibility(false));
|
||||
};
|
||||
|
||||
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
|
||||
|
||||
const onSelect = (environment) => {
|
||||
dispatch(selectEnvironment(environment ? environment.uid : null, collection.uid))
|
||||
.then(() => {
|
||||
if (environment) {
|
||||
toast.success(`Environment changed to ${environment.name}`);
|
||||
} else {
|
||||
toast.success(`No Environments are active now`);
|
||||
}
|
||||
})
|
||||
.catch((err) => console.log(err) && toast.error('An error occurred while selecting the environment'));
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="flex items-center cursor-pointer environment-selector">
|
||||
<StyledWrapper width={dropdownWidth}>
|
||||
<div className="environment-selector flex align-center cursor-pointer">
|
||||
<Dropdown onCreate={onDropdownCreate} icon={<Icon />} placement="bottom-end">
|
||||
<div className="label-item font-medium">Collection Environments</div>
|
||||
{environments && environments.length
|
||||
? environments.map((e) => (
|
||||
<div
|
||||
className={`dropdown-item ${e?.uid === activeEnvironmentUid ? 'active' : ''}`}
|
||||
key={e.uid}
|
||||
onClick={() => {
|
||||
onSelect(e);
|
||||
dropdownTippyRef.current.hide();
|
||||
}}
|
||||
>
|
||||
<IconDatabase size={18} strokeWidth={1.5} /> <span className="ml-2 break-all">{e.name}</span>
|
||||
</div>
|
||||
))
|
||||
: null}
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
dropdownTippyRef.current.hide();
|
||||
onSelect(null);
|
||||
}}
|
||||
>
|
||||
<IconDatabaseOff size={18} strokeWidth={1.5} />
|
||||
<span className="ml-2">No Environment</span>
|
||||
{/* Tab Headers */}
|
||||
<div className="tab-header flex p-[0.75rem]">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
className={`tab-button whitespace-nowrap pb-[0.375rem] border-b-[0.125rem] bg-transparent flex align-center cursor-pointer transition-all duration-200 mr-[1.25rem] ${
|
||||
activeTab === tab.id ? 'active' : 'inactive'
|
||||
}`}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
data-testid={`env-tab-${tab.id}`}
|
||||
>
|
||||
<span className="tab-content-wrapper">
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="dropdown-item border-top" onClick={() => {
|
||||
handleSettingsIconClick();
|
||||
dropdownTippyRef.current.hide();
|
||||
}}>
|
||||
<div className="pr-2 text-gray-600">
|
||||
<IconSettings size={18} strokeWidth={1.5} />
|
||||
</div>
|
||||
<span>Configure</span>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="tab-content">
|
||||
<EnvironmentListContent
|
||||
environments={activeTab === 'collection' ? environments : globalEnvironments}
|
||||
activeEnvironmentUid={activeTab === 'collection' ? activeEnvironmentUid : activeGlobalEnvironmentUid}
|
||||
description={description}
|
||||
onEnvironmentSelect={handleEnvironmentSelect}
|
||||
onSettingsClick={handleSettingsClick}
|
||||
onCreateClick={handleCreateClick}
|
||||
onImportClick={handleImportClick}
|
||||
/>
|
||||
</div>
|
||||
</Dropdown>
|
||||
</div>
|
||||
{openSettingsModal && <EnvironmentSettings collection={collection} onClose={handleModalClose} />}
|
||||
|
||||
{/* Modals - Rendered outside dropdown to avoid conflicts */}
|
||||
{showGlobalSettings && (
|
||||
<GlobalEnvironmentSettings
|
||||
globalEnvironments={globalEnvironments}
|
||||
collection={collection}
|
||||
activeGlobalEnvironmentUid={activeGlobalEnvironmentUid}
|
||||
onClose={handleCloseSettings}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showCollectionSettings && <EnvironmentSettings collection={collection} onClose={handleCloseSettings} />}
|
||||
|
||||
{showCreateGlobalModal && (
|
||||
<CreateGlobalEnvironment
|
||||
onClose={() => setShowCreateGlobalModal(false)}
|
||||
onEnvironmentCreated={() => {
|
||||
setShowGlobalSettings(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showImportGlobalModal && (
|
||||
<ImportEnvironmentModal
|
||||
type="global"
|
||||
onClose={() => setShowImportGlobalModal(false)}
|
||||
onEnvironmentCreated={() => {
|
||||
setShowGlobalSettings(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showCreateCollectionModal && (
|
||||
<CreateEnvironment
|
||||
collection={collection}
|
||||
onClose={() => setShowCreateCollectionModal(false)}
|
||||
onEnvironmentCreated={() => {
|
||||
setShowCollectionSettings(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showImportCollectionModal && (
|
||||
<ImportEnvironmentModal
|
||||
type="collection"
|
||||
collection={collection}
|
||||
onClose={() => setShowImportCollectionModal(false)}
|
||||
onEnvironmentCreated={() => {
|
||||
setShowCollectionSettings(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,7 +8,7 @@ import Portal from 'components/Portal';
|
||||
import Modal from 'components/Modal';
|
||||
import { validateName, validateNameError } from 'utils/common/regex';
|
||||
|
||||
const CreateEnvironment = ({ collection, onClose }) => {
|
||||
const CreateEnvironment = ({ collection, onClose, onEnvironmentCreated }) => {
|
||||
const dispatch = useDispatch();
|
||||
const inputRef = useRef();
|
||||
|
||||
@@ -37,6 +37,10 @@ const CreateEnvironment = ({ collection, onClose }) => {
|
||||
.then(() => {
|
||||
toast.success('Environment created in collection');
|
||||
onClose();
|
||||
// Call the callback if provided
|
||||
if (onEnvironmentCreated) {
|
||||
onEnvironmentCreated();
|
||||
}
|
||||
})
|
||||
.catch(() => toast.error('An error occurred while creating the environment'));
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { IconTrash, IconAlertCircle, IconDeviceFloppy, IconRefresh, IconCircleCh
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { selectEnvironment } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import SingleLineEditor from 'components/SingleLineEditor';
|
||||
import MultiLineEditor from 'components/MultiLineEditor/index';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { uuid } from 'utils/common';
|
||||
import { useFormik } from 'formik';
|
||||
@@ -173,7 +173,7 @@ const EnvironmentVariables = ({ environment, collection, setIsModified, original
|
||||
return (
|
||||
<StyledWrapper className="w-full mt-6 mb-6">
|
||||
<div className="h-[50vh] overflow-y-auto w-full">
|
||||
<table>
|
||||
<table className="environment-variables">
|
||||
<thead>
|
||||
<tr>
|
||||
<td className="text-center">Enabled</td>
|
||||
@@ -185,7 +185,7 @@ const EnvironmentVariables = ({ environment, collection, setIsModified, original
|
||||
</thead>
|
||||
<tbody>
|
||||
{formik.values.map((variable, index) => (
|
||||
<tr key={variable.uid}>
|
||||
<tr key={variable.uid} data-testid={`env-var-row-${variable.name}`}>
|
||||
<td className="text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -214,7 +214,7 @@ const EnvironmentVariables = ({ environment, collection, setIsModified, original
|
||||
</td>
|
||||
<td className="flex flex-row flex-nowrap items-center">
|
||||
<div className="overflow-hidden grow w-full relative">
|
||||
<SingleLineEditor
|
||||
<MultiLineEditor
|
||||
theme={storedTheme}
|
||||
collection={_collection}
|
||||
name={`${index}.value`}
|
||||
@@ -253,6 +253,8 @@ const EnvironmentVariables = ({ environment, collection, setIsModified, original
|
||||
ref={addButtonRef}
|
||||
className="btn-add-param text-link pr-2 py-3 mt-2 select-none"
|
||||
onClick={addVariable}
|
||||
id="add-variable"
|
||||
data-testid="add-variable"
|
||||
>
|
||||
+ Add Variable
|
||||
</button>
|
||||
@@ -260,15 +262,15 @@ const EnvironmentVariables = ({ environment, collection, setIsModified, original
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<button type="submit" className="submit btn btn-sm btn-secondary mt-2 flex items-center" onClick={formik.handleSubmit}>
|
||||
<button type="submit" className="submit btn btn-sm btn-secondary mt-2 flex items-center" onClick={formik.handleSubmit} data-testid="save-env">
|
||||
<IconDeviceFloppy size={16} strokeWidth={1.5} className="mr-1" />
|
||||
Save
|
||||
</button>
|
||||
<button type="submit" className="ml-2 px-1 submit btn btn-sm btn-close mt-2 flex items-center" onClick={handleReset}>
|
||||
<button type="submit" className="ml-2 px-1 submit btn btn-sm btn-close mt-2 flex items-center" onClick={handleReset} data-testid="reset-env">
|
||||
<IconRefresh size={16} strokeWidth={1.5} className="mr-1" />
|
||||
Reset
|
||||
</button>
|
||||
<button type="submit" className="submit btn btn-sm btn-close mt-2 flex items-center" onClick={onActivate}>
|
||||
<button type="submit" className="submit btn btn-sm btn-close mt-2 flex items-center" onClick={onActivate} data-testid="activate-env">
|
||||
<IconCircleCheck size={16} strokeWidth={1.5} className="mr-1" />
|
||||
Activate
|
||||
</button>
|
||||
|
||||
@@ -4,6 +4,7 @@ import CopyEnvironment from '../../CopyEnvironment';
|
||||
import DeleteEnvironment from '../../DeleteEnvironment';
|
||||
import RenameEnvironment from '../../RenameEnvironment';
|
||||
import EnvironmentVariables from './EnvironmentVariables';
|
||||
import ToolHint from 'components/ToolHint/index';
|
||||
|
||||
const EnvironmentDetails = ({ environment, collection, setIsModified, onClose }) => {
|
||||
const [openEditModal, setOpenEditModal] = useState(false);
|
||||
@@ -30,10 +31,22 @@ const EnvironmentDetails = ({ environment, collection, setIsModified, onClose })
|
||||
<IconDatabase className="cursor-pointer" size={20} strokeWidth={1.5} />
|
||||
<span className="ml-1 font-semibold break-all">{environment.name}</span>
|
||||
</div>
|
||||
<div className="flex gap-x-4 pl-4">
|
||||
<IconEdit className="cursor-pointer" size={20} strokeWidth={1.5} onClick={() => setOpenEditModal(true)} />
|
||||
<IconCopy className="cursor-pointer" size={20} strokeWidth={1.5} onClick={() => setOpenCopyModal(true)} />
|
||||
<IconTrash className="cursor-pointer" size={20} strokeWidth={1.5} onClick={() => setOpenDeleteModal(true)} />
|
||||
<div className="flex gap-x-2 pl-2">
|
||||
<ToolHint text="Edit Environment" toolhintId={`edit-${environment.uid}`}>
|
||||
<IconEdit className="cursor-pointer" size={20} strokeWidth={1.5} onClick={() => setOpenEditModal(true)} />
|
||||
</ToolHint>
|
||||
<ToolHint text="Copy Environment" toolhintId={`copy-${environment.uid}`}>
|
||||
<IconCopy className="cursor-pointer" size={20} strokeWidth={1.5} onClick={() => setOpenCopyModal(true)} />
|
||||
</ToolHint>
|
||||
<ToolHint text="Delete Environment" toolhintId={`delete-${environment.uid}`}>
|
||||
<IconTrash
|
||||
className="cursor-pointer"
|
||||
size={20}
|
||||
strokeWidth={1.5}
|
||||
onClick={() => setOpenDeleteModal(true)}
|
||||
data-testid="delete-environment-button"
|
||||
/>
|
||||
</ToolHint>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -3,15 +3,15 @@ import { findEnvironmentInCollection } from 'utils/collections';
|
||||
import usePrevious from 'hooks/usePrevious';
|
||||
import EnvironmentDetails from './EnvironmentDetails';
|
||||
import CreateEnvironment from '../CreateEnvironment';
|
||||
import { IconDownload, IconShieldLock } from '@tabler/icons';
|
||||
import ImportEnvironment from '../ImportEnvironment';
|
||||
import { IconDownload, IconShieldLock, IconUpload } from '@tabler/icons';
|
||||
import ImportEnvironmentModal from 'components/Environments/Common/ImportEnvironmentModal';
|
||||
import ManageSecrets from '../ManageSecrets';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import ConfirmSwitchEnv from './ConfirmSwitchEnv';
|
||||
import ToolHint from 'components/ToolHint';
|
||||
import { isEqual } from 'lodash';
|
||||
|
||||
const EnvironmentList = ({ selectedEnvironment, setSelectedEnvironment, collection, isModified, setIsModified, onClose }) => {
|
||||
const EnvironmentList = ({ selectedEnvironment, setSelectedEnvironment, collection, isModified, setIsModified, onClose, setShowExportModal }) => {
|
||||
const { environments } = collection;
|
||||
const [openCreateModal, setOpenCreateModal] = useState(false);
|
||||
const [openImportModal, setOpenImportModal] = useState(false);
|
||||
@@ -96,7 +96,7 @@ const EnvironmentList = ({ selectedEnvironment, setSelectedEnvironment, collecti
|
||||
return (
|
||||
<StyledWrapper>
|
||||
{openCreateModal && <CreateEnvironment collection={collection} onClose={() => setOpenCreateModal(false)} />}
|
||||
{openImportModal && <ImportEnvironment collection={collection} onClose={() => setOpenImportModal(false)} />}
|
||||
{openImportModal && <ImportEnvironmentModal type="collection" collection={collection} onClose={() => setOpenImportModal(false)} />}
|
||||
{openManageSecretsModal && <ManageSecrets onClose={() => setOpenManageSecretsModal(false)} />}
|
||||
|
||||
<div className="flex">
|
||||
@@ -129,6 +129,10 @@ const EnvironmentList = ({ selectedEnvironment, setSelectedEnvironment, collecti
|
||||
<IconDownload size={12} strokeWidth={2} />
|
||||
<span className="label ml-1 text-xs">Import</span>
|
||||
</div>
|
||||
<div className="flex items-center mt-2" onClick={() => setShowExportModal(true)}>
|
||||
<IconUpload size={12} strokeWidth={2} />
|
||||
<span className="label ml-1 text-xs">Export</span>
|
||||
</div>
|
||||
<div className="flex items-center mt-2" onClick={() => handleSecretsClick()}>
|
||||
<IconShieldLock size={12} strokeWidth={2} />
|
||||
<span className="label ml-1 text-xs">Managing Secrets</span>
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import React from 'react';
|
||||
import Portal from 'components/Portal';
|
||||
import Modal from 'components/Modal';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import importPostmanEnvironment from 'utils/importers/postman-environment';
|
||||
import { importEnvironment } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { toastError } from 'utils/common/error';
|
||||
import { IconDatabaseImport } from '@tabler/icons';
|
||||
|
||||
const ImportEnvironment = ({ collection, onClose }) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const handleImportPostmanEnvironment = () => {
|
||||
importPostmanEnvironment()
|
||||
.then((environments) => {
|
||||
environments
|
||||
.filter((env) =>
|
||||
env.name && env.name !== 'undefined'
|
||||
? true
|
||||
: () => {
|
||||
toast.error('Failed to import environment: env has no name');
|
||||
return false;
|
||||
}
|
||||
)
|
||||
.map((environment) => {
|
||||
dispatch(importEnvironment(environment.name, environment.variables, collection.uid))
|
||||
.then(() => {
|
||||
toast.success('Environment imported successfully');
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error('An error occurred while importing the environment');
|
||||
console.error(error);
|
||||
});
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
onClose();
|
||||
})
|
||||
.catch((err) => toastError(err, 'Postman Import environment failed'));
|
||||
};
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Modal size="sm" title="Import Environment" hideFooter={true} handleConfirm={onClose} handleCancel={onClose}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleImportPostmanEnvironment}
|
||||
className="flex justify-center flex-col items-center w-full dark:bg-zinc-700 rounded-lg border-2 border-dashed border-zinc-300 dark:border-zinc-400 p-12 text-center hover:border-zinc-400 focus:outline-none focus:ring-2 focus:ring-amber-500 focus:ring-offset-2"
|
||||
>
|
||||
<IconDatabaseImport size={64} />
|
||||
<span className="mt-2 block text-sm font-semibold">Import your Postman environments</span>
|
||||
</button>
|
||||
</Modal>
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImportEnvironment;
|
||||
@@ -3,8 +3,9 @@ import React, { useState } from 'react';
|
||||
import CreateEnvironment from './CreateEnvironment';
|
||||
import EnvironmentList from './EnvironmentList';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import ImportEnvironment from './ImportEnvironment';
|
||||
import ImportEnvironmentModal from 'components/Environments/Common/ImportEnvironmentModal';
|
||||
import { IconFileAlert } from '@tabler/icons';
|
||||
import ExportEnvironmentModal from 'components/Environments/Common/ExportEnvironmentModal';
|
||||
|
||||
export const SharedButton = ({ children, className, onClick }) => {
|
||||
return (
|
||||
@@ -47,6 +48,7 @@ const EnvironmentSettings = ({ collection, onClose }) => {
|
||||
const { environments } = collection;
|
||||
const [selectedEnvironment, setSelectedEnvironment] = useState(null);
|
||||
const [tab, setTab] = useState('default');
|
||||
const [showExportModal, setShowExportModal] = useState(false);
|
||||
if (!environments || !environments.length) {
|
||||
return (
|
||||
<StyledWrapper>
|
||||
@@ -54,27 +56,36 @@ const EnvironmentSettings = ({ collection, onClose }) => {
|
||||
{tab === 'create' ? (
|
||||
<CreateEnvironment collection={collection} onClose={() => setTab('default')} />
|
||||
) : tab === 'import' ? (
|
||||
<ImportEnvironment collection={collection} onClose={() => setTab('default')} />
|
||||
<ImportEnvironmentModal type="collection" collection={collection} onClose={() => setTab('default')} />
|
||||
) : (
|
||||
<></>
|
||||
<DefaultTab setTab={setTab} />
|
||||
)}
|
||||
<DefaultTab setTab={setTab} />
|
||||
</Modal>
|
||||
</StyledWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal size="lg" title="Environments" handleCancel={onClose} hideFooter={true}>
|
||||
<EnvironmentList
|
||||
selectedEnvironment={selectedEnvironment}
|
||||
setSelectedEnvironment={setSelectedEnvironment}
|
||||
collection={collection}
|
||||
isModified={isModified}
|
||||
setIsModified={setIsModified}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</Modal>
|
||||
<StyledWrapper>
|
||||
<Modal size="lg" title="Environments" handleCancel={onClose} hideFooter={true}>
|
||||
<EnvironmentList
|
||||
selectedEnvironment={selectedEnvironment}
|
||||
setSelectedEnvironment={setSelectedEnvironment}
|
||||
collection={collection}
|
||||
isModified={isModified}
|
||||
setIsModified={setIsModified}
|
||||
onClose={onClose}
|
||||
setShowExportModal={setShowExportModal}
|
||||
/>
|
||||
</Modal>
|
||||
{showExportModal && (
|
||||
<ExportEnvironmentModal
|
||||
onClose={() => setShowExportModal(false)}
|
||||
environments={collection.environments}
|
||||
environmentType="collection"
|
||||
/>
|
||||
)}
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { browseFiles } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { IconX } from '@tabler/icons';
|
||||
import { isWindowsOS } from 'utils/common/platform';
|
||||
|
||||
const FilePickerEditor = ({ value, onChange, collection, isSingleFilePicker = false }) => {
|
||||
const FilePickerEditor = ({ value, onChange, collection, isSingleFilePicker = false, readOnly = false }) => {
|
||||
const dispatch = useDispatch();
|
||||
const filenames = (isSingleFilePicker ? [value] : value || [])
|
||||
.filter((v) => v != null && v != '')
|
||||
@@ -50,20 +50,24 @@ const FilePickerEditor = ({ value, onChange, collection, isSingleFilePicker = fa
|
||||
return filenames.length + ' file(s) selected';
|
||||
};
|
||||
|
||||
const buttonClass = `btn btn-secondary px-1 ${readOnly ? 'view-mode' : 'edit-mode'}`;
|
||||
|
||||
return filenames.length > 0 ? (
|
||||
<div
|
||||
className="btn btn-secondary px-1"
|
||||
className={buttonClass}
|
||||
style={{ fontWeight: 400, width: '100%', textOverflow: 'ellipsis', overflowX: 'hidden' }}
|
||||
title={title}
|
||||
>
|
||||
<button className="align-middle" onClick={clear}>
|
||||
<IconX size={18} />
|
||||
</button>
|
||||
|
||||
{!readOnly && (
|
||||
<button className="align-middle" onClick={clear}>
|
||||
<IconX size={18} />
|
||||
</button>
|
||||
)}
|
||||
{!readOnly && <> </>}
|
||||
{renderButtonText(filenames)}
|
||||
</div>
|
||||
) : (
|
||||
<button className="btn btn-secondary px-1" style={{ width: '100%' }} onClick={browse}>
|
||||
<button className={buttonClass} style={{ width: '100%' }} onClick={!readOnly ? browse : undefined} disabled={readOnly}>
|
||||
{isSingleFilePicker ? 'Select File' : 'Select Files'}
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -17,7 +17,7 @@ import NTLMAuth from 'components/RequestPane/Auth/NTLMAuth';
|
||||
import WsseAuth from 'components/RequestPane/Auth/WsseAuth';
|
||||
import ApiKeyAuth from 'components/RequestPane/Auth/ApiKeyAuth';
|
||||
import AwsV4Auth from 'components/RequestPane/Auth/AwsV4Auth';
|
||||
import { findItemInCollection, findParentItemInCollection, humanizeRequestAuthMode } from 'utils/collections/index';
|
||||
import { humanizeRequestAuthMode, getTreePathFromCollectionToItem } from 'utils/collections/index';
|
||||
|
||||
const GrantTypeComponentMap = ({ collection, folder }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -48,16 +48,6 @@ const Auth = ({ collection, folder }) => {
|
||||
let request = get(folder, 'root.request', {});
|
||||
const authMode = get(folder, 'root.request.auth.mode');
|
||||
|
||||
const getTreePathFromCollectionToFolder = (collection, _folder) => {
|
||||
let path = [];
|
||||
let item = findItemInCollection(collection, _folder?.uid);
|
||||
while (item) {
|
||||
path.unshift(item);
|
||||
item = findParentItemInCollection(collection, item?.uid);
|
||||
}
|
||||
return path;
|
||||
};
|
||||
|
||||
const getEffectiveAuthSource = () => {
|
||||
if (authMode !== 'inherit') return null;
|
||||
|
||||
@@ -69,7 +59,7 @@ const Auth = ({ collection, folder }) => {
|
||||
};
|
||||
|
||||
// Get path from collection to current folder
|
||||
const folderTreePath = getTreePathFromCollectionToFolder(collection, folder);
|
||||
const folderTreePath = getTreePathFromCollectionToItem(collection, folder);
|
||||
|
||||
// Check parent folders to find closest auth configuration
|
||||
// Skip the last item which is the current folder
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
.current-environment {
|
||||
}
|
||||
.environment-active {
|
||||
padding: 0.3rem 0.4rem;
|
||||
color: ${(props) => props.theme.colors.text.yellow};
|
||||
border: solid 1px ${(props) => props.theme.colors.text.yellow} !important;
|
||||
}
|
||||
.environment-selector {
|
||||
.active: {
|
||||
color: ${(props) => props.theme.colors.text.yellow};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default Wrapper;
|
||||
@@ -1,100 +0,0 @@
|
||||
import React, { useRef, forwardRef, useState } from 'react';
|
||||
import find from 'lodash/find';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import { IconSettings, IconWorld, IconDatabase, IconDatabaseOff, IconCheck } from '@tabler/icons';
|
||||
import EnvironmentSettings from '../EnvironmentSettings';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { selectGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
|
||||
import ToolHint from 'components/ToolHint/index';
|
||||
|
||||
const EnvironmentSelector = () => {
|
||||
const dispatch = useDispatch();
|
||||
const dropdownTippyRef = useRef();
|
||||
const globalEnvironments = useSelector((state) => state.globalEnvironments.globalEnvironments);
|
||||
const activeGlobalEnvironmentUid = useSelector((state) => state.globalEnvironments.activeGlobalEnvironmentUid);
|
||||
const [openSettingsModal, setOpenSettingsModal] = useState(false);
|
||||
const activeEnvironment = activeGlobalEnvironmentUid ? find(globalEnvironments, (e) => e.uid === activeGlobalEnvironmentUid) : null;
|
||||
|
||||
const Icon = forwardRef((props, ref) => {
|
||||
return (
|
||||
<div ref={ref} className={`current-environment flex flex-row gap-1 rounded-xl text-xs cursor-pointer items-center justify-center select-none ${activeGlobalEnvironmentUid? 'environment-active': ''}`}>
|
||||
<ToolHint text="Global Environments" toolhintId="GlobalEnvironmentsToolhintId" className='flex flex-row'>
|
||||
<IconWorld className="globe" size={16} strokeWidth={1.5} />
|
||||
{
|
||||
activeEnvironment ? <div className='text-nowrap truncate max-w-32'>{activeEnvironment?.name}</div> : null
|
||||
}
|
||||
</ToolHint>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const handleSettingsIconClick = () => {
|
||||
setOpenSettingsModal(true);
|
||||
};
|
||||
|
||||
const handleModalClose = () => {
|
||||
setOpenSettingsModal(false);
|
||||
};
|
||||
|
||||
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
|
||||
|
||||
const onSelect = (environment) => {
|
||||
dispatch(selectGlobalEnvironment({ environmentUid: environment ? environment.uid : null }))
|
||||
.then(() => {
|
||||
if (environment) {
|
||||
toast.success(`Environment changed to ${environment.name}`);
|
||||
} else {
|
||||
toast.success(`No Environments are active now`);
|
||||
}
|
||||
})
|
||||
.catch((err) => console.log(err) && toast.error('An error occurred while selecting the environment'));
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="flex items-center cursor-pointer environment-selector mr-3">
|
||||
<Dropdown onCreate={onDropdownCreate} icon={<Icon />} placement="bottom-end" transparent={true}>
|
||||
<div className="label-item font-medium">Global Environments</div>
|
||||
{globalEnvironments && globalEnvironments.length
|
||||
? globalEnvironments.map((e) => (
|
||||
<div
|
||||
className={`dropdown-item ${e?.uid === activeGlobalEnvironmentUid ? 'active' : ''}`}
|
||||
key={e.uid}
|
||||
onClick={() => {
|
||||
onSelect(e);
|
||||
dropdownTippyRef.current.hide();
|
||||
}}
|
||||
>
|
||||
<IconDatabase size={18} strokeWidth={1.5} /> <span className="ml-2 break-all">{e.name}</span>
|
||||
</div>
|
||||
))
|
||||
: null}
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
dropdownTippyRef.current.hide();
|
||||
onSelect(null);
|
||||
}}
|
||||
>
|
||||
<IconDatabaseOff size={18} strokeWidth={1.5} />
|
||||
<span className="ml-2">No Environment</span>
|
||||
</div>
|
||||
<div className="dropdown-item border-top" onClick={() => {
|
||||
handleSettingsIconClick();
|
||||
dropdownTippyRef.current.hide();
|
||||
}}>
|
||||
<div className="pr-2 text-gray-600">
|
||||
<IconSettings size={18} strokeWidth={1.5} />
|
||||
</div>
|
||||
<span>Configure</span>
|
||||
</div>
|
||||
</Dropdown>
|
||||
</div>
|
||||
{openSettingsModal && <EnvironmentSettings globalEnvironments={globalEnvironments} activeGlobalEnvironmentUid={activeGlobalEnvironmentUid} onClose={handleModalClose} />}
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default EnvironmentSelector;
|
||||
@@ -8,7 +8,7 @@ import Modal from 'components/Modal';
|
||||
import { addGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
|
||||
import { validateName, validateNameError } from 'utils/common/regex';
|
||||
|
||||
const CreateEnvironment = ({ onClose }) => {
|
||||
const CreateEnvironment = ({ onClose, onEnvironmentCreated }) => {
|
||||
const globalEnvs = useSelector((state) => state?.globalEnvironments?.globalEnvironments);
|
||||
|
||||
const validateEnvironmentName = (name) => {
|
||||
@@ -39,6 +39,10 @@ const CreateEnvironment = ({ onClose }) => {
|
||||
.then(() => {
|
||||
toast.success('Global environment created!');
|
||||
onClose();
|
||||
// Call the callback if provided
|
||||
if (onEnvironmentCreated) {
|
||||
onEnvironmentCreated();
|
||||
}
|
||||
})
|
||||
.catch(() => toast.error('An error occurred while creating the environment'));
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import { IconTrash, IconAlertCircle } from '@tabler/icons';
|
||||
import { IconTrash, IconAlertCircle, IconInfoCircle } from '@tabler/icons';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import SingleLineEditor from 'components/SingleLineEditor';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import MultiLineEditor from 'components/MultiLineEditor/index';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { uuid } from 'utils/common';
|
||||
import { useFormik } from 'formik';
|
||||
@@ -12,11 +12,18 @@ import { variableNameRegex } from 'utils/common/regex';
|
||||
import toast from 'react-hot-toast';
|
||||
import { saveGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
|
||||
import { Tooltip } from 'react-tooltip';
|
||||
import { getGlobalEnvironmentVariables } from 'utils/collections';
|
||||
|
||||
const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentVariables }) => {
|
||||
const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentVariables, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
const addButtonRef = useRef(null);
|
||||
const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments);
|
||||
|
||||
let _collection = cloneDeep(collection);
|
||||
|
||||
const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid });
|
||||
_collection.globalEnvironmentVariables = globalEnvironmentVariables;
|
||||
|
||||
const formik = useFormik({
|
||||
enableReinitialize: true,
|
||||
@@ -34,7 +41,7 @@ const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentV
|
||||
secret: Yup.boolean(),
|
||||
type: Yup.string(),
|
||||
uid: Yup.string(),
|
||||
value: Yup.string().trim().nullable()
|
||||
value: Yup.mixed().nullable()
|
||||
})
|
||||
),
|
||||
onSubmit: (values) => {
|
||||
@@ -93,7 +100,7 @@ const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentV
|
||||
|
||||
useEffect(() => {
|
||||
if (formik.dirty) {
|
||||
// Smooth scrolling to the changed parameter is temporarily disabled
|
||||
// Smooth scrolling to the changed parameter is temporarily disabled
|
||||
// due to UX issues when editing the first row in a long list of environment variables.
|
||||
// addButtonRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
@@ -118,7 +125,7 @@ const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentV
|
||||
</thead>
|
||||
<tbody>
|
||||
{formik.values.map((variable, index) => (
|
||||
<tr key={variable.uid}>
|
||||
<tr key={variable.uid} data-testid={`env-var-row-${variable.name}`}>
|
||||
<td className="text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -129,7 +136,7 @@ const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentV
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex items-center">
|
||||
<div className="flex items-center" data-testid={`env-var-name-${index}`}>
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
@@ -145,16 +152,32 @@ const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentV
|
||||
<ErrorMessage name={`${index}.name`} />
|
||||
</div>
|
||||
</td>
|
||||
<td className="flex flex-row flex-nowrap">
|
||||
<div className="overflow-hidden grow w-full relative">
|
||||
<SingleLineEditor
|
||||
<td className="flex flex-row flex-nowrap items-center">
|
||||
<div className="overflow-hidden grow w-full relative" data-testid={`env-var-value-${index}`}>
|
||||
<MultiLineEditor
|
||||
theme={storedTheme}
|
||||
collection={_collection}
|
||||
name={`${index}.value`}
|
||||
value={variable.value}
|
||||
isSecret={variable.secret}
|
||||
readOnly={typeof variable.value !== 'string'}
|
||||
onChange={(newValue) => formik.setFieldValue(`${index}.value`, newValue, true)}
|
||||
/>
|
||||
</div>
|
||||
{typeof variable.value !== 'string' && (
|
||||
<span className="ml-2 flex items-center">
|
||||
<IconInfoCircle
|
||||
id={`${variable.name}-disabled-info-icon`}
|
||||
className="text-muted"
|
||||
size={16}
|
||||
/>
|
||||
<Tooltip
|
||||
anchorId={`${variable.name}-disabled-info-icon`}
|
||||
content="Non-string values set via scripts are read-only and can only be updated through scripts."
|
||||
place="top"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-center">
|
||||
<input
|
||||
@@ -179,6 +202,7 @@ const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentV
|
||||
ref={addButtonRef}
|
||||
className="btn-add-param text-link pr-2 py-3 mt-2 select-none"
|
||||
onClick={addVariable}
|
||||
data-testid="add-variable"
|
||||
>
|
||||
+ Add Variable
|
||||
</button>
|
||||
@@ -186,10 +210,10 @@ const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentV
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button type="submit" className="submit btn btn-md btn-secondary mt-2" onClick={formik.handleSubmit}>
|
||||
<button type="submit" className="submit btn btn-md btn-secondary mt-2" onClick={formik.handleSubmit} data-testid="save-env">
|
||||
Save
|
||||
</button>
|
||||
<button type="submit" className="ml-2 px-1 submit btn btn-md btn-secondary mt-2" onClick={handleReset}>
|
||||
<button type="submit" className="ml-2 px-1 submit btn btn-md btn-secondary mt-2" onClick={handleReset} data-testid="reset-env">
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -4,8 +4,9 @@ import CopyEnvironment from '../../CopyEnvironment';
|
||||
import DeleteEnvironment from '../../DeleteEnvironment';
|
||||
import RenameEnvironment from '../../RenameEnvironment';
|
||||
import EnvironmentVariables from './EnvironmentVariables';
|
||||
import ToolHint from 'components/ToolHint/index';
|
||||
|
||||
const EnvironmentDetails = ({ environment, setIsModified }) => {
|
||||
const EnvironmentDetails = ({ environment, setIsModified, collection, allEnvironments }) => {
|
||||
const [openEditModal, setOpenEditModal] = useState(false);
|
||||
const [openDeleteModal, setOpenDeleteModal] = useState(false);
|
||||
const [openCopyModal, setOpenCopyModal] = useState(false);
|
||||
@@ -29,15 +30,26 @@ const EnvironmentDetails = ({ environment, setIsModified }) => {
|
||||
<IconDatabase className="cursor-pointer" size={20} strokeWidth={1.5} />
|
||||
<span className="ml-1 font-semibold break-all">{environment.name}</span>
|
||||
</div>
|
||||
<div className="flex gap-x-4 pl-4">
|
||||
<IconEdit className="cursor-pointer" size={20} strokeWidth={1.5} onClick={() => setOpenEditModal(true)} />
|
||||
<IconCopy className="cursor-pointer" size={20} strokeWidth={1.5} onClick={() => setOpenCopyModal(true)} />
|
||||
<IconTrash className="cursor-pointer" size={20} strokeWidth={1.5} onClick={() => setOpenDeleteModal(true)} />
|
||||
<div className="flex gap-x-2 pl-2">
|
||||
<ToolHint text="Edit Environment" toolhintId={`edit-${environment.uid}`}>
|
||||
<IconEdit className="cursor-pointer" size={20} strokeWidth={1.5} onClick={() => setOpenEditModal(true)} />
|
||||
</ToolHint>
|
||||
<ToolHint text="Copy Environment" toolhintId={`copy-${environment.uid}`}>
|
||||
<IconCopy className="cursor-pointer" size={20} strokeWidth={1.5} onClick={() => setOpenCopyModal(true)} />
|
||||
</ToolHint>
|
||||
<ToolHint text="Delete Environment" toolhintId={`delete-${environment.uid}`}>
|
||||
<IconTrash className="cursor-pointer" size={20} strokeWidth={1.5} onClick={() => setOpenDeleteModal(true)} data-testid="delete-environment-button" />
|
||||
</ToolHint>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<EnvironmentVariables environment={environment} setIsModified={setIsModified} />
|
||||
<EnvironmentVariables
|
||||
environment={environment}
|
||||
setIsModified={setIsModified}
|
||||
collection={collection}
|
||||
allEnvironments={allEnvironments}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,15 +2,15 @@ import React, { useEffect, useState } from 'react';
|
||||
import usePrevious from 'hooks/usePrevious';
|
||||
import EnvironmentDetails from './EnvironmentDetails';
|
||||
import CreateEnvironment from '../CreateEnvironment';
|
||||
import { IconDownload, IconShieldLock } from '@tabler/icons';
|
||||
import { IconDownload, IconShieldLock, IconUpload } from '@tabler/icons';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import ConfirmSwitchEnv from './ConfirmSwitchEnv';
|
||||
import ManageSecrets from 'components/Environments/EnvironmentSettings/ManageSecrets/index';
|
||||
import ImportEnvironment from '../ImportEnvironment';
|
||||
import ImportEnvironmentModal from 'components/Environments/Common/ImportEnvironmentModal';
|
||||
import { isEqual } from 'lodash';
|
||||
import ToolHint from 'components/ToolHint/index';
|
||||
|
||||
const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironment, setSelectedEnvironment, isModified, setIsModified }) => {
|
||||
const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironment, setSelectedEnvironment, isModified, setIsModified, collection, setShowExportModal }) => {
|
||||
const [openCreateModal, setOpenCreateModal] = useState(false);
|
||||
const [openImportModal, setOpenImportModal] = useState(false);
|
||||
const [openManageSecretsModal, setOpenManageSecretsModal] = useState(false);
|
||||
@@ -38,7 +38,7 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme
|
||||
return;
|
||||
}
|
||||
|
||||
const environment = environments?.find(env => env.uid === activeEnvironmentUid) || environments?.[0];
|
||||
const environment = environments?.find((env) => env.uid === activeEnvironmentUid) || environments?.[0] || null;
|
||||
|
||||
setSelectedEnvironment(environment);
|
||||
setOriginalEnvironmentVariables(environment?.variables || []);
|
||||
@@ -90,6 +90,12 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme
|
||||
setOpenManageSecretsModal(true);
|
||||
};
|
||||
|
||||
const handleExportClick = () => {
|
||||
if (setShowExportModal) {
|
||||
setShowExportModal(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmSwitch = (saveChanges) => {
|
||||
if (!saveChanges) {
|
||||
setSwitchEnvConfirmClose(false);
|
||||
@@ -99,7 +105,7 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme
|
||||
return (
|
||||
<StyledWrapper>
|
||||
{openCreateModal && <CreateEnvironment onClose={() => setOpenCreateModal(false)} />}
|
||||
{openImportModal && <ImportEnvironment onClose={() => setOpenImportModal(false)} />}
|
||||
{openImportModal && <ImportEnvironmentModal type="global" onClose={() => setOpenImportModal(false)} />}
|
||||
{openManageSecretsModal && <ManageSecrets onClose={() => setOpenManageSecretsModal(false)} />}
|
||||
|
||||
<div className="flex">
|
||||
@@ -132,6 +138,10 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme
|
||||
<IconDownload size={12} strokeWidth={2} />
|
||||
<span className="label ml-1 text-xs">Import</span>
|
||||
</div>
|
||||
<div className="flex items-center mt-2" onClick={() => handleExportClick()}>
|
||||
<IconUpload size={12} strokeWidth={2} />
|
||||
<span className="label ml-1 text-xs">Export</span>
|
||||
</div>
|
||||
<div className="flex items-center mt-2" onClick={() => handleSecretsClick()}>
|
||||
<IconShieldLock size={12} strokeWidth={2} />
|
||||
<span className="label ml-1 text-xs">Managing Secrets</span>
|
||||
@@ -143,6 +153,8 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme
|
||||
environment={selectedEnvironment}
|
||||
setIsModified={setIsModified}
|
||||
originalEnvironmentVariables={originalEnvironmentVariables}
|
||||
collection={collection}
|
||||
allEnvironments={environments}
|
||||
/>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import React from 'react';
|
||||
import Portal from 'components/Portal';
|
||||
import Modal from 'components/Modal';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import importPostmanEnvironment from 'utils/importers/postman-environment';
|
||||
import { toastError } from 'utils/common/error';
|
||||
import { IconDatabaseImport } from '@tabler/icons';
|
||||
import { addGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
|
||||
import { uuid } from 'utils/common/index';
|
||||
|
||||
const ImportEnvironment = ({ onClose }) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const handleImportPostmanEnvironment = () => {
|
||||
importPostmanEnvironment()
|
||||
.then((environments) => {
|
||||
environments
|
||||
.filter((env) =>
|
||||
env.name && env.name !== 'undefined'
|
||||
? true
|
||||
: () => {
|
||||
toast.error('Failed to import environment: env has no name');
|
||||
return false;
|
||||
}
|
||||
)
|
||||
.map((environment) => {
|
||||
dispatch(addGlobalEnvironment({ name: environment.name, variables: environment.variables }))
|
||||
.then(() => {
|
||||
toast.success('Global Environment imported successfully');
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error('An error occurred while importing the environment');
|
||||
console.error(error);
|
||||
});
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
onClose();
|
||||
})
|
||||
.catch((err) => toastError(err, 'Postman Import environment failed'));
|
||||
};
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Modal size="sm" title="Import Global Environment" hideFooter={true} handleConfirm={onClose} handleCancel={onClose}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleImportPostmanEnvironment}
|
||||
className="flex justify-center flex-col items-center w-full dark:bg-zinc-700 rounded-lg border-2 border-dashed border-zinc-300 dark:border-zinc-400 p-12 text-center hover:border-zinc-400 focus:outline-none focus:ring-2 focus:ring-amber-500 focus:ring-offset-2"
|
||||
>
|
||||
<IconDatabaseImport size={64} />
|
||||
<span className="mt-2 block text-sm font-semibold">Import your Postman environments</span>
|
||||
</button>
|
||||
</Modal>
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImportEnvironment;
|
||||
@@ -4,7 +4,8 @@ import CreateEnvironment from './CreateEnvironment';
|
||||
import EnvironmentList from './EnvironmentList';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { IconFileAlert } from '@tabler/icons';
|
||||
import ImportEnvironment from './ImportEnvironment/index';
|
||||
import ImportEnvironmentModal from 'components/Environments/Common/ImportEnvironmentModal';
|
||||
import ExportEnvironmentModal from 'components/Environments/Common/ExportEnvironmentModal';
|
||||
|
||||
export const SharedButton = ({ children, className, onClick }) => {
|
||||
return (
|
||||
@@ -39,11 +40,12 @@ const DefaultTab = ({ setTab }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const EnvironmentSettings = ({ globalEnvironments, activeGlobalEnvironmentUid, onClose }) => {
|
||||
const EnvironmentSettings = ({ globalEnvironments, collection, activeGlobalEnvironmentUid, onClose }) => {
|
||||
const [isModified, setIsModified] = useState(false);
|
||||
const environments = globalEnvironments;
|
||||
const [selectedEnvironment, setSelectedEnvironment] = useState(null);
|
||||
const [tab, setTab] = useState('default');
|
||||
const [showExportModal, setShowExportModal] = useState(false);
|
||||
if (!environments || !environments.length) {
|
||||
return (
|
||||
<StyledWrapper>
|
||||
@@ -51,27 +53,37 @@ const EnvironmentSettings = ({ globalEnvironments, activeGlobalEnvironmentUid, o
|
||||
{tab === 'create' ? (
|
||||
<CreateEnvironment onClose={() => setTab('default')} />
|
||||
) : tab === 'import' ? (
|
||||
<ImportEnvironment onClose={() => setTab('default')} />
|
||||
<ImportEnvironmentModal type="global" onClose={() => setTab('default')} />
|
||||
) : (
|
||||
<></>
|
||||
<DefaultTab setTab={setTab} />
|
||||
)}
|
||||
<DefaultTab setTab={setTab} />
|
||||
</Modal>
|
||||
</StyledWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal size="lg" title="Global Environments" handleCancel={onClose} hideFooter={true}>
|
||||
<EnvironmentList
|
||||
environments={globalEnvironments}
|
||||
activeEnvironmentUid={activeGlobalEnvironmentUid}
|
||||
selectedEnvironment={selectedEnvironment}
|
||||
setSelectedEnvironment={setSelectedEnvironment}
|
||||
isModified={isModified}
|
||||
setIsModified={setIsModified}
|
||||
/>
|
||||
</Modal>
|
||||
<StyledWrapper>
|
||||
<Modal size="lg" title="Global Environments" handleCancel={onClose} hideFooter={true}>
|
||||
<EnvironmentList
|
||||
environments={globalEnvironments}
|
||||
activeEnvironmentUid={activeGlobalEnvironmentUid}
|
||||
selectedEnvironment={selectedEnvironment}
|
||||
setSelectedEnvironment={setSelectedEnvironment}
|
||||
isModified={isModified}
|
||||
setIsModified={setIsModified}
|
||||
collection={collection}
|
||||
setShowExportModal={setShowExportModal}
|
||||
/>
|
||||
</Modal>
|
||||
{showExportModal && (
|
||||
<ExportEnvironmentModal
|
||||
onClose={() => setShowExportModal(false)}
|
||||
environments={globalEnvironments}
|
||||
environmentType="global"
|
||||
/>
|
||||
)}
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
/* Screen reader only content */
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.command-k-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
overflow-y: auto;
|
||||
z-index: 20;
|
||||
background-color: transparent;
|
||||
&:before {
|
||||
content: '';
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
left: 0;
|
||||
opacity: ${(props) => props.theme.modal.backdrop.opacity};
|
||||
top: 0;
|
||||
background: black;
|
||||
position: fixed;
|
||||
}
|
||||
animation: fade-in 0.1s forwards cubic-bezier(0.19, 1, 0.22, 1);
|
||||
}
|
||||
.command-k-modal {
|
||||
background: ${(props) => props.theme.modal.body.bg};
|
||||
border: 1px solid ${(props) => props.theme.modal.input.border};
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
width: 90%;
|
||||
max-width: 600px;
|
||||
max-height: 70vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
margin: 80px auto;
|
||||
animation: fade-and-slide-in-from-top 0.3s forwards cubic-bezier(0.19, 1, 0.22, 1);
|
||||
will-change: opacity, transform;
|
||||
}
|
||||
.command-k-header {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid ${(props) => props.theme.modal.input.border};
|
||||
background: ${(props) => props.theme.modal.title.bg};
|
||||
}
|
||||
.search-input-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid ${(props) => props.theme.modal.input.border};
|
||||
border-radius: 6px;
|
||||
background: ${(props) => props.theme.modal.input.bg};
|
||||
transition: all 0.2s ease;
|
||||
&:focus-within {
|
||||
border-color: ${(props) => props.theme.colors.text.muted};
|
||||
box-shadow: 0 0 0 1px ${(props) => props.theme.colors.text.muted}40;
|
||||
}
|
||||
.search-icon {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
opacity: 0.8;
|
||||
margin-right: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.clear-button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 4px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
opacity: 0.8;
|
||||
margin-left: 8px;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.mode === 'dark' ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)'};
|
||||
}
|
||||
}
|
||||
}
|
||||
.search-input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: ${(props) => props.theme.text};
|
||||
font-size: 13px;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
&::placeholder {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
.command-k-results {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
max-height: 400px;
|
||||
background: ${(props) => props.theme.modal.body.bg};
|
||||
scrollbar-width: thin;
|
||||
padding: 4px;
|
||||
scroll-behavior: smooth;
|
||||
/* Webkit scrollbar styling */
|
||||
&::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: ${(props) => props.theme.mode === 'dark' ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.2)'};
|
||||
border-radius: 4px;
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.mode === 'dark' ? 'rgba(255,255,255,0.3)' : 'rgba(0,0,0,0.3)'};
|
||||
}
|
||||
}
|
||||
}
|
||||
.result-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
border-left: 2px solid transparent;
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.mode === 'dark' ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'};
|
||||
}
|
||||
&.selected {
|
||||
background: ${(props) => `${props.theme.colors.text.yellow}15`};
|
||||
border-left: 2px solid ${(props) => props.theme.colors.text.yellow};
|
||||
}
|
||||
}
|
||||
.result-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
opacity: 0.8;
|
||||
}
|
||||
.result-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.result-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin-right: 8px;
|
||||
}
|
||||
.result-badges {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.result-name {
|
||||
font-size: 13px;
|
||||
margin-bottom: 3px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: ${(props) => props.theme.text};
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
.result-path {
|
||||
font-size: 12px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
letter-spacing: 0.1px;
|
||||
}
|
||||
.method-badge {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
flex-shrink: 0;
|
||||
min-width: 55px;
|
||||
text-align: center;
|
||||
&.get {
|
||||
color: #2ecc71;
|
||||
background: rgba(46, 204, 113, 0.1);
|
||||
}
|
||||
&.post {
|
||||
color: #3498db;
|
||||
background: rgba(52, 152, 219, 0.1);
|
||||
}
|
||||
&.put {
|
||||
color: #e67e22;
|
||||
background: rgba(230, 126, 34, 0.1);
|
||||
}
|
||||
&.delete {
|
||||
color: #e74c3c;
|
||||
background: rgba(231, 76, 60, 0.1);
|
||||
}
|
||||
&.patch {
|
||||
color: #9b59b6;
|
||||
background: rgba(155, 89, 182, 0.1);
|
||||
}
|
||||
&.head {
|
||||
color: #2980b9;
|
||||
background: rgba(41, 128, 185, 0.1);
|
||||
}
|
||||
&.options {
|
||||
color: #f1c40f;
|
||||
background: rgba(241, 196, 15, 0.1);
|
||||
}
|
||||
&.unary {
|
||||
color: #27ae60;
|
||||
background: rgba(39, 174, 96, 0.12);
|
||||
font-weight: 600;
|
||||
}
|
||||
&.client-streaming {
|
||||
color: #2980b9;
|
||||
background: rgba(41, 128, 185, 0.12);
|
||||
font-weight: 600;
|
||||
}
|
||||
&.server-streaming {
|
||||
color: #f39c12;
|
||||
background: rgba(243, 156, 18, 0.12);
|
||||
font-weight: 600;
|
||||
}
|
||||
&.bidirectional-streaming,
|
||||
&.bidi-streaming {
|
||||
color: #8e44ad;
|
||||
background: rgba(142, 68, 173, 0.12);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
.result-type {
|
||||
font-size: 11px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
background: ${(props) => props.theme.mode === 'dark' ? 'rgba(255,255,255,0.03)' : 'rgba(0,0,0,0.03)'};
|
||||
opacity: 0.8;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.result-item[data-type="documentation"] {
|
||||
.result-icon {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
opacity: 0.8;
|
||||
}
|
||||
.result-path {
|
||||
font-size: 12px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
letter-spacing: 0.1px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
&:hover:not(.selected) {
|
||||
background: ${(props) => props.theme.mode === 'dark' ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'};
|
||||
}
|
||||
}
|
||||
.no-results,
|
||||
.empty-state {
|
||||
padding: 24px 16px;
|
||||
text-align: center;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
font-size: 13px;
|
||||
}
|
||||
.command-k-footer {
|
||||
padding: 8px 12px;
|
||||
border-top: 1px solid ${(props) => props.theme.modal.input.border};
|
||||
background: ${(props) => props.theme.colors.surface};
|
||||
}
|
||||
.keyboard-hints {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 24px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.2px;
|
||||
span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
.hint-icon {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
opacity: 0.8;
|
||||
}
|
||||
.hint-icon + .hint-icon {
|
||||
margin-left: -8px;
|
||||
}
|
||||
.keycap {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px 6px;
|
||||
border: 1px solid ${(props) => props.theme.modal.input.border};
|
||||
border-radius: 4px;
|
||||
background: ${(props) =>
|
||||
props.theme.mode === 'dark' ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)'};
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
line-height: 1;
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
}
|
||||
}
|
||||
.highlight {
|
||||
background: ${(props) => `${props.theme.colors.text.yellow}30`};
|
||||
border-radius: 2px;
|
||||
padding: 0 2px;
|
||||
margin: 0 -1px;
|
||||
font-weight: 500;
|
||||
}
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@keyframes fade-and-slide-in-from-top {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,32 @@
|
||||
export const SEARCH_TYPES = {
|
||||
DOCUMENTATION: 'documentation',
|
||||
COLLECTION: 'collection',
|
||||
FOLDER: 'folder',
|
||||
REQUEST: 'request'
|
||||
};
|
||||
|
||||
export const MATCH_TYPES = {
|
||||
COLLECTION: 'collection',
|
||||
FOLDER: 'folder',
|
||||
REQUEST: 'request',
|
||||
URL: 'url',
|
||||
PATH: 'path',
|
||||
DOCUMENTATION: 'documentation'
|
||||
};
|
||||
|
||||
export const SEARCH_CONFIG = {
|
||||
MAX_DEPTH: 20,
|
||||
FOCUS_DELAY: 100,
|
||||
SCROLL_BEHAVIOR: 'smooth',
|
||||
SCROLL_BLOCK: 'nearest',
|
||||
DEBOUNCE_DELAY: 300
|
||||
};
|
||||
|
||||
export const DOCUMENTATION_RESULT = {
|
||||
type: SEARCH_TYPES.DOCUMENTATION,
|
||||
item: { id: 'docs', name: 'Bruno Documentation' },
|
||||
name: 'Bruno Documentation',
|
||||
path: '/',
|
||||
description: 'Browse the official Bruno documentation',
|
||||
matchType: MATCH_TYPES.DOCUMENTATION
|
||||
};
|
||||
516
packages/bruno-app/src/components/GlobalSearchModal/index.js
Normal file
516
packages/bruno-app/src/components/GlobalSearchModal/index.js
Normal file
@@ -0,0 +1,516 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import {
|
||||
IconSearch,
|
||||
IconX,
|
||||
IconFolder,
|
||||
IconBox,
|
||||
IconFileText,
|
||||
IconBook
|
||||
} from '@tabler/icons';
|
||||
import { flattenItems, isItemARequest, isItemAFolder, findParentItemInCollection } from 'utils/collections';
|
||||
import { addTab, focusTab } from 'providers/ReduxStore/slices/tabs';
|
||||
import { hideHomePage } from 'providers/ReduxStore/slices/app';
|
||||
import { toggleCollectionItem, toggleCollection } from 'providers/ReduxStore/slices/collections';
|
||||
import { mountCollection } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { getDefaultRequestPaneTab } from 'utils/collections';
|
||||
import { normalizeQuery, isValidQuery, highlightText, sortResults, getTypeLabel, getItemPath } from './utils/searchUtils';
|
||||
import { SEARCH_TYPES, MATCH_TYPES, SEARCH_CONFIG, DOCUMENTATION_RESULT } from './constants';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const GlobalSearchModal = ({ isOpen, onClose }) => {
|
||||
const [query, setQuery] = useState('');
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [results, setResults] = useState([]);
|
||||
const inputRef = useRef(null);
|
||||
const resultsRef = useRef(null);
|
||||
const debounceTimeoutRef = useRef(null);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const collections = useSelector((state) => state.collections.collections);
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
|
||||
const createCollectionResults = () => {
|
||||
const collectionResults = collections.map(collection => ({
|
||||
type: SEARCH_TYPES.COLLECTION,
|
||||
item: collection,
|
||||
name: collection.name,
|
||||
path: collection.name,
|
||||
matchType: MATCH_TYPES.COLLECTION,
|
||||
collectionUid: collection.uid
|
||||
}));
|
||||
|
||||
collectionResults.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return [DOCUMENTATION_RESULT, ...collectionResults];
|
||||
};
|
||||
|
||||
const searchInCollections = (searchTerms, enablePathMatch) => {
|
||||
const results = [];
|
||||
|
||||
// Check for documentation match
|
||||
const queryLower = searchTerms.join(' ');
|
||||
if (['documentation', 'docs', 'bruno docs'].some(term => term.includes(queryLower))) {
|
||||
results.push(DOCUMENTATION_RESULT);
|
||||
}
|
||||
|
||||
collections.forEach(collection => {
|
||||
// Search collection name
|
||||
if (searchTerms.every(term => collection.name.toLowerCase().includes(term))) {
|
||||
results.push({
|
||||
type: SEARCH_TYPES.COLLECTION,
|
||||
item: collection,
|
||||
name: collection.name,
|
||||
path: collection.name,
|
||||
matchType: MATCH_TYPES.COLLECTION,
|
||||
collectionUid: collection.uid
|
||||
});
|
||||
}
|
||||
|
||||
// Search collection items
|
||||
const flattenedItems = flattenItems(collection.items);
|
||||
flattenedItems.forEach(item => {
|
||||
const itemPath = getItemPath(item, collection, findParentItemInCollection);
|
||||
const itemPathLower = itemPath.toLowerCase();
|
||||
|
||||
if (isItemARequest(item)) {
|
||||
// add an optional check for the item name to prevent a crash if it doesn’t exist.
|
||||
const nameMatch = searchTerms.every((term) => (item.name || '').toLowerCase().includes(term));
|
||||
const urlMatch = searchTerms.every(term => (item.request?.url || '').toLowerCase().includes(term));
|
||||
const pathMatch = enablePathMatch && searchTerms.every(term => itemPathLower.includes(term));
|
||||
|
||||
if (nameMatch || urlMatch || pathMatch) {
|
||||
// Check if this is a gRPC request and get the method type
|
||||
const isGrpcRequest = item.request?.type === 'grpc';
|
||||
|
||||
let method = item.request?.method || '';
|
||||
|
||||
if (isGrpcRequest) {
|
||||
// For gRPC requests, use the methodType
|
||||
const methodType = item.request?.methodType || 'UNARY';
|
||||
method = methodType.toLowerCase().replace(/[_]/g, '-');
|
||||
}
|
||||
|
||||
results.push({
|
||||
type: SEARCH_TYPES.REQUEST,
|
||||
item,
|
||||
name: item.name,
|
||||
path: itemPath,
|
||||
matchType: nameMatch ? MATCH_TYPES.REQUEST : urlMatch ? MATCH_TYPES.URL : MATCH_TYPES.PATH,
|
||||
method,
|
||||
collectionUid: collection.uid
|
||||
});
|
||||
}
|
||||
} else if (isItemAFolder(item)) {
|
||||
const nameMatch = searchTerms.every(term => item.name.toLowerCase().includes(term));
|
||||
const pathMatch = enablePathMatch && searchTerms.every(term => itemPathLower.includes(term));
|
||||
|
||||
if (nameMatch || pathMatch) {
|
||||
results.push({
|
||||
type: SEARCH_TYPES.FOLDER,
|
||||
item,
|
||||
name: item.name,
|
||||
path: itemPath,
|
||||
matchType: nameMatch ? MATCH_TYPES.FOLDER : MATCH_TYPES.PATH,
|
||||
collectionUid: collection.uid
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
const performSearch = (searchQuery) => {
|
||||
const normalizedQuery = normalizeQuery(searchQuery);
|
||||
|
||||
if (!normalizedQuery) {
|
||||
setResults(createCollectionResults());
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isValidQuery(normalizedQuery)) {
|
||||
setResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const searchTerms = normalizedQuery.toLowerCase().split(/[\s\/]+/).filter(Boolean);
|
||||
if (!searchTerms.length) {
|
||||
setResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const enablePathMatch = normalizedQuery.includes('/');
|
||||
const searchResults = searchInCollections(searchTerms, enablePathMatch);
|
||||
const sortedResults = sortResults(searchResults);
|
||||
|
||||
setResults(sortedResults);
|
||||
setSelectedIndex(0);
|
||||
};
|
||||
|
||||
const debouncedSearch = useCallback((searchQuery) => {
|
||||
// Clear existing timeout
|
||||
if (debounceTimeoutRef.current) {
|
||||
clearTimeout(debounceTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Set new timeout
|
||||
debounceTimeoutRef.current = setTimeout(() => {
|
||||
performSearch(searchQuery);
|
||||
}, SEARCH_CONFIG.DEBOUNCE_DELAY);
|
||||
}, [collections]); // Depend on collections to recreate when they change
|
||||
|
||||
const expandItemPath = (result) => {
|
||||
const collection = collections.find(c => c.uid === result.collectionUid);
|
||||
if (!collection) return;
|
||||
|
||||
ensureCollectionIsMounted(collection);
|
||||
|
||||
if (collection.collapsed) {
|
||||
dispatch(toggleCollection(collection.uid));
|
||||
}
|
||||
|
||||
let currentItem = result.type === SEARCH_TYPES.FOLDER
|
||||
? result.item
|
||||
: findParentItemInCollection(collection, result.item.uid);
|
||||
|
||||
while (currentItem?.type === 'folder') {
|
||||
if (currentItem.collapsed) {
|
||||
dispatch(toggleCollectionItem({ collectionUid: collection.uid, itemUid: currentItem.uid }));
|
||||
}
|
||||
currentItem = findParentItemInCollection(collection, currentItem.uid);
|
||||
}
|
||||
};
|
||||
|
||||
const ensureCollectionIsMounted = (collection) => {
|
||||
if (!collection || collection.mountStatus === 'mounted') return;
|
||||
dispatch(mountCollection({
|
||||
collectionUid: collection.uid,
|
||||
collectionPathname: collection.pathname,
|
||||
brunoConfig: collection.brunoConfig
|
||||
}));
|
||||
};
|
||||
|
||||
const handleKeyNavigation = (e) => {
|
||||
const handlers = {
|
||||
ArrowDown: () => {
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev => prev < results.length - 1 ? prev + 1 : 0);
|
||||
},
|
||||
ArrowUp: () => {
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev => prev > 0 ? prev - 1 : results.length - 1);
|
||||
},
|
||||
Enter: () => {
|
||||
e.preventDefault();
|
||||
if (results[selectedIndex]) {
|
||||
handleResultSelection(results[selectedIndex]);
|
||||
}
|
||||
},
|
||||
Escape: () => {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
},
|
||||
PageDown: () => {
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev => Math.min(prev + 5, results.length - 1));
|
||||
},
|
||||
PageUp: () => {
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev => Math.max(prev - 5, 0));
|
||||
},
|
||||
Home: () => {
|
||||
e.preventDefault();
|
||||
setSelectedIndex(0);
|
||||
},
|
||||
End: () => {
|
||||
e.preventDefault();
|
||||
setSelectedIndex(results.length - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handler = handlers[e.key];
|
||||
if (handler) handler();
|
||||
};
|
||||
|
||||
const handleResultSelection = (result) => {
|
||||
const targetCollection = collections.find(c => c.uid === result.collectionUid);
|
||||
ensureCollectionIsMounted(targetCollection);
|
||||
|
||||
if (result.type === SEARCH_TYPES.DOCUMENTATION) {
|
||||
window.open('https://docs.usebruno.com/', '_blank');
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
expandItemPath(result);
|
||||
|
||||
if (result.type === SEARCH_TYPES.REQUEST) {
|
||||
dispatch(hideHomePage());
|
||||
|
||||
const existingTab = tabs.find(tab => tab.uid === result.item.uid);
|
||||
|
||||
if (existingTab) {
|
||||
dispatch(focusTab({ uid: result.item.uid }));
|
||||
} else {
|
||||
dispatch(addTab({
|
||||
uid: result.item.uid,
|
||||
collectionUid: result.collectionUid,
|
||||
requestPaneTab: getDefaultRequestPaneTab(result.item),
|
||||
type: 'request',
|
||||
}));
|
||||
}
|
||||
} else if (result.type === SEARCH_TYPES.FOLDER) {
|
||||
dispatch(addTab({
|
||||
uid: result.item.uid,
|
||||
collectionUid: result.collectionUid,
|
||||
type: 'folder-settings',
|
||||
}));
|
||||
} else if (result.type === SEARCH_TYPES.COLLECTION) {
|
||||
dispatch(addTab({
|
||||
uid: result.item.uid,
|
||||
collectionUid: result.collectionUid,
|
||||
type: 'collection-settings',
|
||||
}));
|
||||
}
|
||||
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleQueryChange = (e) => {
|
||||
const newQuery = e.target.value;
|
||||
setQuery(newQuery);
|
||||
|
||||
if (newQuery.trim()) {
|
||||
debouncedSearch(newQuery);
|
||||
} else {
|
||||
// For empty queries, search immediately to show collections
|
||||
performSearch(newQuery);
|
||||
}
|
||||
};
|
||||
|
||||
const clearSearch = () => {
|
||||
// Clear any pending debounced search
|
||||
if (debounceTimeoutRef.current) {
|
||||
clearTimeout(debounceTimeoutRef.current);
|
||||
}
|
||||
|
||||
setQuery('');
|
||||
setResults([]);
|
||||
};
|
||||
|
||||
// Initialize modal when opened
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
const timeoutId = setTimeout(() => inputRef.current?.focus(), SEARCH_CONFIG.FOCUS_DELAY);
|
||||
setQuery('');
|
||||
performSearch('');
|
||||
setSelectedIndex(0);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
} else {
|
||||
// Clear any pending debounced search when modal closes
|
||||
if (debounceTimeoutRef.current) {
|
||||
clearTimeout(debounceTimeoutRef.current);
|
||||
}
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Auto-scroll selected item into view
|
||||
useEffect(() => {
|
||||
if (resultsRef.current && results.length > 0) {
|
||||
const selectedElement = resultsRef.current.children[selectedIndex];
|
||||
selectedElement?.scrollIntoView({
|
||||
behavior: SEARCH_CONFIG.SCROLL_BEHAVIOR,
|
||||
block: SEARCH_CONFIG.SCROLL_BLOCK
|
||||
});
|
||||
}
|
||||
}, [selectedIndex, results]);
|
||||
|
||||
// Cleanup debounce timeout on unmount or modal close
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceTimeoutRef.current) {
|
||||
clearTimeout(debounceTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const getResultIcon = (type) => {
|
||||
const iconMap = {
|
||||
[SEARCH_TYPES.DOCUMENTATION]: IconBook,
|
||||
[SEARCH_TYPES.COLLECTION]: IconBox,
|
||||
[SEARCH_TYPES.FOLDER]: IconFolder,
|
||||
[SEARCH_TYPES.REQUEST]: IconFileText
|
||||
};
|
||||
const IconComponent = iconMap[type] || IconFileText;
|
||||
return <IconComponent size={18} stroke={1.5} />;
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div
|
||||
className="command-k-overlay"
|
||||
onClick={onClose}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="search-modal-title"
|
||||
aria-describedby="search-modal-description"
|
||||
>
|
||||
<div className="command-k-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<h1 id="search-modal-title" className="sr-only">Global Search</h1>
|
||||
<p id="search-modal-description" className="sr-only">
|
||||
Search through collections, requests, folders, and documentation. Use arrow keys to navigate results and Enter to select.
|
||||
</p>
|
||||
<div aria-live="polite" aria-atomic="true" className="sr-only">
|
||||
{results.length > 0 && query
|
||||
? `${results.length} result${results.length === 1 ? '' : 's'} found`
|
||||
: query && results.length === 0
|
||||
? 'No results found'
|
||||
: ''
|
||||
}
|
||||
</div>
|
||||
<div className="command-k-header">
|
||||
<div className="search-input-container">
|
||||
<IconSearch size={20} className="search-icon" aria-hidden="true" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder="Search collections, requests, or documentation..."
|
||||
value={query}
|
||||
onChange={handleQueryChange}
|
||||
onKeyDown={handleKeyNavigation}
|
||||
className="search-input"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
aria-label="Search collections, requests, or documentation"
|
||||
aria-expanded={results.length > 0}
|
||||
aria-controls="search-results"
|
||||
aria-activedescendant={results.length > 0 ? `search-result-${selectedIndex}` : undefined}
|
||||
role="combobox"
|
||||
aria-autocomplete="list"
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
onClick={clearSearch}
|
||||
className="clear-button"
|
||||
aria-label="Clear search query"
|
||||
type="button"
|
||||
>
|
||||
<IconX size={16} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="command-k-results"
|
||||
ref={resultsRef}
|
||||
id="search-results"
|
||||
role="listbox"
|
||||
aria-label="Search results"
|
||||
>
|
||||
{results.length === 0 && query ? (
|
||||
<div className="no-results">
|
||||
<p>
|
||||
No results found for "{query}".
|
||||
<br />
|
||||
<span className="block mt-2">
|
||||
The item might not exist yet, or its collection isn’t mounted. Press <strong>Enter</strong> here (or open it from the sidebar) to mount the collection automatically.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
) : results.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<p>
|
||||
No collections are currently mounted or visible.
|
||||
<br />
|
||||
<span className="block mt-2">
|
||||
Mount a collection via the sidebar or this search modal, then try again.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
results.map((result, index) => {
|
||||
const isSelected = index === selectedIndex;
|
||||
const typeLabel = getTypeLabel(result.type);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${result.type}-${result.item.id || result.item.uid}-${index}`}
|
||||
id={`search-result-${index}`}
|
||||
className={`result-item ${isSelected ? 'selected' : ''}`}
|
||||
onClick={() => handleResultSelection(result)}
|
||||
data-selected={isSelected}
|
||||
data-type={result.type}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
aria-label={`${result.name}, ${typeLabel || result.type}${result.method ? `, ${result.method}` : ''}`}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div className="result-icon">
|
||||
{getResultIcon(result.type)}
|
||||
</div>
|
||||
<div className="result-content">
|
||||
<div className="result-info">
|
||||
<div className="result-name">
|
||||
{highlightText(result.name, query)}
|
||||
</div>
|
||||
<div className="result-path">
|
||||
{result.type === SEARCH_TYPES.DOCUMENTATION
|
||||
? result.description
|
||||
: result.type === SEARCH_TYPES.REQUEST
|
||||
? highlightText(result.item.request?.url || '', query)
|
||||
: highlightText(result.path, query)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="result-badges">
|
||||
{result.type === SEARCH_TYPES.REQUEST && result.method && (
|
||||
<span
|
||||
className={`method-badge ${result.method.toLowerCase()}`}
|
||||
aria-label={`HTTP method ${result.method.toUpperCase().replace(/-/g, ' ')}`}
|
||||
>
|
||||
{result.method.toUpperCase().replace(/-/g, ' ')}
|
||||
</span>
|
||||
)}
|
||||
{typeLabel && (
|
||||
<div className="result-type" aria-label={`Item type ${typeLabel}`}>
|
||||
{typeLabel}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="command-k-footer">
|
||||
<div className="keyboard-hints" role="region" aria-label="Keyboard shortcuts">
|
||||
<span aria-label="Use up and down arrows to navigate">
|
||||
<span className="keycap" aria-hidden="true">↑</span>
|
||||
<span className="keycap" aria-hidden="true">↓</span>
|
||||
<span className="hint-label">to navigate</span>
|
||||
</span>
|
||||
<span aria-label="Press Enter to select">
|
||||
<span className="keycap" aria-hidden="true">↵</span>
|
||||
<span className="hint-label">to select</span>
|
||||
</span>
|
||||
<span aria-label="Press Escape to close">
|
||||
<span className="keycap" aria-hidden="true">esc</span>
|
||||
<span className="hint-label">to close</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default GlobalSearchModal;
|
||||
@@ -0,0 +1,94 @@
|
||||
import React from 'react';
|
||||
import { SEARCH_TYPES, MATCH_TYPES, SEARCH_CONFIG } from '../constants';
|
||||
|
||||
export const normalizeQuery = (searchQuery) => {
|
||||
return searchQuery.trim().replace(/\/+/g, '/');
|
||||
};
|
||||
|
||||
export const isValidQuery = (normalizedQuery) => {
|
||||
return normalizedQuery &&
|
||||
normalizedQuery !== '/' &&
|
||||
!(normalizedQuery.length === 1 && !normalizedQuery.match(/[a-zA-Z0-9]/));
|
||||
};
|
||||
|
||||
export const highlightText = (text, searchQuery) => {
|
||||
if (!searchQuery) return text;
|
||||
|
||||
try {
|
||||
const escapedQuery = searchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const regex = new RegExp(`(${escapedQuery})`, 'gi');
|
||||
return text.split(regex).map((part, i) =>
|
||||
regex.test(part) ? (
|
||||
<span key={i} className="highlight">{part}</span>
|
||||
) : part
|
||||
);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
};
|
||||
|
||||
export const sortResults = (results) => {
|
||||
return results.sort((a, b) => {
|
||||
// Documentation always first
|
||||
if (a.type === SEARCH_TYPES.DOCUMENTATION) return -1;
|
||||
if (b.type === SEARCH_TYPES.DOCUMENTATION) return 1;
|
||||
|
||||
// Sort by match type priority
|
||||
const matchTypeOrder = {
|
||||
[MATCH_TYPES.COLLECTION]: 0,
|
||||
[MATCH_TYPES.FOLDER]: 1,
|
||||
[MATCH_TYPES.REQUEST]: 2,
|
||||
[MATCH_TYPES.URL]: 3,
|
||||
[MATCH_TYPES.PATH]: 4
|
||||
};
|
||||
const aMatchType = matchTypeOrder[a.matchType] ?? 5;
|
||||
const bMatchType = matchTypeOrder[b.matchType] ?? 5;
|
||||
|
||||
if (aMatchType !== bMatchType) return aMatchType - bMatchType;
|
||||
|
||||
// Sort by type priority
|
||||
const typeOrder = {
|
||||
[SEARCH_TYPES.COLLECTION]: 0,
|
||||
[SEARCH_TYPES.FOLDER]: 1,
|
||||
[SEARCH_TYPES.REQUEST]: 2
|
||||
};
|
||||
const aType = typeOrder[a.type] ?? 3;
|
||||
const bType = typeOrder[b.type] ?? 3;
|
||||
|
||||
if (aType !== bType) return aType - bType;
|
||||
|
||||
// Finally sort alphabetically
|
||||
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
|
||||
});
|
||||
};
|
||||
|
||||
export const getTypeLabel = (type) => {
|
||||
const baseLabels = {
|
||||
[SEARCH_TYPES.DOCUMENTATION]: 'Documentation',
|
||||
[SEARCH_TYPES.COLLECTION]: 'Collection',
|
||||
[SEARCH_TYPES.FOLDER]: 'Folder'
|
||||
};
|
||||
|
||||
return baseLabels[type] || '';
|
||||
};
|
||||
|
||||
export const getItemPath = (item, collection, findParentItemInCollection) => {
|
||||
const pathParts = [];
|
||||
let currentItem = item;
|
||||
let depth = 0;
|
||||
const maxDepth = SEARCH_CONFIG.MAX_DEPTH;
|
||||
|
||||
while (currentItem && depth < maxDepth) {
|
||||
pathParts.unshift(currentItem.name);
|
||||
const parent = findParentItemInCollection(collection, currentItem.uid);
|
||||
if (parent) {
|
||||
currentItem = parent;
|
||||
depth++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
pathParts.unshift(collection.name);
|
||||
return pathParts.join('/');
|
||||
};
|
||||
21
packages/bruno-app/src/components/Icons/ExampleIcon/index.js
Normal file
21
packages/bruno-app/src/components/Icons/ExampleIcon/index.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
|
||||
const ExampleIcon = ({ color = 'white', size = 16, ...props }) => {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<g clipPath="url(#clip0_486_1191)">
|
||||
<path d="M2.66699 3.33329C2.66699 3.15648 2.73723 2.98691 2.86225 2.86189C2.98728 2.73686 3.15685 2.66663 3.33366 2.66663H12.667C12.8438 2.66663 13.0134 2.73686 13.1384 2.86189C13.2634 2.98691 13.3337 3.15648 13.3337 3.33329V12.6666C13.3337 12.8434 13.2634 13.013 13.1384 13.138C13.0134 13.2631 12.8438 13.3333 12.667 13.3333H3.33366C3.15685 13.3333 2.98728 13.2631 2.86225 13.138C2.73723 13.013 2.66699 12.8434 2.66699 12.6666V3.33329Z" stroke={color} stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M9.33366 5.33337H6.66699V10.6667H9.33366" stroke={color} stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M9.33366 8H6.66699" stroke={color} stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_486_1191">
|
||||
<rect width={size} height={size} fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
);
|
||||
};
|
||||
|
||||
export default ExampleIcon;
|
||||
@@ -90,4 +90,4 @@ export const IconGrpcBidiStreaming = ({ size = 18, strokeWidth = 1.5, className
|
||||
<path d="M6 13l-3 3l3 3" stroke="#F97316" strokeWidth={strokeWidth} />
|
||||
<path d="M10 13l-3 3l3 3" stroke="#F97316" strokeWidth={strokeWidth} />
|
||||
</svg>
|
||||
);
|
||||
);
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import React from 'react';
|
||||
|
||||
const IconCaretDown = ({ color = '#8C8C8C', ...props }) => {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<g clipPath="url(#clip0_464_9256)">
|
||||
<path d="M10.5444 5.75H4.46004C4.26888 5.7509 4.08142 5.78521 3.91637 5.84952C3.75132 5.91383 3.61447 6.00587 3.51947 6.11647C3.42448 6.22706 3.37466 6.35234 3.375 6.47978C3.37534 6.60723 3.42583 6.73238 3.52142 6.84275L6.56492 10.23C6.66228 10.3372 6.79942 10.4258 6.96311 10.4874C7.1268 10.5489 7.31151 10.5813 7.49945 10.5814C7.68739 10.5816 7.8722 10.5494 8.03608 10.4881C8.19995 10.4267 8.33735 10.3383 8.43504 10.2312L11.4763 6.8465C11.573 6.73635 11.6246 6.61118 11.626 6.48355C11.6273 6.35591 11.5783 6.23028 11.4839 6.11924C11.3895 6.0082 11.253 5.91564 11.088 5.85084C10.9231 5.78603 10.7359 5.75126 10.5444 5.75Z" fill="#8C8C8C" />
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_464_9256">
|
||||
<rect width="9" height="6" fill="white" transform="translate(3 5)" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default IconCaretDown;
|
||||
@@ -0,0 +1,11 @@
|
||||
import React from 'react';
|
||||
|
||||
const IconCheckMark = ({ color = '#cccccc', size = 16, ...props }) => {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 16 17" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path d="M3.3335 8.49996L6.66683 11.8333L13.3335 5.16663" stroke={color} strokeWidth="1.33333" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default IconCheckMark;
|
||||
19
packages/bruno-app/src/components/Icons/IconEdit/index.js
Normal file
19
packages/bruno-app/src/components/Icons/IconEdit/index.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
|
||||
const IconEdit = ({ color = '#F39D0E', size = 16, ...props }) => {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<g clipPath="url(#clip0_464_9527)">
|
||||
<path d="M12.6665 13.3332H5.66654L2.85988 10.4665C2.73571 10.3416 2.66602 10.1727 2.66602 9.99654C2.66602 9.82042 2.73571 9.65145 2.85988 9.52654L9.52654 2.85988C9.65145 2.73571 9.82042 2.66602 9.99654 2.66602C10.1727 2.66602 10.3416 2.73571 10.4665 2.85988L13.7999 6.19321C13.924 6.31812 13.9937 6.48709 13.9937 6.66321C13.9937 6.83933 13.924 7.0083 13.7999 7.13321L7.66654 13.3332" stroke={color} strokeWidth="1.33333" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M11.9998 8.86663L7.7998 4.66663" stroke={color} strokeWidth="1.33333" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_464_9527">
|
||||
<rect width={size} height={size} fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default IconEdit;
|
||||
@@ -0,0 +1,90 @@
|
||||
import React from 'react';
|
||||
import { IconChevronDown, IconX } from '@tabler/icons';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
|
||||
const InheritableSettingsInput = ({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
description,
|
||||
onKeyDown,
|
||||
isInherited,
|
||||
onDropdownSelect,
|
||||
onValueChange,
|
||||
onCustomValueReset
|
||||
}) => {
|
||||
const { theme } = useTheme();
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col">
|
||||
<label className="text-xs font-medium text-gray-900 dark:text-gray-100" htmlFor={id}>
|
||||
{label}
|
||||
</label>
|
||||
{description && (
|
||||
<p className="text-xs text-gray-700 dark:text-gray-400">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-end">
|
||||
{isInherited ? (
|
||||
<Dropdown
|
||||
icon={(
|
||||
<button
|
||||
type="button"
|
||||
className="px-2 py-1 text-xs rounded-sm outline-none transition-colors duration-100 w-24 h-8 flex items-center justify-between"
|
||||
style={{
|
||||
backgroundColor: theme.modal.input.bg,
|
||||
border: `1px solid ${theme.modal.input.border}`,
|
||||
color: theme.modal.input.text
|
||||
}}
|
||||
>
|
||||
<span>Inherit</span>
|
||||
<IconChevronDown size={12} />
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
<div className="dropdown-item" onClick={() => onDropdownSelect('inherit')}>
|
||||
Inherit
|
||||
</div>
|
||||
<div className="dropdown-item" onClick={() => onDropdownSelect('custom')}>
|
||||
Custom
|
||||
</div>
|
||||
</Dropdown>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<input
|
||||
id={id}
|
||||
type="text"
|
||||
className="block px-2 py-1 pr-6 rounded-sm outline-none transition-colors duration-100 w-24 h-8"
|
||||
style={{
|
||||
backgroundColor: theme.modal.input.bg,
|
||||
border: `1px solid ${theme.modal.input.border}`,
|
||||
color: theme.modal.input.text
|
||||
}}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
value={value}
|
||||
onChange={onValueChange}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCustomValueReset}
|
||||
className="absolute right-1 top-1/2 transform -translate-y-1/2 p-1 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 transition-colors"
|
||||
title="Reset to inherit"
|
||||
>
|
||||
<IconX size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InheritableSettingsInput;
|
||||
@@ -78,6 +78,14 @@ const StyledMarkdownBodyWrapper = styled.div`
|
||||
background-color: ${(props) => props.theme.bg};
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
div {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -6,6 +6,9 @@ import { isValidUrl } from 'utils/url/index';
|
||||
|
||||
const Markdown = ({ collectionPath, onDoubleClick, content }) => {
|
||||
const markdownItOptions = {
|
||||
html: true,
|
||||
breaks: true,
|
||||
linkify: true,
|
||||
replaceLink: function (link, env) {
|
||||
return link.replace(/^\./, collectionPath);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@ const ModalHeader = ({ title, handleCancel, customHeader, hideClose }) => (
|
||||
<div className="bruno-modal-header">
|
||||
{customHeader ? customHeader : <>{title ? <div className="bruno-modal-header-title">{title}</div> : null}</>}
|
||||
{handleCancel && !hideClose ? (
|
||||
<div className="close cursor-pointer" onClick={handleCancel ? () => handleCancel() : null}>
|
||||
// TODO: Remove data-test-id and use data-testid instead across the codebase.
|
||||
<div className="close cursor-pointer" onClick={handleCancel ? () => handleCancel() : null} data-test-id="modal-close-button" data-testid="modal-close-button">
|
||||
×
|
||||
</div>
|
||||
) : null}
|
||||
@@ -25,7 +26,8 @@ const ModalFooter = ({
|
||||
handleCancel,
|
||||
confirmDisabled,
|
||||
hideCancel,
|
||||
hideFooter
|
||||
hideFooter,
|
||||
confirmButtonClass = 'btn-secondary'
|
||||
}) => {
|
||||
confirmText = confirmText || 'Save';
|
||||
cancelText = cancelText || 'Cancel';
|
||||
@@ -44,7 +46,7 @@ const ModalFooter = ({
|
||||
<span>
|
||||
<button
|
||||
type="submit"
|
||||
className="submit btn btn-md btn-secondary"
|
||||
className={`submit btn btn-md ${confirmButtonClass}`}
|
||||
disabled={confirmDisabled}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
@@ -71,7 +73,9 @@ const Modal = ({
|
||||
disableCloseOnOutsideClick,
|
||||
disableEscapeKey,
|
||||
onClick,
|
||||
closeModalFadeTimeout = 500
|
||||
closeModalFadeTimeout = 500,
|
||||
dataTestId,
|
||||
confirmButtonClass
|
||||
}) => {
|
||||
const modalRef = useRef(null);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
@@ -120,6 +124,7 @@ const Modal = ({
|
||||
role="dialog"
|
||||
aria-labelledby="modal-title"
|
||||
aria-describedby="modal-description"
|
||||
data-testid={dataTestId}
|
||||
>
|
||||
<ModalHeader
|
||||
title={title}
|
||||
@@ -136,6 +141,7 @@ const Modal = ({
|
||||
confirmDisabled={confirmDisabled}
|
||||
hideCancel={hideCancel}
|
||||
hideFooter={hideFooter}
|
||||
confirmButtonClass={confirmButtonClass}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user