mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-04 01:48:33 +00:00
Compare commits
76 Commits
feat/node_
...
feat/tab-r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb44d9e193 | ||
|
|
b1840d189d | ||
|
|
aacb1e0b8e | ||
|
|
fa0f3b3b7b | ||
|
|
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 | ||
|
|
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 | ||
|
|
5dd90e1386 | ||
|
|
da2f2519ec | ||
|
|
7efaa427ca |
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
|
||||
2
.github/workflows/npm-bru-cli.yml
vendored
2
.github/workflows/npm-bru-cli.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- 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@v4
|
||||
|
||||
- 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@v4
|
||||
|
||||
- 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@v4
|
||||
|
||||
- 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
|
||||
13
.github/workflows/tests.yml
vendored
13
.github/workflows/tests.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- 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
|
||||
@@ -65,7 +67,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- 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
|
||||
@@ -102,7 +109,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- 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.
|
||||
@@ -74,10 +74,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
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -78,10 +78,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
|
||||
```
|
||||
|
||||
|
||||
@@ -75,10 +75,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
|
||||
```
|
||||
|
||||
|
||||
@@ -78,10 +78,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
|
||||
```
|
||||
|
||||
|
||||
@@ -77,12 +77,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
|
||||
```
|
||||
|
||||
|
||||
@@ -61,12 +61,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
|
||||
```
|
||||
|
||||
|
||||
@@ -76,10 +76,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,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,6 +0,0 @@
|
||||
{
|
||||
"maximized": true,
|
||||
"lastOpenedCollections": [
|
||||
"{{projectRoot}}/e2e-tests/persistent-env-tests/collection"
|
||||
]
|
||||
}
|
||||
@@ -1,8 +1,62 @@
|
||||
// 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,
|
||||
},
|
||||
files: [
|
||||
'./eslint.config.js',
|
||||
'tests/**/*.spec.{ts,js}',
|
||||
'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',
|
||||
],
|
||||
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'],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["packages/bruno-app/**/*.{js,jsx,ts}"],
|
||||
ignores: ["**/*.config.js", "**/public/**/*"],
|
||||
@@ -197,4 +251,4 @@ module.exports = defineConfig([
|
||||
"no-undef": "error",
|
||||
},
|
||||
},
|
||||
]);
|
||||
]));
|
||||
|
||||
1081
package-lock.json
generated
1081
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",
|
||||
@@ -92,7 +93,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",
|
||||
|
||||
@@ -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;
|
||||
100
packages/bruno-app/src/components/Devtools/Performance/index.js
Normal file
100
packages/bruno-app/src/components/Devtools/Performance/index.js
Normal file
@@ -0,0 +1,100 @@
|
||||
import React 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);
|
||||
|
||||
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;
|
||||
@@ -0,0 +1,60 @@
|
||||
import React from 'react';
|
||||
import { IconPlus, IconDownload, IconSettings } from '@tabler/icons';
|
||||
|
||||
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>
|
||||
<div>
|
||||
{environments.map((env) => (
|
||||
<div
|
||||
key={env.uid}
|
||||
className={`dropdown-item ${env.uid === activeEnvironmentUid ? 'active' : ''}`}
|
||||
onClick={() => onEnvironmentSelect(env)}
|
||||
>
|
||||
<span className="max-w-32 truncate no-wrap">{env.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<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,227 @@ 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;
|
||||
}
|
||||
|
||||
.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 {
|
||||
min-width: 11.875rem;
|
||||
min-height: 15.0625rem;
|
||||
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,240 @@
|
||||
import React, { useRef, forwardRef, useState } from 'react';
|
||||
import React, { 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 ImportEnvironment from '../EnvironmentSettings/ImportEnvironment';
|
||||
import CreateGlobalEnvironment from 'components/GlobalEnvironments/EnvironmentSettings/CreateEnvironment';
|
||||
import ImportGlobalEnvironment from 'components/GlobalEnvironments/EnvironmentSettings/ImportEnvironment';
|
||||
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));
|
||||
};
|
||||
|
||||
// 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" />
|
||||
<span className="env-text max-w-24 truncate no-wrap">{activeCollectionEnvironment.name}</span>
|
||||
</div>
|
||||
{activeGlobalEnvironment && <span className="env-separator">|</span>}
|
||||
</>
|
||||
)}
|
||||
{activeGlobalEnvironment && (
|
||||
<div className="flex items-center">
|
||||
<IconWorld size={14} strokeWidth={1.5} className="env-icon" />
|
||||
<span className="env-text max-w-24 truncate no-wrap">{activeGlobalEnvironment.name}</span>
|
||||
</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">
|
||||
<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 justify-center 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} onClose={handleCloseSettings} />
|
||||
)}
|
||||
|
||||
{showCollectionSettings && <EnvironmentSettings collection={collection} onClose={handleCloseSettings} />}
|
||||
|
||||
{showCreateGlobalModal && (
|
||||
<CreateGlobalEnvironment
|
||||
onClose={() => setShowCreateGlobalModal(false)}
|
||||
onEnvironmentCreated={() => {
|
||||
setShowGlobalSettings(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showImportGlobalModal && (
|
||||
<ImportGlobalEnvironment
|
||||
onClose={() => setShowImportGlobalModal(false)}
|
||||
onEnvironmentCreated={() => {
|
||||
setShowGlobalSettings(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showCreateCollectionModal && (
|
||||
<CreateEnvironment
|
||||
collection={collection}
|
||||
onClose={() => setShowCreateCollectionModal(false)}
|
||||
onEnvironmentCreated={() => {
|
||||
setShowCollectionSettings(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showImportCollectionModal && (
|
||||
<ImportEnvironment
|
||||
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>
|
||||
@@ -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>
|
||||
|
||||
@@ -8,7 +8,7 @@ import { importEnvironment } from 'providers/ReduxStore/slices/collections/actio
|
||||
import { toastError } from 'utils/common/error';
|
||||
import { IconDatabaseImport } from '@tabler/icons';
|
||||
|
||||
const ImportEnvironment = ({ collection, onClose }) => {
|
||||
const ImportEnvironment = ({ collection, onClose, onEnvironmentCreated }) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const handleImportPostmanEnvironment = () => {
|
||||
@@ -36,17 +36,22 @@ const ImportEnvironment = ({ collection, onClose }) => {
|
||||
})
|
||||
.then(() => {
|
||||
onClose();
|
||||
// Call the callback if provided
|
||||
if (onEnvironmentCreated) {
|
||||
onEnvironmentCreated();
|
||||
}
|
||||
})
|
||||
.catch((err) => toastError(err, 'Postman Import environment failed'));
|
||||
};
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Modal size="sm" title="Import Environment" hideFooter={true} handleConfirm={onClose} handleCancel={onClose}>
|
||||
<Modal size="sm" title="Import Environment" hideFooter={true} handleConfirm={onClose} handleCancel={onClose} dataTestId="import-environment-modal">
|
||||
<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"
|
||||
data-testid="import-postman-environment"
|
||||
>
|
||||
<IconDatabaseImport size={64} />
|
||||
<span className="mt-2 block text-sm font-semibold">Import your Postman environments</span>
|
||||
|
||||
@@ -56,9 +56,8 @@ const EnvironmentSettings = ({ collection, onClose }) => {
|
||||
) : tab === 'import' ? (
|
||||
<ImportEnvironment collection={collection} onClose={() => setTab('default')} />
|
||||
) : (
|
||||
<></>
|
||||
<DefaultTab setTab={setTab} />
|
||||
)}
|
||||
<DefaultTab setTab={setTab} />
|
||||
</Modal>
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@ import React, { useRef, useEffect } from 'react';
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import { IconTrash, IconAlertCircle } 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,
|
||||
@@ -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' });
|
||||
}
|
||||
@@ -145,10 +152,11 @@ const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentV
|
||||
<ErrorMessage name={`${index}.name`} />
|
||||
</div>
|
||||
</td>
|
||||
<td className="flex flex-row flex-nowrap">
|
||||
<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`}
|
||||
value={variable.value}
|
||||
isSecret={variable.secret}
|
||||
@@ -179,6 +187,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 +195,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>
|
||||
|
||||
@@ -5,7 +5,7 @@ import DeleteEnvironment from '../../DeleteEnvironment';
|
||||
import RenameEnvironment from '../../RenameEnvironment';
|
||||
import EnvironmentVariables from './EnvironmentVariables';
|
||||
|
||||
const EnvironmentDetails = ({ environment, setIsModified }) => {
|
||||
const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
|
||||
const [openEditModal, setOpenEditModal] = useState(false);
|
||||
const [openDeleteModal, setOpenDeleteModal] = useState(false);
|
||||
const [openCopyModal, setOpenCopyModal] = useState(false);
|
||||
@@ -37,7 +37,7 @@ const EnvironmentDetails = ({ environment, setIsModified }) => {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<EnvironmentVariables environment={environment} setIsModified={setIsModified} />
|
||||
<EnvironmentVariables environment={environment} setIsModified={setIsModified} collection={collection} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -10,7 +10,7 @@ import ImportEnvironment from '../ImportEnvironment';
|
||||
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 }) => {
|
||||
const [openCreateModal, setOpenCreateModal] = useState(false);
|
||||
const [openImportModal, setOpenImportModal] = useState(false);
|
||||
const [openManageSecretsModal, setOpenManageSecretsModal] = useState(false);
|
||||
@@ -143,6 +143,7 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme
|
||||
environment={selectedEnvironment}
|
||||
setIsModified={setIsModified}
|
||||
originalEnvironmentVariables={originalEnvironmentVariables}
|
||||
collection={collection}
|
||||
/>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
|
||||
@@ -9,7 +9,7 @@ import { IconDatabaseImport } from '@tabler/icons';
|
||||
import { addGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
|
||||
import { uuid } from 'utils/common/index';
|
||||
|
||||
const ImportEnvironment = ({ onClose }) => {
|
||||
const ImportEnvironment = ({ onClose, onEnvironmentCreated }) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const handleImportPostmanEnvironment = () => {
|
||||
@@ -37,17 +37,22 @@ const ImportEnvironment = ({ onClose }) => {
|
||||
})
|
||||
.then(() => {
|
||||
onClose();
|
||||
// Call the callback if provided
|
||||
if (onEnvironmentCreated) {
|
||||
onEnvironmentCreated();
|
||||
}
|
||||
})
|
||||
.catch((err) => toastError(err, 'Postman Import environment failed'));
|
||||
};
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Modal size="sm" title="Import Global Environment" hideFooter={true} handleConfirm={onClose} handleCancel={onClose}>
|
||||
<Modal size="sm" title="Import Global Environment" hideFooter={true} handleConfirm={onClose} handleCancel={onClose} dataTestId="import-global-environment-modal">
|
||||
<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"
|
||||
data-testid="import-postman-global-environment"
|
||||
>
|
||||
<IconDatabaseImport size={64} />
|
||||
<span className="mt-2 block text-sm font-semibold">Import your Postman environments</span>
|
||||
|
||||
@@ -39,7 +39,7 @@ const DefaultTab = ({ setTab }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const EnvironmentSettings = ({ globalEnvironments, activeGlobalEnvironmentUid, onClose }) => {
|
||||
const EnvironmentSettings = ({ globalEnvironments, collection, onClose }) => {
|
||||
const [isModified, setIsModified] = useState(false);
|
||||
const environments = globalEnvironments;
|
||||
const [selectedEnvironment, setSelectedEnvironment] = useState(null);
|
||||
@@ -53,9 +53,8 @@ const EnvironmentSettings = ({ globalEnvironments, activeGlobalEnvironmentUid, o
|
||||
) : tab === 'import' ? (
|
||||
<ImportEnvironment onClose={() => setTab('default')} />
|
||||
) : (
|
||||
<></>
|
||||
<DefaultTab setTab={setTab} />
|
||||
)}
|
||||
<DefaultTab setTab={setTab} />
|
||||
</Modal>
|
||||
</StyledWrapper>
|
||||
);
|
||||
@@ -65,11 +64,11 @@ const EnvironmentSettings = ({ globalEnvironments, activeGlobalEnvironmentUid, o
|
||||
<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}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -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('/');
|
||||
};
|
||||
@@ -9,7 +9,7 @@ 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}>
|
||||
<div className="close cursor-pointer" onClick={handleCancel ? () => handleCancel() : null} data-test-id="modal-close-button">
|
||||
×
|
||||
</div>
|
||||
) : null}
|
||||
@@ -71,7 +71,8 @@ const Modal = ({
|
||||
disableCloseOnOutsideClick,
|
||||
disableEscapeKey,
|
||||
onClick,
|
||||
closeModalFadeTimeout = 500
|
||||
closeModalFadeTimeout = 500,
|
||||
dataTestId
|
||||
}) => {
|
||||
const modalRef = useRef(null);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
@@ -120,6 +121,7 @@ const Modal = ({
|
||||
role="dialog"
|
||||
aria-labelledby="modal-title"
|
||||
aria-describedby="modal-description"
|
||||
data-testid={dataTestId}
|
||||
>
|
||||
<ModalHeader
|
||||
title={title}
|
||||
|
||||
@@ -11,7 +11,9 @@ const StyledWrapper = styled.div`
|
||||
height: fit-content;
|
||||
font-size: 14px;
|
||||
line-height: 30px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 200px;
|
||||
|
||||
pre.CodeMirror-placeholder {
|
||||
color: ${(props) => props.theme.text};
|
||||
@@ -19,18 +21,10 @@ const StyledWrapper = styled.div`
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.CodeMirror-scroll {
|
||||
overflow: visible !important;
|
||||
position: relative;
|
||||
display: block;
|
||||
margin: 0px;
|
||||
padding: 0px;
|
||||
}
|
||||
|
||||
.CodeMirror-vscrollbar,
|
||||
.CodeMirror-hscrollbar,
|
||||
.CodeMirror-scrollbar-filler {
|
||||
display: none;
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.CodeMirror-lines {
|
||||
|
||||
@@ -3,7 +3,9 @@ import isEqual from 'lodash/isEqual';
|
||||
import { getAllVariables } from 'utils/collections';
|
||||
import { defineCodeMirrorBrunoVariablesMode } from 'utils/common/codemirror';
|
||||
import { setupAutoComplete } from 'utils/codemirror/autocomplete';
|
||||
import { MaskedEditor } from 'utils/common/masked-editor';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { IconEye, IconEyeOff } from '@tabler/icons';
|
||||
|
||||
const CodeMirror = require('codemirror');
|
||||
|
||||
@@ -16,6 +18,10 @@ class MultiLineEditor extends Component {
|
||||
this.cachedValue = props.value || '';
|
||||
this.editorRef = React.createRef();
|
||||
this.variables = {};
|
||||
|
||||
this.state = {
|
||||
maskInput: props.isSecret || false // Always mask the input by default (if it's a secret)
|
||||
};
|
||||
}
|
||||
componentDidMount() {
|
||||
// Initialize CodeMirror as a single line editor
|
||||
@@ -23,22 +29,14 @@ class MultiLineEditor extends Component {
|
||||
const variables = getAllVariables(this.props.collection, this.props.item);
|
||||
|
||||
this.editor = CodeMirror(this.editorRef.current, {
|
||||
lineWrapping: false,
|
||||
lineNumbers: false,
|
||||
theme: this.props.theme === 'dark' ? 'monokai' : 'default',
|
||||
placeholder: this.props.placeholder,
|
||||
mode: 'brunovariables',
|
||||
brunoVarInfo: {
|
||||
variables
|
||||
},
|
||||
scrollbarStyle: null,
|
||||
tabindex: 0,
|
||||
extraKeys: {
|
||||
Enter: () => {
|
||||
if (this.props.onRun) {
|
||||
this.props.onRun();
|
||||
}
|
||||
},
|
||||
'Ctrl-Enter': () => {
|
||||
if (this.props.onRun) {
|
||||
this.props.onRun();
|
||||
@@ -49,14 +47,6 @@ class MultiLineEditor extends Component {
|
||||
this.props.onRun();
|
||||
}
|
||||
},
|
||||
'Alt-Enter': () => {
|
||||
this.editor.setValue(this.editor.getValue() + '\n');
|
||||
this.editor.setCursor({ line: this.editor.lineCount(), ch: 0 });
|
||||
},
|
||||
'Shift-Enter': () => {
|
||||
this.editor.setValue(this.editor.getValue() + '\n');
|
||||
this.editor.setCursor({ line: this.editor.lineCount(), ch: 0 });
|
||||
},
|
||||
'Cmd-S': () => {
|
||||
if (this.props.onSave) {
|
||||
this.props.onSave();
|
||||
@@ -94,6 +84,10 @@ class MultiLineEditor extends Component {
|
||||
this.editor.setValue(String(this.props.value) || '');
|
||||
this.editor.on('change', this._onEdit);
|
||||
this.addOverlay(variables);
|
||||
|
||||
// Initialize masking if this is a secret field
|
||||
this.setState({ maskInput: this.props.isSecret });
|
||||
this._enableMaskedEditor(this.props.isSecret);
|
||||
}
|
||||
|
||||
_onEdit = () => {
|
||||
@@ -105,6 +99,19 @@ class MultiLineEditor extends Component {
|
||||
}
|
||||
};
|
||||
|
||||
/** Enable or disable masking the rendered content of the editor */
|
||||
_enableMaskedEditor = (enabled) => {
|
||||
if (typeof enabled !== 'boolean') return;
|
||||
|
||||
if (enabled == true) {
|
||||
if (!this.maskedEditor) this.maskedEditor = new MaskedEditor(this.editor, '*');
|
||||
this.maskedEditor.enable();
|
||||
} else {
|
||||
this.maskedEditor?.disable();
|
||||
this.maskedEditor = null;
|
||||
}
|
||||
};
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
// Ensure the changes caused by this update are not interpreted as
|
||||
// user-input changes which could otherwise result in an infinite
|
||||
@@ -123,8 +130,11 @@ class MultiLineEditor extends Component {
|
||||
this.cachedValue = String(this.props.value);
|
||||
this.editor.setValue(String(this.props.value) || '');
|
||||
}
|
||||
if (this.editorRef?.current) {
|
||||
this.editorRef.current.scrollTo(0, 10000);
|
||||
if (!isEqual(this.props.isSecret, prevProps.isSecret)) {
|
||||
// If the secret flag has changed, update the editor to reflect the change
|
||||
this._enableMaskedEditor(this.props.isSecret);
|
||||
// also set the maskInput flag to the new value
|
||||
this.setState({ maskInput: this.props.isSecret });
|
||||
}
|
||||
this.ignoreChangeEvent = false;
|
||||
}
|
||||
@@ -133,6 +143,10 @@ class MultiLineEditor extends Component {
|
||||
if (this.brunoAutoCompleteCleanup) {
|
||||
this.brunoAutoCompleteCleanup();
|
||||
}
|
||||
if (this.maskedEditor) {
|
||||
this.maskedEditor.destroy();
|
||||
this.maskedEditor = null;
|
||||
}
|
||||
this.editor.getWrapperElement().remove();
|
||||
}
|
||||
|
||||
@@ -142,8 +156,38 @@ class MultiLineEditor extends Component {
|
||||
this.editor.setOption('mode', 'brunovariables');
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Toggle the visibility of the secret value
|
||||
*/
|
||||
toggleVisibleSecret = () => {
|
||||
const isVisible = !this.state.maskInput;
|
||||
this.setState({ maskInput: isVisible });
|
||||
this._enableMaskedEditor(isVisible);
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Eye icon to show/hide the secret value
|
||||
* @returns ReactComponent The eye icon
|
||||
*/
|
||||
secretEye = (isSecret) => {
|
||||
return isSecret === true ? (
|
||||
<button className="mx-2" onClick={() => this.toggleVisibleSecret()}>
|
||||
{this.state.maskInput === true ? (
|
||||
<IconEyeOff size={18} strokeWidth={2} />
|
||||
) : (
|
||||
<IconEye size={18} strokeWidth={2} />
|
||||
)}
|
||||
</button>
|
||||
) : null;
|
||||
};
|
||||
|
||||
render() {
|
||||
return <StyledWrapper ref={this.editorRef} className="single-line-editor"></StyledWrapper>;
|
||||
return (
|
||||
<div className={`flex flex-row justify-between w-full overflow-x-auto ${this.props.className}`}>
|
||||
<StyledWrapper ref={this.editorRef} className="multi-line-editor grow" />
|
||||
{this.secretEye(this.props.isSecret)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
export default MultiLineEditor;
|
||||
|
||||
@@ -14,6 +14,11 @@ const BETA_FEATURES = [
|
||||
id: 'grpc',
|
||||
label: 'gRPC Support',
|
||||
description: 'Enable gRPC request support for making gRPC calls to services'
|
||||
},
|
||||
{
|
||||
id: 'nodevm',
|
||||
label: 'Node VM Runtime',
|
||||
description: 'Enable Node VM runtime for JavaScript execution in Developer Mode'
|
||||
}
|
||||
];
|
||||
|
||||
@@ -68,7 +73,7 @@ const Beta = ({ close }) => {
|
||||
.catch((err) => console.log(err) && toast.error('Failed to update beta preferences'));
|
||||
};
|
||||
|
||||
const hasAnyBetaFeatures = Object.values(formik.values).length > 0;
|
||||
const hasAnyBetaFeatures = BETA_FEATURES.length > 0;
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
|
||||
@@ -3,6 +3,7 @@ import get from 'lodash/get';
|
||||
import { useFormik } from 'formik';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { savePreferences } from 'providers/ReduxStore/slices/app';
|
||||
import { browseDirectory } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import * as Yup from 'yup';
|
||||
import toast from 'react-hot-toast';
|
||||
@@ -35,7 +36,8 @@ const General = ({ close }) => {
|
||||
})
|
||||
.test('isValidTimeout', 'Request Timeout must be equal or greater than 0', (value) => {
|
||||
return value === undefined || Number(value) >= 0;
|
||||
})
|
||||
}),
|
||||
defaultCollectionLocation: Yup.string().max(1024)
|
||||
});
|
||||
|
||||
const formik = useFormik({
|
||||
@@ -50,7 +52,8 @@ const General = ({ close }) => {
|
||||
},
|
||||
timeout: preferences.request.timeout,
|
||||
storeCookies: get(preferences, 'request.storeCookies', true),
|
||||
sendCookies: get(preferences, 'request.sendCookies', true)
|
||||
sendCookies: get(preferences, 'request.sendCookies', true),
|
||||
defaultCollectionLocation: get(preferences, 'general.defaultCollectionLocation', '')
|
||||
},
|
||||
validationSchema: preferencesSchema,
|
||||
onSubmit: async (values) => {
|
||||
@@ -79,6 +82,9 @@ const General = ({ close }) => {
|
||||
timeout: newPreferences.timeout,
|
||||
storeCookies: newPreferences.storeCookies,
|
||||
sendCookies: newPreferences.sendCookies
|
||||
},
|
||||
general: {
|
||||
defaultCollectionLocation: newPreferences.defaultCollectionLocation
|
||||
}
|
||||
}))
|
||||
.then(() => {
|
||||
@@ -99,6 +105,19 @@ const General = ({ close }) => {
|
||||
formik.setFieldValue('customCaCertificate.filePath', null);
|
||||
};
|
||||
|
||||
const browseDefaultLocation = () => {
|
||||
dispatch(browseDirectory())
|
||||
.then((dirPath) => {
|
||||
if (typeof dirPath === 'string') {
|
||||
formik.setFieldValue('defaultCollectionLocation', dirPath);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
formik.setFieldValue('defaultCollectionLocation', '');
|
||||
console.error(error);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<form className="bruno-form" onSubmit={formik.handleSubmit}>
|
||||
@@ -176,11 +195,11 @@ const General = ({ close }) => {
|
||||
name="keepDefaultCaCertificates.enabled"
|
||||
checked={formik.values.keepDefaultCaCertificates.enabled}
|
||||
onChange={formik.handleChange}
|
||||
className={`mousetrap mr-0 ${formik.values.customCaCertificate.enabled ? '' : 'opacity-25'}`}
|
||||
disabled={formik.values.customCaCertificate.enabled ? false : true}
|
||||
className={`mousetrap mr-0 ${formik.values.customCaCertificate.enabled && formik.values.customCaCertificate.filePath ? '' : 'opacity-25'}`}
|
||||
disabled={formik.values.customCaCertificate.enabled && formik.values.customCaCertificate.filePath ? false : true}
|
||||
/>
|
||||
<label
|
||||
className={`block ml-2 select-none ${formik.values.customCaCertificate.enabled ? '' : 'opacity-25'}`}
|
||||
className={`block ml-2 select-none ${formik.values.customCaCertificate.enabled && formik.values.customCaCertificate.filePath ? '' : 'opacity-25'}`}
|
||||
htmlFor="keepDefaultCaCertificatesEnabled"
|
||||
>
|
||||
Keep Default CA Certificates
|
||||
@@ -231,6 +250,35 @@ const General = ({ close }) => {
|
||||
{formik.touched.timeout && formik.errors.timeout ? (
|
||||
<div className="text-red-500">{formik.errors.timeout}</div>
|
||||
) : null}
|
||||
<div className="flex flex-col mt-6">
|
||||
<label className="block select-none default-collection-location-label" htmlFor="defaultCollectionLocation">
|
||||
Default Collection Location
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="defaultCollectionLocation"
|
||||
className="block textbox mt-2 w-full cursor-pointer default-collection-location-input"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
onChange={formik.handleChange}
|
||||
value={formik.values.defaultCollectionLocation || ''}
|
||||
onClick={browseDefaultLocation}
|
||||
placeholder="Click to browse for default location"
|
||||
/>
|
||||
<div className="mt-1">
|
||||
<span
|
||||
className="text-link cursor-pointer hover:underline default-collection-location-browse"
|
||||
onClick={browseDefaultLocation}
|
||||
>
|
||||
Browse
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{formik.touched.defaultCollectionLocation && formik.errors.defaultCollectionLocation ? (
|
||||
<div className="text-red-500">{formik.errors.defaultCollectionLocation}</div>
|
||||
) : null}
|
||||
<div className="mt-10">
|
||||
<button type="submit" className="submit btn btn-sm btn-secondary">
|
||||
Save
|
||||
|
||||
@@ -30,6 +30,10 @@ const GrpcAuthMode = ({ item, collection }) => {
|
||||
name: 'OAuth2',
|
||||
mode: 'oauth2'
|
||||
},
|
||||
{
|
||||
name: 'WSSE Auth',
|
||||
mode: 'wsse'
|
||||
},
|
||||
{
|
||||
name: 'Inherit',
|
||||
mode: 'inherit'
|
||||
|
||||
@@ -6,6 +6,7 @@ import BearerAuth from '../../Auth/BearerAuth';
|
||||
import BasicAuth from '../../Auth/BasicAuth';
|
||||
import ApiKeyAuth from '../../Auth/ApiKeyAuth';
|
||||
import OAuth2 from '../../Auth/OAuth2/index';
|
||||
import WsseAuth from '../../Auth/WsseAuth';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { humanizeRequestAuthMode } from 'utils/collections';
|
||||
import { getTreePathFromCollectionToItem } from 'utils/collections/index';
|
||||
@@ -13,7 +14,10 @@ import { updateRequestAuthMode, updateAuth } from 'providers/ReduxStore/slices/c
|
||||
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
|
||||
// List of auth modes supported by gRPC
|
||||
const supportedGrpcAuthModes = ['basic', 'bearer', 'apikey', 'oauth2', 'none', 'inherit'];
|
||||
// Note: Only header-based auth modes work with gRPC
|
||||
// Complex auth modes like AWS Sig v4, Digest, and NTLM require axios interceptors
|
||||
// and cannot be supported in gRPC requests as of now
|
||||
const supportedGrpcAuthModes = ['basic', 'bearer', 'apikey', 'oauth2', 'wsse', 'none', 'inherit'];
|
||||
|
||||
const GrpcAuth = ({ item, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -83,6 +87,9 @@ const GrpcAuth = ({ item, collection }) => {
|
||||
case 'oauth2': {
|
||||
return <OAuth2 collection={collection} item={item} updateAuth={updateAuth} request={request} save={save} />;
|
||||
}
|
||||
case 'wsse': {
|
||||
return <WsseAuth collection={collection} item={item} updateAuth={updateAuth} request={request} save={save} />;
|
||||
}
|
||||
case 'inherit': {
|
||||
const source = getEffectiveAuthSource();
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ const Wrapper = styled.div`
|
||||
|
||||
.method-selector {
|
||||
border-radius: 3px;
|
||||
min-width: 90px;
|
||||
|
||||
.tippy-box {
|
||||
max-width: 150px !important;
|
||||
@@ -21,6 +20,28 @@ const Wrapper = styled.div`
|
||||
}
|
||||
}
|
||||
|
||||
input {
|
||||
background-color: ${(props) => props.theme.requestTabPanel.url.bg};
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
text-align: left;
|
||||
|
||||
&:focus {
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.method-span {
|
||||
width: 70px;
|
||||
min-width: 70px;
|
||||
max-width: 90px;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.caret {
|
||||
color: rgb(140, 140, 140);
|
||||
fill: rgb(140 140 140);
|
||||
|
||||
@@ -1,52 +1,142 @@
|
||||
import React, { useRef, forwardRef } from 'react';
|
||||
import React, { useState, useRef, forwardRef } from 'react';
|
||||
import { IconCaretDown } from '@tabler/icons';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const HttpMethodSelector = ({ method, onMethodSelect }) => {
|
||||
const dropdownTippyRef = useRef();
|
||||
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
|
||||
const STANDARD_METHODS = Object.freeze(['GET','POST','PUT','DELETE','PATCH','OPTIONS','HEAD','TRACE','CONNECT']);
|
||||
|
||||
const Icon = forwardRef((props, ref) => {
|
||||
const KEY = Object.freeze({ ENTER: 'Enter', ESCAPE: 'Escape' });
|
||||
|
||||
const DEFAULT_METHOD = 'GET';
|
||||
|
||||
function Verb({ verb, onSelect }) {
|
||||
return (
|
||||
<div className="dropdown-item" onClick={() => onSelect(verb)}>
|
||||
{verb}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const Icon = forwardRef(function IconComponent(
|
||||
{ isCustomMode, inputValue, handleInputChange, handleBlur, handleKeyDown, inputRef },
|
||||
ref
|
||||
) {
|
||||
if (isCustomMode) {
|
||||
return (
|
||||
<div ref={ref} className="flex w-full items-center pl-3 py-1 select-none uppercase">
|
||||
<div className="flex-grow font-medium" id="create-new-request-method">
|
||||
{method}
|
||||
</div>
|
||||
<div>
|
||||
<IconCaretDown className="caret ml-2 mr-2" size={14} strokeWidth={2} />
|
||||
</div>
|
||||
<div className="flex flex-col w-full">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className="font-medium px-2 w-full focus:bg-transparent"
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
title={inputValue}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const handleMethodSelect = (verb) => onMethodSelect(verb);
|
||||
|
||||
const Verb = ({ verb }) => {
|
||||
return (
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
dropdownTippyRef.current.hide();
|
||||
handleMethodSelect(verb);
|
||||
}}
|
||||
return (
|
||||
<div ref={ref} className="flex pr-4 select-none">
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer flex items-center text-left w-full"
|
||||
>
|
||||
{verb}
|
||||
</div>
|
||||
);
|
||||
<span
|
||||
className="font-medium px-2 truncate method-span"
|
||||
id="create-new-request-method"
|
||||
title={inputValue}
|
||||
>
|
||||
{inputValue}
|
||||
</span>
|
||||
<IconCaretDown className="caret" size={16} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const HttpMethodSelector = ({ method = DEFAULT_METHOD, onMethodSelect }) => {
|
||||
const [isCustomMode, setIsCustomMode] = useState(false);
|
||||
const dropdownTippyRef = useRef();
|
||||
const inputRef = useRef();
|
||||
|
||||
const blurInput = () => inputRef.current?.blur();
|
||||
|
||||
const handleInputChange = (e) => {
|
||||
const val = e.target.value.toUpperCase();
|
||||
onMethodSelect(val);
|
||||
};
|
||||
|
||||
const handleDropdownSelect = (verb) => {
|
||||
onMethodSelect(verb);
|
||||
setIsCustomMode(false);
|
||||
dropdownTippyRef.current?.hide();
|
||||
blurInput();
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
setIsCustomMode(false);
|
||||
};
|
||||
|
||||
const handleAddCustomMethod = () => {
|
||||
setIsCustomMode(true);
|
||||
onMethodSelect('');
|
||||
dropdownTippyRef.current?.hide();
|
||||
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
switch (e.key) {
|
||||
case KEY.ESCAPE:
|
||||
setIsCustomMode(false);
|
||||
blurInput();
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
case KEY.ENTER:
|
||||
onMethodSelect(e.target.value ? e.target.value.toUpperCase() : DEFAULT_METHOD);
|
||||
setIsCustomMode(false);
|
||||
blurInput();
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="flex items-center cursor-pointer method-selector">
|
||||
<Dropdown onCreate={onDropdownCreate} icon={<Icon />} placement="bottom-start">
|
||||
<Verb verb="GET" />
|
||||
<Verb verb="POST" />
|
||||
<Verb verb="PUT" />
|
||||
<Verb verb="DELETE" />
|
||||
<Verb verb="PATCH" />
|
||||
<Verb verb="OPTIONS" />
|
||||
<Verb verb="HEAD" />
|
||||
<div className="flex method-selector">
|
||||
<Dropdown
|
||||
onCreate={onDropdownCreate}
|
||||
icon={
|
||||
<Icon
|
||||
isCustomMode={isCustomMode}
|
||||
inputValue={method}
|
||||
handleInputChange={handleInputChange}
|
||||
handleBlur={handleBlur}
|
||||
handleKeyDown={handleKeyDown}
|
||||
inputRef={inputRef}
|
||||
/>
|
||||
}
|
||||
placement="bottom-start"
|
||||
>
|
||||
<div>
|
||||
{STANDARD_METHODS.map((verb) => (
|
||||
<Verb key={verb} verb={verb} onSelect={handleDropdownSelect} />
|
||||
))}
|
||||
<div className="dropdown-item font-normal mt-1" onClick={handleAddCustomMethod}>
|
||||
<span className="text-link">+ Add Custom</span>
|
||||
</div>
|
||||
</div>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
import '@testing-library/jest-dom';
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { ThemeProvider } from 'styled-components';
|
||||
import HttpMethodSelector from './index';
|
||||
import themes from 'themes/index';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
const renderWithTheme = (component) => {
|
||||
return render(
|
||||
<ThemeProvider theme={themes.dark}>
|
||||
{component}
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('HttpMethodSelector', () => {
|
||||
const mockOnMethodSelect = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
mockOnMethodSelect.mockClear();
|
||||
});
|
||||
|
||||
describe('Initial Render', () => {
|
||||
it('should render with default GET method when no method prop is provided', () => {
|
||||
renderWithTheme(<HttpMethodSelector onMethodSelect={mockOnMethodSelect} />);
|
||||
|
||||
const methodSpan = screen.getByText('GET');
|
||||
expect(methodSpan).toBeInTheDocument();
|
||||
expect(methodSpan).toHaveClass('method-span');
|
||||
expect(methodSpan).toHaveAttribute('title', 'GET');
|
||||
});
|
||||
|
||||
it('should render with a standard method when method prop is provided', () => {
|
||||
renderWithTheme(<HttpMethodSelector method="POST" onMethodSelect={mockOnMethodSelect} />);
|
||||
|
||||
const methodSpan = screen.getByText('POST');
|
||||
expect(methodSpan).toBeInTheDocument();
|
||||
expect(methodSpan).toHaveAttribute('title', 'POST');
|
||||
});
|
||||
|
||||
it('should render with a custom method when method prop is provided', () => {
|
||||
renderWithTheme(<HttpMethodSelector method="CUSTOM" onMethodSelect={mockOnMethodSelect} />);
|
||||
|
||||
const methodSpan = screen.getByText('CUSTOM');
|
||||
expect(methodSpan).toBeInTheDocument();
|
||||
expect(methodSpan).toHaveAttribute('title', 'CUSTOM');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dropdown Interaction', () => {
|
||||
it('should display all standard HTTP methods in dropdown when clicked', async () => {
|
||||
renderWithTheme(<HttpMethodSelector onMethodSelect={mockOnMethodSelect} />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
fireEvent.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
const standardMethods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS', 'HEAD', 'TRACE', 'CONNECT'];
|
||||
const dropdownItems = screen.getAllByText((content, element) => {
|
||||
return element?.classList.contains('dropdown-item');
|
||||
});
|
||||
const renderedMethods = dropdownItems.map(item => item.textContent);
|
||||
|
||||
standardMethods.forEach(method => {
|
||||
expect(renderedMethods).toContain(method);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should display "Add Custom" option in dropdown', async () => {
|
||||
renderWithTheme(<HttpMethodSelector onMethodSelect={mockOnMethodSelect} />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
fireEvent.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
const addCustomSpan = screen.getByText('+ Add Custom');
|
||||
expect(addCustomSpan).toBeInTheDocument();
|
||||
expect(addCustomSpan).toHaveClass('text-link');
|
||||
});
|
||||
});
|
||||
|
||||
it('should call onMethodSelect when a standard method is clicked', async () => {
|
||||
renderWithTheme(<HttpMethodSelector onMethodSelect={mockOnMethodSelect} />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
fireEvent.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
const postMethod = screen.getByText('POST');
|
||||
fireEvent.click(postMethod);
|
||||
});
|
||||
|
||||
expect(mockOnMethodSelect).toHaveBeenCalledWith('POST');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Custom Method Mode', () => {
|
||||
it('should enter custom mode when "Add Custom" is clicked', async () => {
|
||||
renderWithTheme(<HttpMethodSelector onMethodSelect={mockOnMethodSelect} />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
fireEvent.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
const addCustom = screen.getByText('+ Add Custom');
|
||||
fireEvent.click(addCustom);
|
||||
});
|
||||
|
||||
expect(mockOnMethodSelect).toHaveBeenCalledWith('');
|
||||
|
||||
// Should show input field
|
||||
await waitFor(() => {
|
||||
const input = screen.getByRole('textbox');
|
||||
expect(input).toBeInTheDocument();
|
||||
expect(input).toHaveFocus();
|
||||
});
|
||||
});
|
||||
|
||||
it('should call onMethodSelect with uppercase value when typing in custom input', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Create a wrapper component that manages the method state
|
||||
const TestWrapper = () => {
|
||||
const [method, setMethod] = React.useState('GET');
|
||||
|
||||
const handleMethodSelect = (newMethod) => {
|
||||
mockOnMethodSelect(newMethod);
|
||||
setMethod(newMethod);
|
||||
};
|
||||
|
||||
return (
|
||||
<HttpMethodSelector
|
||||
method={method}
|
||||
onMethodSelect={handleMethodSelect}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
renderWithTheme(<TestWrapper />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
fireEvent.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
const addCustom = screen.getByText('+ Add Custom');
|
||||
fireEvent.click(addCustom);
|
||||
});
|
||||
|
||||
const input = await screen.findByRole('textbox');
|
||||
await user.type(input, 'custom');
|
||||
|
||||
expect(mockOnMethodSelect).toHaveBeenCalledWith('');
|
||||
expect(mockOnMethodSelect).toHaveBeenCalledWith('C');
|
||||
expect(mockOnMethodSelect).toHaveBeenCalledWith('CU');
|
||||
expect(mockOnMethodSelect).toHaveBeenCalledWith('CUS');
|
||||
expect(mockOnMethodSelect).toHaveBeenCalledWith('CUST');
|
||||
expect(mockOnMethodSelect).toHaveBeenCalledWith('CUSTO');
|
||||
expect(mockOnMethodSelect).toHaveBeenCalledWith('CUSTOM');
|
||||
expect(mockOnMethodSelect).toHaveBeenCalledTimes(7);
|
||||
});
|
||||
|
||||
it('should exit custom mode and set method on Enter key', async () => {
|
||||
renderWithTheme(<HttpMethodSelector onMethodSelect={mockOnMethodSelect} />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
fireEvent.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
const addCustom = screen.getByText('+ Add Custom');
|
||||
fireEvent.click(addCustom);
|
||||
});
|
||||
|
||||
const input = await screen.findByRole('textbox');
|
||||
fireEvent.change(input, { target: { value: 'CUSTOM' } });
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
|
||||
expect(mockOnMethodSelect).toHaveBeenCalledWith('CUSTOM');
|
||||
|
||||
// Should exit custom mode
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should set default method on Enter key with empty input', async () => {
|
||||
renderWithTheme(<HttpMethodSelector onMethodSelect={mockOnMethodSelect} />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
fireEvent.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
const addCustom = screen.getByText('+ Add Custom');
|
||||
fireEvent.click(addCustom);
|
||||
});
|
||||
|
||||
const input = await screen.findByRole('textbox');
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
|
||||
expect(mockOnMethodSelect).toHaveBeenCalledWith('GET');
|
||||
});
|
||||
|
||||
it('should exit custom mode on Escape key and keep the custom method', async () => {
|
||||
renderWithTheme(<HttpMethodSelector method="POST" onMethodSelect={mockOnMethodSelect} />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
fireEvent.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
const addCustom = screen.getByText('+ Add Custom');
|
||||
fireEvent.click(addCustom);
|
||||
});
|
||||
|
||||
const input = await screen.findByRole('textbox');
|
||||
fireEvent.change(input, { target: { value: 'CUSTOM' } });
|
||||
fireEvent.keyDown(input, { key: 'Escape' });
|
||||
|
||||
// Should exit custom mode and onMethodSelect should be called with custom method
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
|
||||
expect(mockOnMethodSelect).toHaveBeenCalledWith('CUSTOM');
|
||||
});
|
||||
});
|
||||
|
||||
it('should exit custom mode on blur and keep the custom method', async () => {
|
||||
renderWithTheme(<HttpMethodSelector onMethodSelect={mockOnMethodSelect} />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
fireEvent.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
const addCustom = screen.getByText('+ Add Custom');
|
||||
fireEvent.click(addCustom);
|
||||
});
|
||||
|
||||
const input = await screen.findByRole('textbox');
|
||||
fireEvent.change(input, { target: { value: 'CUSTOM' } });
|
||||
fireEvent.blur(input);
|
||||
|
||||
// Should exit custom mode and onMethodSelect should be called with custom method
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
|
||||
expect(mockOnMethodSelect).toHaveBeenCalledWith('CUSTOM');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -80,7 +80,7 @@ const QueryUrl = ({ item, collection, handleRun }) => {
|
||||
|
||||
return (
|
||||
<StyledWrapper className="flex items-center">
|
||||
<div className="flex items-center h-full method-selector-container">
|
||||
<div className="flex flex-1 items-center h-full method-selector-container">
|
||||
{isGrpc ? (
|
||||
<div className="flex items-center justify-center h-full w-16">
|
||||
<span className="text-xs text-indigo-500 font-bold">gRPC</span>
|
||||
@@ -112,6 +112,7 @@ const QueryUrl = ({ item, collection, handleRun }) => {
|
||||
/>
|
||||
<div className="flex items-center h-full mr-2 cursor-pointer" id="send-request" onClick={handleRun}>
|
||||
<div
|
||||
title="Generate Code"
|
||||
className="infotip mr-3"
|
||||
onClick={(e) => {
|
||||
handleGenerateCode(e);
|
||||
@@ -128,6 +129,7 @@ const QueryUrl = ({ item, collection, handleRun }) => {
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
title="Save Request"
|
||||
className="infotip mr-3"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -145,7 +147,7 @@ const QueryUrl = ({ item, collection, handleRun }) => {
|
||||
Save <span className="shortcut">({saveShortcut})</span>
|
||||
</span>
|
||||
</div>
|
||||
<IconArrowRight color={theme.requestTabPanel.url.icon} strokeWidth={1.5} size={22} />
|
||||
<IconArrowRight color={theme.requestTabPanel.url.icon} strokeWidth={1.5} size={22} data-testid="send-arrow-icon" />
|
||||
</div>
|
||||
</div>
|
||||
{generateCodeItemModalOpen && (
|
||||
|
||||
@@ -63,7 +63,8 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
|
||||
div.graphql-docs-explorer-container {
|
||||
background: white;
|
||||
background: ${(props) => props.theme.requestTabPanel.graphqlDocsExplorer.bg};
|
||||
color: ${(props) => props.theme.requestTabPanel.graphqlDocsExplorer.color};
|
||||
outline: none;
|
||||
box-shadow: rgb(0 0 0 / 15%) 0px 0px 8px;
|
||||
position: absolute;
|
||||
@@ -72,6 +73,14 @@ const StyledWrapper = styled.div`
|
||||
width: 350px;
|
||||
height: 100%;
|
||||
|
||||
.doc-explorer-contents,
|
||||
.doc-explorer,
|
||||
.search-box > input,
|
||||
.search-box-clear {
|
||||
background-color: ${(props) => props.theme.requestTabPanel.graphqlDocsExplorer.bg};
|
||||
color: ${(props) => props.theme.requestTabPanel.graphqlDocsExplorer.color};
|
||||
}
|
||||
|
||||
div.doc-explorer-title {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import React from 'react';
|
||||
import { uuid } from 'utils/common';
|
||||
import { IconFiles, IconRun, IconEye, IconSettings } from '@tabler/icons';
|
||||
import EnvironmentSelector from 'components/Environments/EnvironmentSelector';
|
||||
import GlobalEnvironmentSelector from 'components/GlobalEnvironments/EnvironmentSelector';
|
||||
import { addTab } from 'providers/ReduxStore/slices/tabs';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import ToolHint from 'components/ToolHint';
|
||||
@@ -69,9 +68,8 @@ const CollectionToolBar = ({ collection }) => {
|
||||
</ToolHint>
|
||||
</span>
|
||||
<span>
|
||||
<GlobalEnvironmentSelector />
|
||||
<EnvironmentSelector collection={collection} />
|
||||
</span>
|
||||
<EnvironmentSelector collection={collection} />
|
||||
</div>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import React from 'react';
|
||||
import { useDrag, useDrop } from 'react-dnd';
|
||||
|
||||
const DraggableTab = ({ id, onMoveTab, index, children, className, onClick }) => {
|
||||
const ref = React.useRef(null);
|
||||
|
||||
const [{ handlerId, isOver }, drop] = useDrop({
|
||||
accept: 'tab',
|
||||
hover(item, monitor) {
|
||||
onMoveTab(item.id, id);
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
handlerId: monitor.getHandlerId(),
|
||||
isOver: monitor.isOver()
|
||||
})
|
||||
});
|
||||
|
||||
const [{ isDragging }, drag] = useDrag({
|
||||
type: 'tab',
|
||||
item: () => {
|
||||
return { id, index };
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isDragging: monitor.isDragging()
|
||||
}),
|
||||
options: {
|
||||
dropEffect: 'move'
|
||||
}
|
||||
});
|
||||
|
||||
drag(drop(ref));
|
||||
|
||||
return (
|
||||
<li
|
||||
className={className}
|
||||
ref={ref}
|
||||
role="tab"
|
||||
style={{ opacity: isDragging || isOver ? 0 : 1 }}
|
||||
onClick={onClick}
|
||||
data-handler-id={handlerId}
|
||||
>
|
||||
{children}
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
export default DraggableTab;
|
||||
@@ -4,11 +4,12 @@ import filter from 'lodash/filter';
|
||||
import classnames from 'classnames';
|
||||
import { IconChevronRight, IconChevronLeft } from '@tabler/icons';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { focusTab } from 'providers/ReduxStore/slices/tabs';
|
||||
import { focusTab, reorderTabs } from 'providers/ReduxStore/slices/tabs';
|
||||
import NewRequest from 'components/Sidebar/NewRequest';
|
||||
import CollectionToolBar from './CollectionToolBar';
|
||||
import RequestTab from './RequestTab';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import DraggableTab from './DraggableTab';
|
||||
|
||||
const RequestTabs = () => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -106,10 +107,17 @@ const RequestTabs = () => {
|
||||
{collectionRequestTabs && collectionRequestTabs.length
|
||||
? collectionRequestTabs.map((tab, index) => {
|
||||
return (
|
||||
<li
|
||||
<DraggableTab
|
||||
key={tab.uid}
|
||||
id={tab.uid}
|
||||
index={index}
|
||||
onMoveTab={(source, target) => {
|
||||
dispatch(reorderTabs({
|
||||
sourceUid: source,
|
||||
targetUid: target
|
||||
}));
|
||||
}}
|
||||
className={getTabClassname(tab, index)}
|
||||
role="tab"
|
||||
onClick={() => handleClick(tab)}
|
||||
>
|
||||
<RequestTab
|
||||
@@ -120,7 +128,7 @@ const RequestTabs = () => {
|
||||
collection={activeCollection}
|
||||
folderUid={tab.folderUid}
|
||||
/>
|
||||
</li>
|
||||
</DraggableTab>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
|
||||
@@ -16,7 +16,7 @@ const StatusCode = ({ status }) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper className={getTabClassname(status)}>
|
||||
<StyledWrapper className={`response-status-code ${getTabClassname(status)}`} data-testid="response-status-code">
|
||||
{status} {statusCodePhraseMap[status]}
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -12,12 +12,15 @@ import PathDisplay from 'components/PathDisplay';
|
||||
import { useState } from 'react';
|
||||
import { IconArrowBackUp, IconEdit } from "@tabler/icons";
|
||||
import { findCollectionByUid } from 'utils/collections/index';
|
||||
import get from 'lodash/get';
|
||||
|
||||
const CloneCollection = ({ onClose, collectionUid }) => {
|
||||
const inputRef = useRef();
|
||||
const dispatch = useDispatch();
|
||||
const [isEditing, toggleEditing] = useState(false);
|
||||
const collection = useSelector(state => findCollectionByUid(state.collections.collections, collectionUid));
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const defaultLocation = get(preferences, 'general.defaultCollectionLocation', '');
|
||||
const { name } = collection;
|
||||
|
||||
const formik = useFormik({
|
||||
@@ -25,7 +28,7 @@ const CloneCollection = ({ onClose, collectionUid }) => {
|
||||
initialValues: {
|
||||
collectionName: `${name} copy`,
|
||||
collectionFolderName: `${sanitizeName(name)} copy`,
|
||||
collectionLocation: ''
|
||||
collectionLocation: defaultLocation
|
||||
},
|
||||
validationSchema: Yup.object({
|
||||
collectionName: Yup.string()
|
||||
|
||||
@@ -60,8 +60,8 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
|
||||
const [dropType, setDropType] = useState(null); // 'adjacent' or 'inside'
|
||||
|
||||
const [{ isDragging }, drag, dragPreview] = useDrag({
|
||||
type: `collection-item-${collectionUid}`,
|
||||
item,
|
||||
type: 'collection-item',
|
||||
item: { ...item, sourceCollectionUid: collectionUid },
|
||||
collect: (monitor) => ({
|
||||
isDragging: monitor.isDragging()
|
||||
}),
|
||||
@@ -92,10 +92,15 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
|
||||
|
||||
const canItemBeDropped = ({ draggedItem, targetItem, dropType }) => {
|
||||
const { uid: targetItemUid, pathname: targetItemPathname } = targetItem;
|
||||
const { uid: draggedItemUid, pathname: draggedItemPathname } = draggedItem;
|
||||
const { uid: draggedItemUid, pathname: draggedItemPathname, sourceCollectionUid } = draggedItem;
|
||||
|
||||
if (draggedItemUid === targetItemUid) return false;
|
||||
|
||||
// For cross-collection moves, we allow the drop
|
||||
if (sourceCollectionUid !== collectionUid) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const newPathname = calculateDraggedItemNewPathname({ draggedItem, targetItem, dropType, collectionPathname });
|
||||
if (!newPathname) return false;
|
||||
|
||||
@@ -105,7 +110,7 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
|
||||
};
|
||||
|
||||
const [{ isOver, canDrop }, drop] = useDrop({
|
||||
accept: `collection-item-${collectionUid}`,
|
||||
accept: 'collection-item',
|
||||
hover: (draggedItem, monitor) => {
|
||||
const { uid: targetItemUid } = item;
|
||||
const { uid: draggedItemUid } = draggedItem;
|
||||
|
||||
@@ -7,6 +7,7 @@ const Wrapper = styled.div`
|
||||
user-select: none;
|
||||
padding-left: 8px;
|
||||
font-weight: 600;
|
||||
border: ${(props) => props.theme.dragAndDrop.borderStyle} transparent;
|
||||
|
||||
.rotate-90 {
|
||||
transform: rotateZ(90deg);
|
||||
@@ -66,6 +67,7 @@ const Wrapper = styled.div`
|
||||
}
|
||||
|
||||
&.drop-target {
|
||||
border: ${(props) => props.theme.dragAndDrop.borderStyle} ${(props) => props.theme.dragAndDrop.border};
|
||||
background-color: ${(props) => props.theme.dragAndDrop.hoverBg};
|
||||
transition: ${(props) => props.theme.dragAndDrop.transition};
|
||||
}
|
||||
@@ -95,15 +97,6 @@ const Wrapper = styled.div`
|
||||
}
|
||||
}
|
||||
|
||||
.collection-name.drop-target {
|
||||
border: ${(props) => props.theme.dragAndDrop.borderStyle} ${(props) => props.theme.dragAndDrop.border};
|
||||
border-radius: 4px;
|
||||
background-color: ${(props) => props.theme.dragAndDrop.hoverBg};
|
||||
margin: -2px;
|
||||
transition: ${(props) => props.theme.dragAndDrop.transition};
|
||||
box-shadow: 0 0 0 2px ${(props) => props.theme.dragAndDrop.hoverBg};
|
||||
}
|
||||
|
||||
#sidebar-collection-name {
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
@@ -6,9 +6,10 @@ import filter from 'lodash/filter';
|
||||
import { useDrop, useDrag } from 'react-dnd';
|
||||
import { IconChevronRight, IconDots, IconLoader2 } from '@tabler/icons';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import { toggleCollection } from 'providers/ReduxStore/slices/collections';
|
||||
import { toggleCollection, collapseFullCollection } from 'providers/ReduxStore/slices/collections';
|
||||
import { mountCollection, moveCollectionAndPersist, handleCollectionItemDrop } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { hideHomePage } from 'providers/ReduxStore/slices/app';
|
||||
import { addTab, makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
|
||||
import NewRequest from 'components/Sidebar/NewRequest';
|
||||
import NewFolder from 'components/Sidebar/NewFolder';
|
||||
@@ -34,6 +35,7 @@ const Collection = ({ collection, searchText }) => {
|
||||
const [showCloneCollectionModalOpen, setShowCloneCollectionModalOpen] = useState(false);
|
||||
const [showShareCollectionModal, setShowShareCollectionModal] = useState(false);
|
||||
const [showRemoveCollectionModal, setShowRemoveCollectionModal] = useState(false);
|
||||
const [dropType, setDropType] = useState(null);
|
||||
const dispatch = useDispatch();
|
||||
const isLoading = areItemsLoading(collection);
|
||||
const collectionRef = useRef(null);
|
||||
@@ -42,7 +44,7 @@ const Collection = ({ collection, searchText }) => {
|
||||
|
||||
const menuDropdownTippyRef = useRef();
|
||||
const onMenuDropdownCreate = (ref) => (menuDropdownTippyRef.current = ref);
|
||||
const MenuIcon = forwardRef((props, ref) => {
|
||||
const MenuIcon = forwardRef((_props, ref) => {
|
||||
return (
|
||||
<div ref={ref} className="pr-2">
|
||||
<IconDots size={22} />
|
||||
@@ -91,6 +93,7 @@ const Collection = ({ collection, searchText }) => {
|
||||
}
|
||||
|
||||
if(!isChevronClick) {
|
||||
dispatch(hideHomePage()); // @TODO Playwright tests are often stuck on home page, rather than collection settings tab. Revisit for a proper fix.
|
||||
dispatch(
|
||||
addTab({
|
||||
uid: collection.uid,
|
||||
@@ -101,7 +104,7 @@ const Collection = ({ collection, searchText }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDoubleClick = (event) => {
|
||||
const handleDoubleClick = (_event) => {
|
||||
dispatch(makeTabPermanent({ uid: collection.uid }))
|
||||
};
|
||||
|
||||
@@ -118,7 +121,7 @@ const Collection = ({ collection, searchText }) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const handleRightClick = (event) => {
|
||||
const handleRightClick = (_event) => {
|
||||
const _menuDropdown = menuDropdownTippyRef.current;
|
||||
if (_menuDropdown) {
|
||||
let menuDropdownBehavior = 'show';
|
||||
@@ -129,6 +132,10 @@ const Collection = ({ collection, searchText }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCollapseFullCollection = () => {
|
||||
dispatch(collapseFullCollection({ collectionUid: collection.uid }));
|
||||
};
|
||||
|
||||
const viewCollectionSettings = () => {
|
||||
dispatch(
|
||||
addTab({
|
||||
@@ -140,7 +147,7 @@ const Collection = ({ collection, searchText }) => {
|
||||
};
|
||||
|
||||
const isCollectionItem = (itemType) => {
|
||||
return itemType.startsWith('collection-item');
|
||||
return itemType === 'collection-item';
|
||||
};
|
||||
|
||||
const [{ isDragging }, drag, dragPreview] = useDrag({
|
||||
@@ -155,7 +162,17 @@ const Collection = ({ collection, searchText }) => {
|
||||
});
|
||||
|
||||
const [{ isOver }, drop] = useDrop({
|
||||
accept: ["collection", `collection-item-${collection.uid}`],
|
||||
accept: ["collection", "collection-item"],
|
||||
hover: (_draggedItem, monitor) => {
|
||||
const itemType = monitor.getItemType();
|
||||
if (isCollectionItem(itemType)) {
|
||||
// For collection items, always show full highlight (inside drop)
|
||||
setDropType('inside');
|
||||
} else {
|
||||
// For collections, show line indicator (adjacent drop)
|
||||
setDropType('adjacent');
|
||||
}
|
||||
},
|
||||
drop: (draggedItem, monitor) => {
|
||||
const itemType = monitor.getItemType();
|
||||
if (isCollectionItem(itemType)) {
|
||||
@@ -163,6 +180,7 @@ const Collection = ({ collection, searchText }) => {
|
||||
} else {
|
||||
dispatch(moveCollectionAndPersist({draggedItem, targetItem: collection}));
|
||||
}
|
||||
setDropType(null);
|
||||
},
|
||||
canDrop: (draggedItem) => {
|
||||
return draggedItem.uid !== collection.uid;
|
||||
@@ -183,7 +201,8 @@ const Collection = ({ collection, searchText }) => {
|
||||
}
|
||||
|
||||
const collectionRowClassName = classnames('flex py-1 collection-name items-center', {
|
||||
'item-hovered': isOver,
|
||||
'item-hovered': isOver && dropType === 'adjacent', // For collection-to-collection moves (show line)
|
||||
'drop-target': isOver && dropType === 'inside', // For collection-item drops (highlight full area)
|
||||
'collection-focused-in-tab': isCollectionFocused
|
||||
});
|
||||
|
||||
@@ -196,7 +215,7 @@ const Collection = ({ collection, searchText }) => {
|
||||
const folderItems = sortByNameThenSequence(filter(collection.items, (i) => isItemAFolder(i)));
|
||||
|
||||
return (
|
||||
<StyledWrapper className="flex flex-col">
|
||||
<StyledWrapper className="flex flex-col" id={`collection-${collection.name.replace(/\s+/g, '-').toLowerCase()}`}>
|
||||
{showNewRequestModal && <NewRequest collectionUid={collection.uid} onClose={() => setShowNewRequestModal(false)} />}
|
||||
{showNewFolderModal && <NewFolder collectionUid={collection.uid} onClose={() => setShowNewFolderModal(false)} />}
|
||||
{showRenameCollectionModal && (
|
||||
@@ -232,16 +251,16 @@ const Collection = ({ collection, searchText }) => {
|
||||
onClick={handleCollectionCollapse}
|
||||
onDoubleClick={handleCollectionDoubleClick}
|
||||
/>
|
||||
<div className="ml-1 w-full" id="sidebar-collection-name">
|
||||
<div className="ml-1 w-full" id="sidebar-collection-name" title={collection.name}>
|
||||
{collection.name}
|
||||
</div>
|
||||
{isLoading ? <IconLoader2 className="animate-spin mx-1" size={18} strokeWidth={1.5} /> : null}
|
||||
</div>
|
||||
<div className="collection-actions">
|
||||
<div className="collection-actions" data-testid="collection-actions">
|
||||
<Dropdown onCreate={onMenuDropdownCreate} icon={<MenuIcon />} placement="bottom-start">
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={(e) => {
|
||||
onClick={(_e) => {
|
||||
menuDropdownTippyRef.current.hide();
|
||||
setShowNewRequestModal(true);
|
||||
}}
|
||||
@@ -250,7 +269,7 @@ const Collection = ({ collection, searchText }) => {
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={(e) => {
|
||||
onClick={(_e) => {
|
||||
menuDropdownTippyRef.current.hide();
|
||||
setShowNewFolderModal(true);
|
||||
}}
|
||||
@@ -259,7 +278,8 @@ const Collection = ({ collection, searchText }) => {
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={(e) => {
|
||||
data-testid="clone-collection"
|
||||
onClick={(_e) => {
|
||||
menuDropdownTippyRef.current.hide();
|
||||
setShowCloneCollectionModalOpen(true);
|
||||
}}
|
||||
@@ -268,7 +288,7 @@ const Collection = ({ collection, searchText }) => {
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={(e) => {
|
||||
onClick={(_e) => {
|
||||
menuDropdownTippyRef.current.hide();
|
||||
ensureCollectionIsMounted();
|
||||
handleRun();
|
||||
@@ -278,7 +298,7 @@ const Collection = ({ collection, searchText }) => {
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={(e) => {
|
||||
onClick={(_e) => {
|
||||
menuDropdownTippyRef.current.hide();
|
||||
setShowRenameCollectionModal(true);
|
||||
}}
|
||||
@@ -287,7 +307,7 @@ const Collection = ({ collection, searchText }) => {
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={(e) => {
|
||||
onClick={(_e) => {
|
||||
menuDropdownTippyRef.current.hide();
|
||||
ensureCollectionIsMounted();
|
||||
setShowShareCollectionModal(true);
|
||||
@@ -297,7 +317,16 @@ const Collection = ({ collection, searchText }) => {
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={(e) => {
|
||||
onClick={(_e) => {
|
||||
menuDropdownTippyRef.current.hide();
|
||||
handleCollapseFullCollection();
|
||||
}}
|
||||
>
|
||||
Collapse
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={(_e) => {
|
||||
menuDropdownTippyRef.current.hide();
|
||||
setShowRemoveCollectionModal(true);
|
||||
}}
|
||||
@@ -306,7 +335,7 @@ const Collection = ({ collection, searchText }) => {
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={(e) => {
|
||||
onClick={(_e) => {
|
||||
menuDropdownTippyRef.current.hide();
|
||||
viewCollectionSettings();
|
||||
}}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import { browseDirectory } from 'providers/ReduxStore/slices/collections/actions';
|
||||
@@ -14,18 +14,21 @@ import Help from 'components/Help';
|
||||
import { multiLineMsg } from "utils/common";
|
||||
import { formatIpcError } from "utils/common/error";
|
||||
import { toggleSidebarCollapse } from 'providers/ReduxStore/slices/app';
|
||||
import get from 'lodash/get';
|
||||
|
||||
const CreateCollection = ({ onClose }) => {
|
||||
const inputRef = useRef();
|
||||
const dispatch = useDispatch();
|
||||
const [isEditing, toggleEditing] = useState(false);
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const defaultLocation = get(preferences, 'general.defaultCollectionLocation', '');
|
||||
|
||||
const formik = useFormik({
|
||||
enableReinitialize: true,
|
||||
initialValues: {
|
||||
collectionName: '',
|
||||
collectionFolderName: '',
|
||||
collectionLocation: ''
|
||||
collectionLocation: defaultLocation
|
||||
},
|
||||
validationSchema: Yup.object({
|
||||
collectionName: Yup.string()
|
||||
|
||||
@@ -153,7 +153,7 @@ const ImportCollection = ({ onClose, handleSubmit }) => {
|
||||
]
|
||||
|
||||
return (
|
||||
<Modal size="sm" title="Import Collection" hideFooter={true} handleCancel={onClose}>
|
||||
<Modal size="sm" title="Import Collection" hideFooter={true} handleCancel={onClose} dataTestId="import-collection-modal">
|
||||
<div className="flex flex-col">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">Import from file</h3>
|
||||
|
||||
@@ -48,7 +48,7 @@ const ImportCollectionLocation = ({ onClose, handleSubmit, collectionName }) =>
|
||||
const onSubmit = () => formik.handleSubmit();
|
||||
|
||||
return (
|
||||
<Modal size="sm" title="Import Collection" confirmText="Import" handleConfirm={onSubmit} handleCancel={onClose}>
|
||||
<Modal size="sm" title="Import Collection" confirmText="Import" handleConfirm={onSubmit} handleCancel={onClose} dataTestId="import-collection-location-modal">
|
||||
<form className="bruno-form" onSubmit={e => e.preventDefault()}>
|
||||
<div>
|
||||
<label htmlFor="collectionName" className="block font-semibold">
|
||||
|
||||
@@ -463,7 +463,7 @@ const NewRequest = ({ collectionUid, item, isEphemeral, onClose }) => {
|
||||
</label>
|
||||
<div className="flex items-center mt-2 ">
|
||||
{formik.values.requestType !== 'grpc-request' ? (
|
||||
<div className="flex items-center h-full method-selector-container">
|
||||
<div className="flex items-center h-full method-selector-container w-1/5">
|
||||
<HttpMethodSelector
|
||||
method={formik.values.requestMethod}
|
||||
onMethodSelect={(val) => formik.setFieldValue('requestMethod', val)}
|
||||
|
||||
@@ -78,7 +78,7 @@ const TitleBar = () => {
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center">
|
||||
<button className="flex items-center gap-2 text-sm font-medium" onClick={handleTitleClick}>
|
||||
<button className="bruno-logo flex items-center gap-2 text-sm font-medium" onClick={handleTitleClick}>
|
||||
<span aria-hidden>
|
||||
<Bruno width={30} />
|
||||
</span>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { Component } from 'react';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
import { getAllVariables } from 'utils/collections';
|
||||
import { defineCodeMirrorBrunoVariablesMode, MaskedEditor } from 'utils/common/codemirror';
|
||||
import { defineCodeMirrorBrunoVariablesMode } from 'utils/common/codemirror';
|
||||
import { MaskedEditor } from 'utils/common/masked-editor';
|
||||
import { setupAutoComplete } from 'utils/codemirror/autocomplete';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { IconEye, IconEyeOff } from '@tabler/icons';
|
||||
@@ -159,6 +160,10 @@ class SingleLineEditor extends Component {
|
||||
if (this.brunoAutoCompleteCleanup) {
|
||||
this.brunoAutoCompleteCleanup();
|
||||
}
|
||||
if (this.maskedEditor) {
|
||||
this.maskedEditor.destroy();
|
||||
this.maskedEditor = null;
|
||||
}
|
||||
}
|
||||
|
||||
addOverlay = (variables) => {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { IconSettings, IconCookie, IconTool } from '@tabler/icons';
|
||||
import IconSidebarToggle from 'components/Icons/IconSidebarToggle';
|
||||
import { IconSettings, IconCookie, IconTool, IconSearch } from '@tabler/icons';
|
||||
import Mousetrap from 'mousetrap';
|
||||
import { getKeyBindingsForActionAllOS } from 'providers/Hotkeys/keyMappings';
|
||||
import ToolHint from 'components/ToolHint';
|
||||
import Preferences from 'components/Preferences';
|
||||
import IconSidebarToggle from 'components/Icons/IconSidebarToggle';
|
||||
import Cookies from 'components/Cookies';
|
||||
import Notifications from 'components/Notifications';
|
||||
import Portal from 'components/Portal';
|
||||
@@ -26,6 +28,13 @@ const StatusBar = () => {
|
||||
dispatch(openConsole());
|
||||
};
|
||||
|
||||
const openGlobalSearch = () => {
|
||||
const bindings = getKeyBindingsForActionAllOS('globalSearch') || [];
|
||||
bindings.forEach((binding) => {
|
||||
Mousetrap.trigger(binding);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
{preferencesOpen && (
|
||||
@@ -73,7 +82,7 @@ const StatusBar = () => {
|
||||
|
||||
<ToolHint text="Preferences" toolhintId="Preferences" place="top-start" offset={10}>
|
||||
<button
|
||||
className="status-bar-button"
|
||||
className="status-bar-button preferences-button"
|
||||
data-trigger="preferences"
|
||||
onClick={() => dispatch(showPreferences(true))}
|
||||
tabIndex={0}
|
||||
@@ -93,6 +102,19 @@ const StatusBar = () => {
|
||||
|
||||
<div className="status-bar-section">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
className="status-bar-button"
|
||||
data-trigger="search"
|
||||
onClick={openGlobalSearch}
|
||||
tabIndex={0}
|
||||
aria-label="Global Search"
|
||||
>
|
||||
<div className="console-button-content">
|
||||
<IconSearch size={16} strokeWidth={1.5} aria-hidden="true" />
|
||||
<span className="console-label">Search</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="status-bar-button"
|
||||
data-trigger="cookies"
|
||||
|
||||
@@ -24,6 +24,22 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.keycap {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1px 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: 0.75rem;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
line-height: 1;
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
|
||||
@@ -78,7 +78,7 @@ const Welcome = () => {
|
||||
aria-label={t('WELCOME.CREATE_COLLECTION')}
|
||||
>
|
||||
<IconPlus aria-hidden size={18} strokeWidth={2} />
|
||||
<span className="label ml-2" id="create-collection">
|
||||
<span className="label ml-2" id="create-collection" data-testid="create-collection">
|
||||
{t('WELCOME.CREATE_COLLECTION')}
|
||||
</span>
|
||||
</button>
|
||||
@@ -138,6 +138,12 @@ const Welcome = () => {
|
||||
<span className="label ml-2">{t('COMMON.GITHUB')}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 select-none">
|
||||
{t('WELCOME.GLOBAL_SEARCH_TIP_PART1')} <span className="keycap">⌘</span>{' '}<span className="keycap">K</span>{' '}
|
||||
{t('WELCOME.GLOBAL_SEARCH_TIP_PART2')} <span className="keycap">Ctrl</span>{' '}<span className="keycap">K</span>{' '}
|
||||
{t('WELCOME.GLOBAL_SEARCH_TIP_PART3')}
|
||||
</div>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
"IMPORT_COLLECTION": "Import Collection",
|
||||
"COLLECTION_IMPORT_SUCCESS": "Collection imported successfully",
|
||||
"COLLECTION_IMPORT_ERROR": "An error occurred while importing the collection. Check the logs for more information.",
|
||||
"COLLECTION_OPEN_ERROR": "An error occurred while opening the collection"
|
||||
"COLLECTION_OPEN_ERROR": "An error occurred while opening the collection",
|
||||
"GLOBAL_SEARCH_TIP_PART1": "Press",
|
||||
"GLOBAL_SEARCH_TIP_PART2": "(mac) or",
|
||||
"GLOBAL_SEARCH_TIP_PART3": "(windows) anytime to quickly search collections, folders, and requests"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import Sidebar from 'components/Sidebar';
|
||||
import StatusBar from 'components/StatusBar';
|
||||
// import ErrorCapture from 'components/ErrorCapture';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { isElectron } from 'utils/common/platform';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import 'codemirror/theme/material.css';
|
||||
import 'codemirror/theme/monokai.css';
|
||||
@@ -56,12 +57,31 @@ export default function Main() {
|
||||
'is-dragging': isDragging
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isElectron()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { ipcRenderer } = window;
|
||||
|
||||
const removeAppLoadedListener = ipcRenderer.on('main:app-loaded', () => {
|
||||
if (mainSectionRef.current) {
|
||||
mainSectionRef.current.setAttribute('data-app-state', 'loaded');
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
removeAppLoadedListener();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
// <ErrorCapture>
|
||||
<div className="flex flex-col h-screen max-h-screen overflow-hidden">
|
||||
<div
|
||||
<div id="main-container" className="flex flex-col h-screen max-h-screen overflow-hidden">
|
||||
<div
|
||||
ref={mainSectionRef}
|
||||
className="flex-1 min-h-0 flex"
|
||||
data-app-state="loading"
|
||||
style={{
|
||||
height: isConsoleOpen ? `calc(100vh - 22px - ${isConsoleOpen ? '300px' : '0px'})` : 'calc(100vh - 22px)'
|
||||
}}
|
||||
@@ -80,7 +100,7 @@ export default function Main() {
|
||||
</section>
|
||||
</StyledWrapper>
|
||||
</div>
|
||||
|
||||
|
||||
<Devtools mainSectionRef={mainSectionRef} />
|
||||
<StatusBar />
|
||||
</div>
|
||||
|
||||
@@ -26,6 +26,7 @@ import { isElectron } from 'utils/common/platform';
|
||||
import { globalEnvironmentsUpdateEvent, updateGlobalEnvironments } from 'providers/ReduxStore/slices/global-environments';
|
||||
import { collectionAddOauth2CredentialsByUrl, updateCollectionLoadingState } from 'providers/ReduxStore/slices/collections/index';
|
||||
import { addLog } from 'providers/ReduxStore/slices/logs';
|
||||
import { updateSystemResources } from 'providers/ReduxStore/slices/performance';
|
||||
|
||||
const useIpcEvents = () => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -145,6 +146,10 @@ const useIpcEvents = () => {
|
||||
}));
|
||||
});
|
||||
|
||||
const removeSystemResourcesListener = ipcRenderer.on('main:filesync-system-resources', resourceData => {
|
||||
dispatch(updateSystemResources(resourceData));
|
||||
});
|
||||
|
||||
const removeConfigUpdatesListener = ipcRenderer.on('main:bruno-config-update', (val) =>
|
||||
dispatch(brunoConfigUpdateEvent(val))
|
||||
);
|
||||
@@ -209,6 +214,7 @@ const useIpcEvents = () => {
|
||||
removeCollectionOauth2CredentialsUpdatesListener();
|
||||
removeCollectionLoadingStateListener();
|
||||
removePersistentEnvVariablesUpdateListener();
|
||||
removeSystemResourcesListener();
|
||||
};
|
||||
}, [isElectron]);
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user