mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-04 09:58:35 +00:00
Compare commits
120 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34460d5bcf | ||
|
|
422a43ce56 | ||
|
|
2720ac20b4 | ||
|
|
2e1c8b3382 | ||
|
|
95fccbeb8d | ||
|
|
e964bdc7fe | ||
|
|
cd06f28430 | ||
|
|
3b502fd63d | ||
|
|
d4cd34fc50 | ||
|
|
58942b383d | ||
|
|
476d30a49e | ||
|
|
4d6032ba0d | ||
|
|
fabba4d296 | ||
|
|
c273c10f0c | ||
|
|
073b1ef036 | ||
|
|
5db34dff11 | ||
|
|
233013df20 | ||
|
|
5086ac4b8c | ||
|
|
f112c4fdd8 | ||
|
|
5e1a36f8c8 | ||
|
|
9465de02ee | ||
|
|
5c1dc1184a | ||
|
|
bae5934137 | ||
|
|
5cd3e7abbd | ||
|
|
765c9f1060 | ||
|
|
7ddd2d3f17 | ||
|
|
ce87289616 | ||
|
|
c7ebe25cd6 | ||
|
|
0a9988f80d | ||
|
|
d73e01993d | ||
|
|
64bdef23ec | ||
|
|
97467c57bf | ||
|
|
c8abb5be16 | ||
|
|
8e978ae305 | ||
|
|
00bc93d3ac | ||
|
|
3c3acf33a0 | ||
|
|
8c9cad6d78 | ||
|
|
0b3f5100e7 | ||
|
|
c502f959b4 | ||
|
|
87ca5a85d0 | ||
|
|
40298b96a4 | ||
|
|
9e89255f6d | ||
|
|
28d1ba2438 | ||
|
|
652f3cc3fe | ||
|
|
aa7b8f4ca1 | ||
|
|
bcc1b535ff | ||
|
|
ce105aea58 | ||
|
|
8338f91487 | ||
|
|
4a78f637d3 | ||
|
|
3b38b14362 | ||
|
|
4f5c73840c | ||
|
|
3ea489816c | ||
|
|
f0866be3b3 | ||
|
|
882b11ca3d | ||
|
|
53aa9ed6e3 | ||
|
|
c01942a6f3 | ||
|
|
f491e9091b | ||
|
|
2977fc7bea | ||
|
|
d07c323755 | ||
|
|
f1b84e09c3 | ||
|
|
13e97f0367 | ||
|
|
7ef3981656 | ||
|
|
d2f6eb146b | ||
|
|
bef4b6bbee | ||
|
|
c2de480091 | ||
|
|
f5a9a485ed | ||
|
|
95de14adcb | ||
|
|
784e851d4c | ||
|
|
708e88241f | ||
|
|
bbf3cb8dd3 | ||
|
|
53b75d083f | ||
|
|
9cea60477a | ||
|
|
35cd72534b | ||
|
|
f69afd7fa2 | ||
|
|
ff975c44f2 | ||
|
|
03dcb6b7b9 | ||
|
|
c8d835ef4d | ||
|
|
9944819f73 | ||
|
|
73df422c4e | ||
|
|
304f6c8b80 | ||
|
|
4245944ccc | ||
|
|
590a5a968d | ||
|
|
650ad0fe60 | ||
|
|
367465b371 | ||
|
|
7182cee629 | ||
|
|
86b6e2f4f3 | ||
|
|
37c0a76146 | ||
|
|
4461dfded3 | ||
|
|
123c2893f3 | ||
|
|
f1d7f007fe | ||
|
|
32b9f527ea | ||
|
|
79f9dbff9f | ||
|
|
646c90819d | ||
|
|
37be721922 | ||
|
|
d6429cbb6d | ||
|
|
0109d72475 | ||
|
|
68d80b8f78 | ||
|
|
1877119b81 | ||
|
|
7e717768d2 | ||
|
|
c1dff11fa1 | ||
|
|
7e0b8d9f9d | ||
|
|
83ddfc33d2 | ||
|
|
1ab296f1e3 | ||
|
|
384bf4f190 | ||
|
|
1e25825e74 | ||
|
|
994d51b680 | ||
|
|
ab18a6ba84 | ||
|
|
a8542c7312 | ||
|
|
ab8a730bc3 | ||
|
|
c0a2d74789 | ||
|
|
b25b6f36bb | ||
|
|
670f11be37 | ||
|
|
ddb1c69fc9 | ||
|
|
6f6a9100e9 | ||
|
|
7c58740c74 | ||
|
|
e7c2c7c872 | ||
|
|
f6ff8efabe | ||
|
|
ce8775c75c | ||
|
|
803b3f0d1f | ||
|
|
9bdd439472 |
2
.github/CODEOWNERS
vendored
2
.github/CODEOWNERS
vendored
@@ -1 +1 @@
|
||||
* @helloanoop @maintainer-bruno @bijin-bruno @lohit-bruno @naman-bruno
|
||||
* @helloanoop @maintainer-bruno @bijin-bruno @lohit-bruno @naman-bruno @sid-bruno
|
||||
|
||||
19
.github/actions/auth/oauth1/linux/run-auth-e2e-tests/action.yml
vendored
Normal file
19
.github/actions/auth/oauth1/linux/run-auth-e2e-tests/action.yml
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
name: 'Run Auth E2E Tests - Linux'
|
||||
description: 'Run Auth E2E tests on Linux'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Run Auth E2E tests
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
xvfb-run npm run test:e2e:auth
|
||||
|
||||
- name: Upload Playwright Report
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report-auth-linux
|
||||
path: playwright-report/
|
||||
retention-days: 30
|
||||
30
.github/actions/auth/oauth1/linux/run-oauth1-cli-tests/action.yml
vendored
Normal file
30
.github/actions/auth/oauth1/linux/run-oauth1-cli-tests/action.yml
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
name: 'Run OAuth1 CLI Tests - Linux'
|
||||
description: 'Run OAuth1 CLI tests on Linux'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Run BRU format CLI tests
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
BRU_CLI="../../../../../../packages/bruno-cli/bin/bru.js"
|
||||
|
||||
# navigate to BRU test collection directory
|
||||
cd tests/auth/oauth1/fixtures/collections/bru
|
||||
|
||||
echo "=== BRU Format Collection Run ==="
|
||||
node $BRU_CLI run --env Local --output junit-bru.xml --format junit
|
||||
|
||||
- name: Run YML format CLI tests
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
BRU_CLI="../../../../../../packages/bruno-cli/bin/bru.js"
|
||||
|
||||
# navigate to YML test collection directory
|
||||
cd tests/auth/oauth1/fixtures/collections/yml
|
||||
|
||||
echo "=== YML Format Collection Run ==="
|
||||
node $BRU_CLI run --env Local --output junit-yml.xml --format junit
|
||||
15
.github/actions/auth/oauth1/linux/setup-feature-specific-deps/action.yml
vendored
Normal file
15
.github/actions/auth/oauth1/linux/setup-feature-specific-deps/action.yml
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
name: 'Setup Auth Feature Dependencies - Linux'
|
||||
description: 'Setup feature-specific dependencies for auth tests on Linux'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Install additional OS dependencies for auth tests
|
||||
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
|
||||
|
||||
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
|
||||
16
.github/actions/auth/oauth1/linux/start-test-server/action.yml
vendored
Normal file
16
.github/actions/auth/oauth1/linux/start-test-server/action.yml
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
name: 'Start Test Server - Linux'
|
||||
description: 'Start the bruno-tests mock server for OAuth1 CLI tests on Linux'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Start test server
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
cd packages/bruno-tests
|
||||
|
||||
echo "starting test server in background"
|
||||
node src/index.js &
|
||||
|
||||
echo "server started with PID: $!"
|
||||
17
.github/actions/auth/oauth1/macos/run-auth-e2e-tests/action.yml
vendored
Normal file
17
.github/actions/auth/oauth1/macos/run-auth-e2e-tests/action.yml
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
name: 'Run Auth E2E Tests - macOS'
|
||||
description: 'Run Auth E2E tests on macOS'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Run Auth E2E tests
|
||||
shell: bash
|
||||
run: |
|
||||
npm run test:e2e:auth
|
||||
|
||||
- name: Upload Playwright Report
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report-auth-macos
|
||||
path: playwright-report/
|
||||
retention-days: 30
|
||||
30
.github/actions/auth/oauth1/macos/run-oauth1-cli-tests/action.yml
vendored
Normal file
30
.github/actions/auth/oauth1/macos/run-oauth1-cli-tests/action.yml
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
name: 'Run OAuth1 CLI Tests - macOS'
|
||||
description: 'Run OAuth1 CLI tests on macOS'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Run BRU format CLI tests
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
BRU_CLI="../../../../../../packages/bruno-cli/bin/bru.js"
|
||||
|
||||
# navigate to BRU test collection directory
|
||||
cd tests/auth/oauth1/fixtures/collections/bru
|
||||
|
||||
echo "=== BRU Format Collection Run ==="
|
||||
node $BRU_CLI run --env Local --output junit-bru.xml --format junit
|
||||
|
||||
- name: Run YML format CLI tests
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
BRU_CLI="../../../../../../packages/bruno-cli/bin/bru.js"
|
||||
|
||||
# navigate to YML test collection directory
|
||||
cd tests/auth/oauth1/fixtures/collections/yml
|
||||
|
||||
echo "=== YML Format Collection Run ==="
|
||||
node $BRU_CLI run --env Local --output junit-yml.xml --format junit
|
||||
16
.github/actions/auth/oauth1/macos/start-test-server/action.yml
vendored
Normal file
16
.github/actions/auth/oauth1/macos/start-test-server/action.yml
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
name: 'Start Test Server - macOS'
|
||||
description: 'Start the bruno-tests mock server for OAuth1 CLI tests on macOS'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Start test server
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
cd packages/bruno-tests
|
||||
|
||||
echo "starting test server in background"
|
||||
node src/index.js &
|
||||
|
||||
echo "server started with PID: $!"
|
||||
17
.github/actions/auth/oauth1/windows/run-auth-e2e-tests/action.yml
vendored
Normal file
17
.github/actions/auth/oauth1/windows/run-auth-e2e-tests/action.yml
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
name: 'Run Auth E2E Tests - Windows'
|
||||
description: 'Run Auth E2E tests on Windows'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Run Auth E2E tests
|
||||
shell: pwsh
|
||||
run: |
|
||||
npm run test:e2e:auth
|
||||
|
||||
- name: Upload Playwright Report
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report-auth-windows
|
||||
path: playwright-report/
|
||||
retention-days: 30
|
||||
34
.github/actions/auth/oauth1/windows/run-oauth1-cli-tests/action.yml
vendored
Normal file
34
.github/actions/auth/oauth1/windows/run-oauth1-cli-tests/action.yml
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
name: 'Run OAuth1 CLI Tests - Windows'
|
||||
description: 'Run OAuth1 CLI tests on Windows'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Run BRU format CLI tests
|
||||
shell: pwsh
|
||||
run: |
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$BRU_CLI = "..\..\..\..\..\..\packages\bruno-cli\bin\bru.js"
|
||||
|
||||
# navigate to BRU test collection directory
|
||||
Set-Location tests\auth\oauth1\fixtures\collections\bru
|
||||
|
||||
Write-Host "=== BRU Format Collection Run ==="
|
||||
$process = Start-Process -FilePath "node" -ArgumentList "$BRU_CLI run --env Local --output junit-bru.xml --format junit" -NoNewWindow -Wait -PassThru -RedirectStandardError "nul"
|
||||
if ($process.ExitCode -ne 0) { exit 1 }
|
||||
|
||||
- name: Run YML format CLI tests
|
||||
shell: pwsh
|
||||
run: |
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$BRU_CLI = "..\..\..\..\..\..\packages\bruno-cli\bin\bru.js"
|
||||
|
||||
# navigate to YML test collection directory
|
||||
Set-Location tests\auth\oauth1\fixtures\collections\yml
|
||||
|
||||
Write-Host "=== YML Format Collection Run ==="
|
||||
$process = Start-Process -FilePath "node" -ArgumentList "$BRU_CLI run --env Local --output junit-yml.xml --format junit" -NoNewWindow -Wait -PassThru -RedirectStandardError "nul"
|
||||
if ($process.ExitCode -ne 0) { exit 1 }
|
||||
14
.github/actions/auth/oauth1/windows/start-test-server/action.yml
vendored
Normal file
14
.github/actions/auth/oauth1/windows/start-test-server/action.yml
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
name: 'Start Test Server - Windows'
|
||||
description: 'Start the bruno-tests mock server for OAuth1 CLI tests on Windows'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Start test server
|
||||
shell: pwsh
|
||||
run: |
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
Set-Location packages\bruno-tests
|
||||
|
||||
Write-Host "starting test server in background"
|
||||
Start-Process -FilePath "node" -ArgumentList "src\index.js" -PassThru -WindowStyle Hidden
|
||||
79
.github/workflows/auth-tests.yml
vendored
Normal file
79
.github/workflows/auth-tests.yml
vendored
Normal file
@@ -0,0 +1,79 @@
|
||||
name: Auth Tests
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
oauth1-tests-for-linux:
|
||||
name: OAuth 1.0 Auth Tests - Linux
|
||||
timeout-minutes: 60
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
checks: write
|
||||
pull-requests: write
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node Dependencies
|
||||
uses: ./.github/actions/common/setup-node-deps
|
||||
|
||||
- name: Setup Feature Dependencies
|
||||
uses: ./.github/actions/auth/oauth1/linux/setup-feature-specific-deps
|
||||
|
||||
- name: Run Auth E2E Tests
|
||||
uses: ./.github/actions/auth/oauth1/linux/run-auth-e2e-tests
|
||||
|
||||
- name: Start Test Server
|
||||
uses: ./.github/actions/auth/oauth1/linux/start-test-server
|
||||
|
||||
- name: Run OAuth1 CLI Tests
|
||||
uses: ./.github/actions/auth/oauth1/linux/run-oauth1-cli-tests
|
||||
|
||||
oauth1-tests-for-macos:
|
||||
name: OAuth 1.0 Auth Tests - macOS
|
||||
timeout-minutes: 60
|
||||
runs-on: macos-latest
|
||||
permissions:
|
||||
checks: write
|
||||
pull-requests: write
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node Dependencies
|
||||
uses: ./.github/actions/common/setup-node-deps
|
||||
|
||||
- name: Run Auth E2E Tests
|
||||
uses: ./.github/actions/auth/oauth1/macos/run-auth-e2e-tests
|
||||
|
||||
- name: Start Test Server
|
||||
uses: ./.github/actions/auth/oauth1/macos/start-test-server
|
||||
|
||||
- name: Run OAuth1 CLI Tests
|
||||
uses: ./.github/actions/auth/oauth1/macos/run-oauth1-cli-tests
|
||||
|
||||
oauth1-tests-for-windows:
|
||||
name: OAuth 1.0 Auth Tests - Windows
|
||||
timeout-minutes: 60
|
||||
runs-on: windows-latest
|
||||
permissions:
|
||||
checks: write
|
||||
pull-requests: write
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node Dependencies
|
||||
uses: ./.github/actions/common/setup-node-deps
|
||||
|
||||
- name: Run Auth E2E Tests
|
||||
uses: ./.github/actions/auth/oauth1/windows/run-auth-e2e-tests
|
||||
|
||||
- name: Start Test Server
|
||||
uses: ./.github/actions/auth/oauth1/windows/start-test-server
|
||||
|
||||
- name: Run OAuth1 CLI Tests
|
||||
uses: ./.github/actions/auth/oauth1/windows/run-oauth1-cli-tests
|
||||
2
.github/workflows/npm-bru-cli.yml
vendored
2
.github/workflows/npm-bru-cli.yml
vendored
@@ -43,7 +43,7 @@ jobs:
|
||||
bru run --env Prod --output junit.xml --format junit --sandbox developer
|
||||
|
||||
- name: Publish Test Report
|
||||
uses: dorny/test-reporter@v2
|
||||
uses: dorny/test-reporter@v3
|
||||
if: success() || failure()
|
||||
with:
|
||||
name: Test Report
|
||||
|
||||
@@ -75,6 +75,8 @@ Remember, these rules are here to make our codebase harmonious. If something doe
|
||||
- Avoid: `import * as React from "react";` then `React.useCallback(...)`
|
||||
- Add `data-testid` to testable elements for Playwright
|
||||
- Co-locate utilities that are truly component-specific next to the component, otherwise place shared items under a common folder
|
||||
- Avoid mixed controlled and uncontrolled state in React components. A component is either controlled or uncontrolled. State needs a single source of truth instead of being computed by props and then recomputed internally.
|
||||
- SHOULD: Use derived state variables instead of adding unneeded `React.useState` / `useState` hooks.
|
||||
|
||||
|
||||
## Readability and Abstractions
|
||||
|
||||
@@ -324,7 +324,7 @@ test('should create and execute HTTP request', async ({ page, createTmpDir }) =>
|
||||
await page.getByRole('button', { name: 'Create' }).click();
|
||||
|
||||
// Execute request
|
||||
await page.locator('#send-request').getByRole('img').nth(2).click();
|
||||
await page.getByTestId('send-arrow-icon').click();
|
||||
|
||||
// Verify response
|
||||
await expect(page.getByRole('main')).toContainText('200 OK');
|
||||
|
||||
1384
package-lock.json
generated
1384
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
11
package.json
11
package.json
@@ -23,7 +23,7 @@
|
||||
"@eslint/compat": "^1.3.2",
|
||||
"@faker-js/faker": "^7.6.0",
|
||||
"@jest/globals": "^29.2.0",
|
||||
"@opencollection/types": "~0.8.0",
|
||||
"@opencollection/types": "0.9.1",
|
||||
"@playwright/test": "^1.51.1",
|
||||
"@rollup/plugin-json": "^6.1.0",
|
||||
"@storybook/addon-webpack5-compiler-babel": "^4.0.0",
|
||||
@@ -37,7 +37,7 @@
|
||||
"@typescript-eslint/parser": "^8.39.0",
|
||||
"concurrently": "^8.2.2",
|
||||
"cross-env": "10.1.0",
|
||||
"eslint": "^9.26.0",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-diff": "^2.0.3",
|
||||
"fs-extra": "^11.1.1",
|
||||
"globals": "^16.1.0",
|
||||
@@ -82,6 +82,7 @@
|
||||
"test:codegen": "node playwright/codegen.ts",
|
||||
"test:e2e": "playwright test --project=default",
|
||||
"test:e2e:ssl": "playwright test --project=ssl",
|
||||
"test:e2e:auth": "playwright test --project=auth",
|
||||
"lint": "cross-env NODE_OPTIONS=\"--max_old_space_size=4096\" npx eslint",
|
||||
"lint:fix": "cross-env NODE_OPTIONS=\"--max_old_space_size=4096\" npx eslint --fix",
|
||||
"prepare": "husky"
|
||||
@@ -92,7 +93,9 @@
|
||||
]
|
||||
},
|
||||
"overrides": {
|
||||
"rollup": "3.29.5",
|
||||
"axios":"1.13.6",
|
||||
"rollup": "3.30.0",
|
||||
"pbkdf2":"3.1.5",
|
||||
"electron-store": {
|
||||
"conf": {
|
||||
"json-schema-typed": "8.0.1"
|
||||
@@ -103,4 +106,4 @@
|
||||
"ajv": "^8.17.1",
|
||||
"git-url-parse": "^14.1.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"presets": ["@babel/preset-env", "@babel/preset-react"],
|
||||
"presets": ["@babel/preset-env", "@babel/preset-react", "@babel/preset-typescript"],
|
||||
"plugins": [["styled-components", { "ssr": true }]]
|
||||
}
|
||||
@@ -100,6 +100,7 @@
|
||||
"@babel/core": "^7.27.1",
|
||||
"@babel/preset-env": "^7.27.2",
|
||||
"@babel/preset-react": "^7.27.1",
|
||||
"@babel/preset-typescript": "^7.22.0",
|
||||
"@rsbuild/core": "^1.1.2",
|
||||
"@rsbuild/plugin-babel": "^1.0.3",
|
||||
"@rsbuild/plugin-node-polyfill": "1.2.0",
|
||||
|
||||
@@ -61,6 +61,17 @@ const StyledWrapper = styled.div`
|
||||
.cm-variable-invalid {
|
||||
color: ${(props) => props.theme.codemirror.variable.invalid};
|
||||
}
|
||||
|
||||
.CodeMirror-matchingbracket {
|
||||
background: ${(props) => props.theme.status.success.background} !important;
|
||||
text-decoration: unset;
|
||||
}
|
||||
|
||||
.CodeMirror-nonmatchingbracket {
|
||||
color: ${(props) => props.theme.colors.text.danger} !important;
|
||||
background: ${(props) => props.theme.status.danger.background} !important;
|
||||
text-decoration: unset;
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
|
||||
@@ -57,16 +57,6 @@ export default class CodeEditor extends React.Component {
|
||||
scrollbarStyle: 'overlay',
|
||||
theme: this.props.theme === 'dark' ? 'monokai' : 'default',
|
||||
extraKeys: {
|
||||
'Cmd-S': () => {
|
||||
if (this.props.onSave) {
|
||||
this.props.onSave();
|
||||
}
|
||||
},
|
||||
'Ctrl-S': () => {
|
||||
if (this.props.onSave) {
|
||||
this.props.onSave();
|
||||
}
|
||||
},
|
||||
'Cmd-F': 'findPersistent',
|
||||
'Ctrl-F': 'findPersistent',
|
||||
'Cmd-H': 'replace',
|
||||
|
||||
@@ -151,8 +151,14 @@ const StyledWrapper = styled.div`
|
||||
|
||||
//matching bracket fix
|
||||
.CodeMirror-matchingbracket {
|
||||
background: #5cc0b48c !important;
|
||||
text-decoration:unset;
|
||||
background: ${(props) => props.theme.status.success.background} !important;
|
||||
text-decoration: unset;
|
||||
}
|
||||
|
||||
.CodeMirror-nonmatchingbracket {
|
||||
color: ${(props) => props.theme.colors.text.danger} !important;
|
||||
background: ${(props) => props.theme.status.danger.background} !important;
|
||||
text-decoration: unset;
|
||||
}
|
||||
|
||||
.cm-search-line-highlight {
|
||||
|
||||
@@ -74,26 +74,6 @@ export default class CodeEditor extends React.Component {
|
||||
scrollbarStyle: 'overlay',
|
||||
theme: this.props.theme === 'dark' ? 'monokai' : 'default',
|
||||
extraKeys: {
|
||||
'Cmd-Enter': () => {
|
||||
if (this.props.onRun) {
|
||||
this.props.onRun();
|
||||
}
|
||||
},
|
||||
'Ctrl-Enter': () => {
|
||||
if (this.props.onRun) {
|
||||
this.props.onRun();
|
||||
}
|
||||
},
|
||||
'Cmd-S': () => {
|
||||
if (this.props.onSave) {
|
||||
this.props.onSave();
|
||||
}
|
||||
},
|
||||
'Ctrl-S': () => {
|
||||
if (this.props.onSave) {
|
||||
this.props.onSave();
|
||||
}
|
||||
},
|
||||
'Cmd-F': (cm) => {
|
||||
this.setState({ searchBarVisible: true }, () => {
|
||||
this.searchBarRef.current?.focus();
|
||||
@@ -104,8 +84,8 @@ export default class CodeEditor extends React.Component {
|
||||
this.searchBarRef.current?.focus();
|
||||
});
|
||||
},
|
||||
'Cmd-H': 'replace',
|
||||
'Ctrl-H': 'replace',
|
||||
'Cmd-H': this.props.readOnly ? false : 'replace',
|
||||
'Ctrl-H': this.props.readOnly ? false : 'replace',
|
||||
'Tab': function (cm) {
|
||||
cm.getSelection().includes('\n') || editor.getLine(cm.getCursor().line) == cm.getSelection()
|
||||
? cm.execCommand('indentMore')
|
||||
@@ -217,6 +197,12 @@ export default class CodeEditor extends React.Component {
|
||||
|
||||
// Setup lint error tooltip on line number hover
|
||||
this.cleanupLintErrorTooltip = setupLintErrorTooltip(editor);
|
||||
|
||||
// Add mousetrap class so Mousetrap captures shortcuts even when CodeMirror is focused
|
||||
const cmInput = editor.getInputField();
|
||||
if (cmInput) {
|
||||
cmInput.classList.add('mousetrap');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,18 +219,10 @@ export default class CodeEditor extends React.Component {
|
||||
CodeMirror.signal(this.editor, 'change', this.editor);
|
||||
}
|
||||
if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue && this.editor) {
|
||||
// TODO: temporary fix for keeping cursor state when auto save and new line insertion collide PR#7098
|
||||
const nextValue = this.props.value ?? '';
|
||||
const currentValue = this.editor.getValue();
|
||||
// Skip updating only when focused and editable; read-only editors (e.g. response viewer) must always show new value
|
||||
if (this.editor.hasFocus?.() && currentValue !== nextValue && !this.props.readOnly) {
|
||||
this.cachedValue = currentValue;
|
||||
} else {
|
||||
const cursor = this.editor.getCursor();
|
||||
this.cachedValue = nextValue;
|
||||
this.editor.setValue(nextValue);
|
||||
this.editor.setCursor(cursor);
|
||||
}
|
||||
const cursor = this.editor.getCursor();
|
||||
this.cachedValue = String(this?.props?.value ?? '');
|
||||
this.editor.setValue(String(this.props.value) || '');
|
||||
this.editor.setCursor(cursor);
|
||||
}
|
||||
|
||||
if (this.editor) {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import styled from 'styled-components';
|
||||
import { rgba } from 'polished';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.code-snippet {
|
||||
font-family: monospace;
|
||||
font-size: ${(props) => props.theme.font.size.xs};
|
||||
line-height: 1.4;
|
||||
overflow-x: auto;
|
||||
border-radius: ${(props) => props.theme.border.radius.base};
|
||||
background-color: ${(props) => props.theme.background.elevated};
|
||||
border: 1px solid ${(props) => props.theme.border.border2};
|
||||
}
|
||||
|
||||
.code-line {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.code-line.highlighted-error {
|
||||
background-color: ${(props) => rgba(props.theme.colors.text.danger, 0.1)};
|
||||
border-left: 3px solid ${(props) => props.theme.colors.text.danger};
|
||||
}
|
||||
|
||||
.code-line.highlighted-warning {
|
||||
background-color: ${(props) => rgba(props.theme.colors.text.warning, 0.1)};
|
||||
border-left: 3px solid ${(props) => props.theme.colors.text.warning};
|
||||
}
|
||||
|
||||
.code-line:not(.highlighted-error):not(.highlighted-warning) {
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
|
||||
.code-line-number {
|
||||
min-width: 2.5rem;
|
||||
text-align: right;
|
||||
padding: 0 0.5rem;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.code-line-content {
|
||||
white-space: pre;
|
||||
padding: 0 0.5rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.code-line-separator {
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
|
||||
.separator-content {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
user-select: none;
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
55
packages/bruno-app/src/components/CodeSnippet/index.js
Normal file
55
packages/bruno-app/src/components/CodeSnippet/index.js
Normal file
@@ -0,0 +1,55 @@
|
||||
import React from 'react';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const renderLine = (line, highlightClass, hunkIdx) => {
|
||||
const isHighlighted = line.isHighlighted || line.isError;
|
||||
const key = hunkIdx != null ? `${hunkIdx}-${line.lineNumber}` : line.lineNumber;
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={`code-line ${isHighlighted ? highlightClass : ''}`}
|
||||
data-testid={isHighlighted ? 'code-line-error' : 'code-line'}
|
||||
>
|
||||
<span className="code-line-number">{line.lineNumber}</span>
|
||||
<span className="code-line-content">
|
||||
{isHighlighted ? '> ' : ' '}{line.content}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CodeSnippet = ({ lines, hunks, variant = 'error' }) => {
|
||||
const highlightClass = variant === 'warning' ? 'highlighted-warning' : 'highlighted-error';
|
||||
|
||||
if (hunks?.length) {
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="code-snippet" data-testid="code-snippet">
|
||||
{hunks.map((hunk, idx) => (
|
||||
<React.Fragment key={idx}>
|
||||
{hunk.hasSeparatorBefore && (
|
||||
<div className="code-line code-line-separator">
|
||||
<span className="code-line-number"></span>
|
||||
<span className="code-line-content separator-content">{'\u22EE'}</span>
|
||||
</div>
|
||||
)}
|
||||
{hunk.lines.map((line) => renderLine(line, highlightClass, idx))}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
if (!lines?.length) return null;
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="code-snippet" data-testid="code-snippet">
|
||||
{lines.map((line) => renderLine(line, highlightClass))}
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default CodeSnippet;
|
||||
140
packages/bruno-app/src/components/CodeSnippet/index.spec.js
Normal file
140
packages/bruno-app/src/components/CodeSnippet/index.spec.js
Normal file
@@ -0,0 +1,140 @@
|
||||
import '@testing-library/jest-dom';
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ThemeProvider } from 'styled-components';
|
||||
import CodeSnippet from './index';
|
||||
|
||||
const theme = {
|
||||
font: { size: { xs: '0.75rem' } },
|
||||
background: { elevated: '#f5f5f5' },
|
||||
border: { border2: '#e0e0e0', radius: { base: '4px' } },
|
||||
colors: { text: { danger: '#ef4444', warning: '#f59e0b', muted: '#999' } }
|
||||
};
|
||||
|
||||
const renderWithTheme = (component) => {
|
||||
return render(
|
||||
<ThemeProvider theme={theme}>
|
||||
{component}
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const sampleLines = [
|
||||
{ lineNumber: 3, content: 'const a = 1;', isHighlighted: false },
|
||||
{ lineNumber: 4, content: 'undefinedVar.foo();', isHighlighted: true },
|
||||
{ lineNumber: 5, content: 'const b = 2;', isHighlighted: false }
|
||||
];
|
||||
|
||||
describe('CodeSnippet', () => {
|
||||
it('should render nothing when lines is empty', () => {
|
||||
const { container } = renderWithTheme(<CodeSnippet lines={[]} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('should render nothing when lines is null', () => {
|
||||
const { container } = renderWithTheme(<CodeSnippet lines={null} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('should render all lines with line numbers', () => {
|
||||
renderWithTheme(<CodeSnippet lines={sampleLines} />);
|
||||
expect(screen.getByText('3')).toBeInTheDocument();
|
||||
expect(screen.getByText('4')).toBeInTheDocument();
|
||||
expect(screen.getByText('5')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply error highlight class by default', () => {
|
||||
const { container } = renderWithTheme(<CodeSnippet lines={sampleLines} variant="error" />);
|
||||
const highlightedLine = container.querySelector('.highlighted-error');
|
||||
expect(highlightedLine).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply warning highlight class when variant is warning', () => {
|
||||
const { container } = renderWithTheme(<CodeSnippet lines={sampleLines} variant="warning" />);
|
||||
const highlightedLine = container.querySelector('.highlighted-warning');
|
||||
expect(highlightedLine).toBeInTheDocument();
|
||||
expect(container.querySelector('.highlighted-error')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show > prefix on highlighted line for accessibility', () => {
|
||||
const { container } = renderWithTheme(<CodeSnippet lines={sampleLines} />);
|
||||
const codeLineContents = container.querySelectorAll('.code-line-content');
|
||||
// The highlighted line (index 1) should start with "> "
|
||||
expect(codeLineContents[1].textContent).toContain('> ');
|
||||
// Non-highlighted lines should not have ">"
|
||||
expect(codeLineContents[0].textContent).not.toContain('>');
|
||||
});
|
||||
|
||||
it('should also support isError property for backward compatibility', () => {
|
||||
const linesWithIsError = [
|
||||
{ lineNumber: 1, content: 'line 1', isError: false },
|
||||
{ lineNumber: 2, content: 'error line', isError: true },
|
||||
{ lineNumber: 3, content: 'line 3', isError: false }
|
||||
];
|
||||
const { container } = renderWithTheme(<CodeSnippet lines={linesWithIsError} />);
|
||||
expect(container.querySelector('.highlighted-error')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('hunks prop', () => {
|
||||
const sampleHunks = [
|
||||
{
|
||||
hasSeparatorBefore: false,
|
||||
lines: [
|
||||
{ lineNumber: 1, content: 'const a = true;', isHighlighted: false },
|
||||
{ lineNumber: 2, content: 'pm.vault.get();', isHighlighted: true },
|
||||
{ lineNumber: 3, content: 'const b = false;', isHighlighted: false }
|
||||
]
|
||||
},
|
||||
{
|
||||
hasSeparatorBefore: true,
|
||||
lines: [
|
||||
{ lineNumber: 10, content: 'const x = null;', isHighlighted: false },
|
||||
{ lineNumber: 11, content: 'pm.cookies.jar();', isHighlighted: true },
|
||||
{ lineNumber: 12, content: 'const y = undefined;', isHighlighted: false }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
it('should render all lines from all hunks', () => {
|
||||
renderWithTheme(<CodeSnippet hunks={sampleHunks} variant="warning" />);
|
||||
// line numbers
|
||||
expect(screen.getByText('1')).toBeInTheDocument();
|
||||
expect(screen.getByText('2')).toBeInTheDocument();
|
||||
expect(screen.getByText('10')).toBeInTheDocument();
|
||||
expect(screen.getByText('11')).toBeInTheDocument();
|
||||
// content
|
||||
expect(screen.getByText(/const a = true;/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/pm\.vault\.get\(\);/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/const x = null;/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/pm\.cookies\.jar\(\);/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render separator between hunks when hasSeparatorBefore is true', () => {
|
||||
const { container } = renderWithTheme(<CodeSnippet hunks={sampleHunks} variant="warning" />);
|
||||
const separators = container.querySelectorAll('.code-line-separator');
|
||||
expect(separators).toHaveLength(1);
|
||||
// separator should appear between the two hunks, not before the first
|
||||
const allRows = container.querySelectorAll('.code-line, .code-line-separator');
|
||||
const separatorIndex = Array.from(allRows).findIndex((el) => el.classList.contains('code-line-separator'));
|
||||
// first hunk has 3 lines (indices 0-2), separator should be at index 3
|
||||
expect(separatorIndex).toBe(3);
|
||||
});
|
||||
|
||||
it('should render the ellipsis character in separator', () => {
|
||||
const { container } = renderWithTheme(<CodeSnippet hunks={sampleHunks} variant="warning" />);
|
||||
const separator = container.querySelector('.separator-content');
|
||||
expect(separator.textContent).toBe('\u22EE');
|
||||
});
|
||||
|
||||
it('should apply warning highlights within hunks', () => {
|
||||
const { container } = renderWithTheme(<CodeSnippet hunks={sampleHunks} variant="warning" />);
|
||||
const highlighted = container.querySelectorAll('.highlighted-warning');
|
||||
expect(highlighted).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should render nothing when hunks is empty array', () => {
|
||||
const { container } = renderWithTheme(<CodeSnippet hunks={[]} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -51,6 +51,11 @@ const AuthMode = ({ collection }) => {
|
||||
label: 'NTLM Auth',
|
||||
onClick: () => onModeChange('ntlm')
|
||||
},
|
||||
{
|
||||
id: 'oauth1',
|
||||
label: 'OAuth 1.0',
|
||||
onClick: () => onModeChange('oauth1')
|
||||
},
|
||||
{
|
||||
id: 'oauth2',
|
||||
label: 'OAuth 2.0',
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import OAuth1 from 'components/RequestPane/Auth/OAuth1';
|
||||
import { updateCollectionAuth } from 'providers/ReduxStore/slices/collections';
|
||||
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
|
||||
|
||||
const CollectionOAuth1 = ({ collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const request = collection.draft?.root
|
||||
? get(collection, 'draft.root.request', {})
|
||||
: get(collection, 'root.request', {});
|
||||
|
||||
const save = () => dispatch(saveCollectionSettings(collection.uid));
|
||||
|
||||
return (
|
||||
<OAuth1
|
||||
collection={collection}
|
||||
request={request}
|
||||
save={save}
|
||||
updateAuth={updateCollectionAuth}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default CollectionOAuth1;
|
||||
@@ -12,6 +12,7 @@ import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import OAuth2 from './OAuth2';
|
||||
import NTLMAuth from './NTLMAuth';
|
||||
import OAuth1 from './Oauth1';
|
||||
import Button from 'ui/Button';
|
||||
|
||||
const Auth = ({ collection }) => {
|
||||
@@ -37,6 +38,9 @@ const Auth = ({ collection }) => {
|
||||
case 'ntlm': {
|
||||
return <NTLMAuth collection={collection} />;
|
||||
}
|
||||
case 'oauth1': {
|
||||
return <OAuth1 collection={collection} />;
|
||||
}
|
||||
case 'oauth2': {
|
||||
return <OAuth2 collection={collection} />;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,25 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
position: relative;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
|
||||
.editing-mode {
|
||||
cursor: pointer;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: ${(props) => props.theme.bg};
|
||||
padding: 6px 0;
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.markdown-body {
|
||||
height: auto !important;
|
||||
overflow-y: visible !important;
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { setCollectionHeaders } from 'providers/ReduxStore/slices/collections';
|
||||
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
|
||||
import SingleLineEditor from 'components/SingleLineEditor';
|
||||
import EditableTable from 'components/EditableTable';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
@@ -18,11 +19,21 @@ const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header);
|
||||
const Headers = ({ collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
|
||||
const headers = collection.draft?.root
|
||||
? get(collection, 'draft.root.request.headers', [])
|
||||
: get(collection, 'root.request.headers', []);
|
||||
const [isBulkEditMode, setIsBulkEditMode] = useState(false);
|
||||
|
||||
// Get column widths from Redux
|
||||
const focusedTab = tabs?.find((t) => t.uid === activeTabUid);
|
||||
const collectionHeadersWidths = focusedTab?.tableColumnWidths?.['collection-headers'] || {};
|
||||
|
||||
const handleColumnWidthsChange = (tableId, widths) => {
|
||||
dispatch(updateTableColumnWidths({ uid: activeTabUid, tableId, widths }));
|
||||
};
|
||||
|
||||
const toggleBulkEditMode = () => {
|
||||
setIsBulkEditMode(!isBulkEditMode);
|
||||
};
|
||||
@@ -114,11 +125,14 @@ const Headers = ({ collection }) => {
|
||||
Add request headers that will be sent with every request in this collection.
|
||||
</div>
|
||||
<EditableTable
|
||||
tableId="collection-headers"
|
||||
columns={columns}
|
||||
rows={headers}
|
||||
onChange={handleHeadersChange}
|
||||
defaultRow={defaultRow}
|
||||
getRowError={getRowError}
|
||||
columnWidths={collectionHeadersWidths}
|
||||
onColumnWidthsChange={(widths) => handleColumnWidthsChange('collection-headers', widths)}
|
||||
/>
|
||||
<div className="flex justify-end mt-2">
|
||||
<button className="text-link select-none" onClick={toggleBulkEditMode}>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import find from 'lodash/find';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import CodeEditor from 'components/CodeEditor';
|
||||
import { updateCollectionRequestScript, updateCollectionResponseScript } from 'providers/ReduxStore/slices/collections';
|
||||
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { updateScriptPaneTab } from 'providers/ReduxStore/slices/tabs';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from 'components/Tabs';
|
||||
import StatusDot from 'components/StatusDot';
|
||||
@@ -18,27 +20,24 @@ const Script = ({ collection }) => {
|
||||
const requestScript = collection.draft?.root ? get(collection, 'draft.root.request.script.req', '') : get(collection, 'root.request.script.req', '');
|
||||
const responseScript = collection.draft?.root ? get(collection, 'draft.root.request.script.res', '') : get(collection, 'root.request.script.res', '');
|
||||
|
||||
// Default to post-response if pre-request script is empty
|
||||
const getInitialTab = () => {
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
const focusedTab = find(tabs, (t) => t.uid === collection.uid);
|
||||
const scriptPaneTab = focusedTab?.scriptPaneTab;
|
||||
|
||||
const getDefaultTab = () => {
|
||||
const hasPreRequestScript = requestScript && requestScript.trim().length > 0;
|
||||
return hasPreRequestScript ? 'pre-request' : 'post-response';
|
||||
};
|
||||
|
||||
const [activeTab, setActiveTab] = useState(getInitialTab);
|
||||
const prevCollectionUidRef = useRef(collection.uid);
|
||||
const activeTab = scriptPaneTab || getDefaultTab();
|
||||
|
||||
const setActiveTab = (tab) => {
|
||||
dispatch(updateScriptPaneTab({ uid: collection.uid, scriptPaneTab: tab }));
|
||||
};
|
||||
|
||||
const { displayedTheme } = useTheme();
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
|
||||
// Update active tab only when switching to a different collection
|
||||
useEffect(() => {
|
||||
if (prevCollectionUidRef.current !== collection.uid) {
|
||||
prevCollectionUidRef.current = collection.uid;
|
||||
const hasPreRequestScript = requestScript && requestScript.trim().length > 0;
|
||||
setActiveTab(hasPreRequestScript ? 'pre-request' : 'post-response');
|
||||
}
|
||||
}, [collection.uid, requestScript]);
|
||||
|
||||
// Refresh CodeMirror when tab becomes visible
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.markdown-body {
|
||||
height: auto !important;
|
||||
overflow-y: visible !important;
|
||||
}
|
||||
div.tabs {
|
||||
div.tab {
|
||||
padding: 6px 0px;
|
||||
@@ -24,7 +28,8 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
|
||||
&.active {
|
||||
font-weight: ${(props) => props.theme.tabs.active.fontWeight} !important;
|
||||
font-weight: ${(props) =>
|
||||
props.theme.tabs.active.fontWeight} !important;
|
||||
color: ${(props) => props.theme.tabs.active.color} !important;
|
||||
border-bottom: solid 2px ${(props) => props.theme.tabs.active.border} !important;
|
||||
}
|
||||
@@ -45,7 +50,7 @@ const StyledWrapper = styled.div`
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
input[type='radio'] {
|
||||
input[type="radio"] {
|
||||
cursor: pointer;
|
||||
accent-color: ${(props) => props.theme.primary.solid};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
|
||||
import MultiLineEditor from 'components/MultiLineEditor';
|
||||
import InfoTip from 'components/InfoTip';
|
||||
import EditableTable from 'components/EditableTable';
|
||||
@@ -13,6 +14,16 @@ import { setCollectionVars } from 'providers/ReduxStore/slices/collections/index
|
||||
const VarsTable = ({ collection, vars, varType }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
|
||||
|
||||
// Get column widths from Redux
|
||||
const focusedTab = tabs?.find((t) => t.uid === activeTabUid);
|
||||
const collectionVarsWidths = focusedTab?.tableColumnWidths?.['collection-vars'] || {};
|
||||
|
||||
const handleColumnWidthsChange = (tableId, widths) => {
|
||||
dispatch(updateTableColumnWidths({ uid: activeTabUid, tableId, widths }));
|
||||
};
|
||||
|
||||
const onSave = () => dispatch(saveCollectionSettings(collection.uid));
|
||||
|
||||
@@ -68,11 +79,14 @@ const VarsTable = ({ collection, vars, varType }) => {
|
||||
return (
|
||||
<StyledWrapper className="w-full">
|
||||
<EditableTable
|
||||
tableId="collection-vars"
|
||||
columns={columns}
|
||||
rows={vars}
|
||||
onChange={handleVarsChange}
|
||||
defaultRow={defaultRow}
|
||||
getRowError={getRowError}
|
||||
columnWidths={collectionVarsWidths}
|
||||
onColumnWidthsChange={(widths) => handleColumnWidthsChange('collection-vars', widths)}
|
||||
/>
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -106,42 +106,42 @@ const CollectionSettings = ({ collection }) => {
|
||||
return (
|
||||
<StyledWrapper className="flex flex-col h-full relative px-4 py-4 overflow-hidden">
|
||||
<div className="flex flex-wrap items-center tabs" role="tablist">
|
||||
<div className={getTabClassname('overview')} role="tab" onClick={() => setTab('overview')}>
|
||||
<div className={getTabClassname('overview')} role="tab" data-testid="collection-settings-tab-overview" onClick={() => setTab('overview')}>
|
||||
Overview
|
||||
</div>
|
||||
<div className={getTabClassname('headers')} role="tab" onClick={() => setTab('headers')}>
|
||||
<div className={getTabClassname('headers')} role="tab" data-testid="collection-settings-tab-headers" onClick={() => setTab('headers')}>
|
||||
Headers
|
||||
{activeHeadersCount > 0 && <sup className="ml-1 font-medium">{activeHeadersCount}</sup>}
|
||||
</div>
|
||||
<div className={getTabClassname('vars')} role="tab" onClick={() => setTab('vars')}>
|
||||
<div className={getTabClassname('vars')} role="tab" data-testid="collection-settings-tab-vars" onClick={() => setTab('vars')}>
|
||||
Vars
|
||||
{activeVarsCount > 0 && <sup className="ml-1 font-medium">{activeVarsCount}</sup>}
|
||||
</div>
|
||||
<div className={getTabClassname('auth')} role="tab" onClick={() => setTab('auth')}>
|
||||
<div className={getTabClassname('auth')} role="tab" data-testid="collection-settings-tab-auth" onClick={() => setTab('auth')}>
|
||||
Auth
|
||||
{authMode !== 'none' && <StatusDot />}
|
||||
</div>
|
||||
<div className={getTabClassname('script')} role="tab" onClick={() => setTab('script')}>
|
||||
<div className={getTabClassname('script')} role="tab" data-testid="collection-settings-tab-script" onClick={() => setTab('script')}>
|
||||
Script
|
||||
{hasScripts && <StatusDot />}
|
||||
</div>
|
||||
<div className={getTabClassname('tests')} role="tab" onClick={() => setTab('tests')}>
|
||||
<div className={getTabClassname('tests')} role="tab" data-testid="collection-settings-tab-tests" onClick={() => setTab('tests')}>
|
||||
Tests
|
||||
{hasTests && <StatusDot />}
|
||||
</div>
|
||||
<div className={getTabClassname('presets')} role="tab" onClick={() => setTab('presets')}>
|
||||
<div className={getTabClassname('presets')} role="tab" data-testid="collection-settings-tab-presets" onClick={() => setTab('presets')}>
|
||||
Presets
|
||||
{hasPresets && <StatusDot />}
|
||||
</div>
|
||||
<div className={getTabClassname('proxy')} role="tab" onClick={() => setTab('proxy')}>
|
||||
<div className={getTabClassname('proxy')} role="tab" data-testid="collection-settings-tab-proxy" onClick={() => setTab('proxy')}>
|
||||
Proxy
|
||||
{Object.keys(proxyConfig).length > 0 && proxyEnabled && <StatusDot />}
|
||||
</div>
|
||||
<div className={getTabClassname('clientCert')} role="tab" onClick={() => setTab('clientCert')}>
|
||||
<div className={getTabClassname('clientCert')} role="tab" data-testid="collection-settings-tab-clientCert" onClick={() => setTab('clientCert')}>
|
||||
Client Certificates
|
||||
{clientCertConfig.length > 0 && <StatusDot />}
|
||||
</div>
|
||||
<div className={getTabClassname('protobuf')} role="tab" onClick={() => setTab('protobuf')}>
|
||||
<div className={getTabClassname('protobuf')} role="tab" data-testid="collection-settings-tab-protobuf" onClick={() => setTab('protobuf')}>
|
||||
Protobuf
|
||||
{protobufConfig.protoFiles && protobufConfig.protoFiles.length > 0 && <StatusDot />}
|
||||
</div>
|
||||
|
||||
@@ -113,7 +113,7 @@ const SessionList = ({ sessions, activeSessionId, onSelectSession, onCloseSessio
|
||||
|
||||
return (
|
||||
<StyledSessionList>
|
||||
{sessions.map((session) => {
|
||||
{sessions.map((session, idx) => {
|
||||
const { name } = getSessionDisplayInfo(session);
|
||||
return (
|
||||
<ToolHint
|
||||
@@ -125,6 +125,7 @@ const SessionList = ({ sessions, activeSessionId, onSelectSession, onCloseSessio
|
||||
>
|
||||
<div
|
||||
className={`session-list-item ${activeSessionId === session.sessionId ? 'active' : ''}`}
|
||||
data-testid={`session-list-${idx}`}
|
||||
onClick={() => onSelectSession(session.sessionId)}
|
||||
>
|
||||
<div className="session-name">
|
||||
@@ -133,6 +134,7 @@ const SessionList = ({ sessions, activeSessionId, onSelectSession, onCloseSessio
|
||||
</div>
|
||||
<div
|
||||
className="session-close-btn"
|
||||
data-testid={`session-close-${idx}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCloseSession(session.sessionId);
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
.editing-mode {
|
||||
cursor: pointer;
|
||||
position: sticky;
|
||||
z-index: 10;
|
||||
top: 0;
|
||||
background: ${(props) => props.theme.bg};
|
||||
padding-bottom: 0.5em;
|
||||
}
|
||||
.markdown-body {
|
||||
height: auto !important;
|
||||
overflow-y: visible !important;
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'github-markdown-css/github-markdown.css';
|
||||
import get from 'lodash/get';
|
||||
import find from 'lodash/find';
|
||||
import { updateRequestDocs } from 'providers/ReduxStore/slices/collections';
|
||||
import { updateDocsEditing } from 'providers/ReduxStore/slices/tabs';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
@@ -12,12 +14,15 @@ import StyledWrapper from './StyledWrapper';
|
||||
const Documentation = ({ item, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { displayedTheme } = useTheme();
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
|
||||
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
|
||||
const isEditing = focusedTab?.docsEditing || false;
|
||||
const docs = item.draft ? get(item, 'draft.request.docs') : get(item, 'request.docs');
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
|
||||
const toggleViewMode = () => {
|
||||
setIsEditing((prev) => !prev);
|
||||
dispatch(updateDocsEditing({ uid: activeTabUid, docsEditing: !isEditing }));
|
||||
};
|
||||
|
||||
const onEdit = (value) => {
|
||||
|
||||
@@ -63,7 +63,7 @@ const StyledWrapper = styled.div`
|
||||
height: 100%;
|
||||
cursor: col-resize;
|
||||
background: transparent;
|
||||
z-index: 100;
|
||||
z-index: 10;
|
||||
|
||||
&:hover,
|
||||
&.resizing {
|
||||
|
||||
@@ -7,6 +7,7 @@ import StyledWrapper from './StyledWrapper';
|
||||
const MIN_COLUMN_WIDTH = 80;
|
||||
|
||||
const EditableTable = ({
|
||||
tableId, // Not being used kept to maintain uniqueness & pass similar in onColumnWidthsChange
|
||||
columns,
|
||||
rows,
|
||||
onChange,
|
||||
@@ -20,20 +21,20 @@ const EditableTable = ({
|
||||
reorderable = false,
|
||||
onReorder,
|
||||
showAddRow = true,
|
||||
testId = 'editable-table'
|
||||
testId = 'editable-table',
|
||||
columnWidths,
|
||||
onColumnWidthsChange
|
||||
}) => {
|
||||
const tableRef = useRef(null);
|
||||
const emptyRowUidRef = useRef(null);
|
||||
const [hoveredRow, setHoveredRow] = useState(null);
|
||||
const [resizing, setResizing] = useState(null);
|
||||
const [tableHeight, setTableHeight] = useState(0);
|
||||
const [columnWidths, setColumnWidths] = useState(() => {
|
||||
const initialWidths = {};
|
||||
columns.forEach((col) => {
|
||||
initialWidths[col.key] = col.width || 'auto';
|
||||
});
|
||||
return initialWidths;
|
||||
});
|
||||
const widths = columnWidths || {};
|
||||
|
||||
const handleColumnWidthsChange = useCallback((newWidths) => {
|
||||
onColumnWidthsChange?.(newWidths);
|
||||
}, [onColumnWidthsChange]);
|
||||
|
||||
const handleResizeStart = useCallback((e, columnKey) => {
|
||||
e.preventDefault();
|
||||
@@ -59,11 +60,13 @@ const EditableTable = ({
|
||||
const maxShrink = startWidth - MIN_COLUMN_WIDTH;
|
||||
const clampedDiff = Math.max(-maxShrink, Math.min(maxGrow, diff));
|
||||
|
||||
setColumnWidths((prev) => ({
|
||||
...prev,
|
||||
const newWidths = {
|
||||
...widths,
|
||||
[columnKey]: `${startWidth + clampedDiff}px`,
|
||||
[nextColumnKey]: `${nextColumnStartWidth - clampedDiff}px`
|
||||
}));
|
||||
};
|
||||
|
||||
handleColumnWidthsChange(newWidths);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
@@ -88,7 +91,7 @@ const EditableTable = ({
|
||||
});
|
||||
|
||||
if (Object.keys(newWidths).length > 0) {
|
||||
setColumnWidths((prev) => ({ ...prev, ...newWidths }));
|
||||
handleColumnWidthsChange({ ...widths, ...newWidths });
|
||||
}
|
||||
}
|
||||
setResizing(null);
|
||||
@@ -98,7 +101,7 @@ const EditableTable = ({
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
}, [columns, showCheckbox]);
|
||||
}, [columns, showCheckbox, widths, handleColumnWidthsChange]);
|
||||
|
||||
// Track table height for resize handles
|
||||
useEffect(() => {
|
||||
@@ -118,8 +121,8 @@ const EditableTable = ({
|
||||
}, [rows.length]);
|
||||
|
||||
const getColumnWidth = useCallback((column) => {
|
||||
return columnWidths[column.key] || column.width || 'auto';
|
||||
}, [columnWidths]);
|
||||
return widths[column.key] || column.width || 'auto';
|
||||
}, [widths]);
|
||||
|
||||
const createEmptyRow = useCallback(() => {
|
||||
const newUid = uuid();
|
||||
|
||||
@@ -3,7 +3,8 @@ import { TableVirtuoso } from 'react-virtuoso';
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import { IconTrash, IconAlertCircle, IconInfoCircle } from '@tabler/icons';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
|
||||
import MultiLineEditor from 'components/MultiLineEditor/index';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { uuid } from 'utils/common';
|
||||
@@ -44,14 +45,42 @@ const EnvironmentVariablesTable = ({
|
||||
}) => {
|
||||
const { storedTheme } = useTheme();
|
||||
const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments);
|
||||
const activeWorkspace = useSelector((state) => {
|
||||
const uid = state.workspaces?.activeWorkspaceUid;
|
||||
return state.workspaces?.workspaces?.find((w) => w.uid === uid);
|
||||
});
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
|
||||
|
||||
const hasDraftForThisEnv = draft?.environmentUid === environment.uid;
|
||||
|
||||
const [tableHeight, setTableHeight] = useState(MIN_H);
|
||||
const [columnWidths, setColumnWidths] = useState({ name: '30%', value: 'auto' });
|
||||
|
||||
// Use environment UID as part of tableId so each environment has its own column widths
|
||||
const tableId = `env-vars-table-${environment.uid}`;
|
||||
|
||||
// Get column widths from Redux - derived value (not state)
|
||||
const focusedTab = tabs?.find((t) => t.uid === activeTabUid);
|
||||
const storedColumnWidths = focusedTab?.tableColumnWidths?.[tableId];
|
||||
|
||||
// Local state initialized from Redux (computed once on mount/environment change via key)
|
||||
const [columnWidths, setColumnWidths] = useState(() => {
|
||||
return storedColumnWidths || { name: '30%', value: 'auto' };
|
||||
});
|
||||
|
||||
const [resizing, setResizing] = useState(null);
|
||||
const [pinnedData, setPinnedData] = useState({ query: '', uids: new Set() });
|
||||
|
||||
const handleColumnWidthsChange = (id, widths) => {
|
||||
dispatch(updateTableColumnWidths({ uid: activeTabUid, tableId: id, widths }));
|
||||
};
|
||||
|
||||
// Store column widths in ref for access in event handlers
|
||||
const columnWidthsRef = useRef(columnWidths);
|
||||
columnWidthsRef.current = columnWidths;
|
||||
|
||||
const handleResizeStart = useCallback((e, columnKey) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -73,21 +102,24 @@ const EnvironmentVariablesTable = ({
|
||||
const maxShrink = startWidth - MIN_COLUMN_WIDTH;
|
||||
const clampedDiff = Math.max(-maxShrink, Math.min(maxGrow, diff));
|
||||
|
||||
setColumnWidths({
|
||||
const newWidths = {
|
||||
[columnKey]: `${startWidth + clampedDiff}px`,
|
||||
[nextColumnKey]: `${nextColumnStartWidth - clampedDiff}px`
|
||||
});
|
||||
};
|
||||
setColumnWidths(newWidths);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setResizing(null);
|
||||
// Save to Redux after resize ends using ref for latest values
|
||||
handleColumnWidthsChange(tableId, columnWidthsRef.current);
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
}, []);
|
||||
}, [handleColumnWidthsChange]);
|
||||
|
||||
const handleTotalHeightChanged = useCallback((h) => {
|
||||
setTableHeight(h);
|
||||
@@ -110,6 +142,12 @@ const EnvironmentVariablesTable = ({
|
||||
_collection.globalEnvironmentVariables = globalEnvironmentVariables;
|
||||
}
|
||||
|
||||
// When collection is null (global/workspace environments), populate process env
|
||||
// variables from the active workspace so that {{process.env.X}} can resolve
|
||||
if (!collection && activeWorkspace?.processEnvVariables) {
|
||||
_collection.workspaceProcessEnvVariables = activeWorkspace.processEnvVariables;
|
||||
}
|
||||
|
||||
const initialValues = useMemo(() => {
|
||||
const vars = environment.variables || [];
|
||||
return [
|
||||
|
||||
@@ -8,11 +8,12 @@ const CollapsibleSection = ({
|
||||
onToggle,
|
||||
badge,
|
||||
actions,
|
||||
children
|
||||
children,
|
||||
testId
|
||||
}) => {
|
||||
return (
|
||||
<StyledWrapper className={expanded ? 'expanded' : 'collapsed'}>
|
||||
<div className="section-header" onClick={onToggle}>
|
||||
<div className="section-header" onClick={onToggle} data-testid={testId}>
|
||||
<div className="section-title-wrapper">
|
||||
<IconChevronRight
|
||||
size={14}
|
||||
|
||||
@@ -44,6 +44,7 @@ const DotEnvFileDetails = ({
|
||||
className={`toggle-btn ${viewMode === 'raw' ? 'active' : ''}`}
|
||||
onClick={() => onViewModeChange?.('raw')}
|
||||
aria-pressed={viewMode === 'raw'}
|
||||
data-testid="dotenv-view-raw"
|
||||
>
|
||||
Raw
|
||||
</button>
|
||||
|
||||
@@ -13,7 +13,7 @@ const DotEnvRawView = ({
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<div className="raw-editor-container">
|
||||
<div className="raw-editor-container" data-testid="dotenv-raw-editor">
|
||||
<CodeEditor
|
||||
collection={collection}
|
||||
item={item}
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
import { uuid } from 'utils/common';
|
||||
import { utils } from '@usebruno/common';
|
||||
|
||||
export const variablesToRaw = (variables) => {
|
||||
return variables
|
||||
.filter((v) => v.name && v.name.trim() !== '')
|
||||
.map((v) => {
|
||||
const value = v.value || '';
|
||||
if (value.includes('\n') || value.includes('"') || value.includes('\'')) {
|
||||
const escapedValue = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
|
||||
return `${v.name}="${escapedValue}"`;
|
||||
}
|
||||
return `${v.name}=${value}`;
|
||||
})
|
||||
.join('\n');
|
||||
return utils.jsonToDotenv(variables);
|
||||
};
|
||||
|
||||
export const rawToVariables = (rawContent) => {
|
||||
@@ -37,9 +28,16 @@ export const rawToVariables = (rawContent) => {
|
||||
const name = trimmedLine.substring(0, equalIndex).trim();
|
||||
let value = trimmedLine.substring(equalIndex + 1);
|
||||
|
||||
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith('\'') && value.endsWith('\''))) {
|
||||
if (value.startsWith('\'') && value.endsWith('\'')) {
|
||||
// Single-quoted values are fully literal in dotenv — no unescaping
|
||||
value = value.slice(1, -1);
|
||||
value = value.replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\');
|
||||
} else if (value.startsWith('`') && value.endsWith('`')) {
|
||||
// Backtick-quoted values are fully literal in dotenv — no unescaping
|
||||
value = value.slice(1, -1);
|
||||
} else if (value.startsWith('"') && value.endsWith('"')) {
|
||||
// Double-quoted values: unescape \", \n, and \r (the escapes we produce)
|
||||
value = value.slice(1, -1);
|
||||
value = value.replace(/\\"/g, '"').replace(/\\n/g, '\n').replace(/\\r/g, '\r');
|
||||
}
|
||||
|
||||
if (name) {
|
||||
|
||||
@@ -46,7 +46,7 @@ const EnvironmentListContent = ({
|
||||
</div>
|
||||
</ToolHint>
|
||||
<div className="dropdown-item configure-button">
|
||||
<button onClick={onSettingsClick} id="configure-env">
|
||||
<button onClick={onSettingsClick} id="configure-env" data-testid="configure-env">
|
||||
<IconSettings size={16} strokeWidth={1.5} />
|
||||
<span>Configure</span>
|
||||
</button>
|
||||
|
||||
@@ -103,6 +103,7 @@ const EnvironmentVariables = ({ environment, setIsModified, collection, searchQu
|
||||
|
||||
return (
|
||||
<EnvironmentVariablesTable
|
||||
key={environment?.uid}
|
||||
environment={environment}
|
||||
collection={collection}
|
||||
onSave={handleSave}
|
||||
|
||||
@@ -736,6 +736,7 @@ const EnvironmentList = ({
|
||||
|
||||
<CollapsibleSection
|
||||
title=".env Files"
|
||||
testId="dotenv-files-section"
|
||||
expanded={dotEnvExpanded}
|
||||
onToggle={() => setDotEnvExpanded(!dotEnvExpanded)}
|
||||
badge={dotEnvFiles.length}
|
||||
@@ -744,6 +745,7 @@ const EnvironmentList = ({
|
||||
className="btn-action"
|
||||
onClick={handleCreateDotEnvInlineClick}
|
||||
title="Create .env file"
|
||||
data-testid="create-dotenv-file"
|
||||
>
|
||||
<IconPlus size={14} strokeWidth={1.5} />
|
||||
</button>
|
||||
@@ -768,6 +770,7 @@ const EnvironmentList = ({
|
||||
ref={dotEnvInputRef}
|
||||
type="text"
|
||||
className="environment-name-input"
|
||||
data-testid="dotenv-name-input"
|
||||
value={newDotEnvName}
|
||||
onChange={handleDotEnvNameChange}
|
||||
onKeyDown={handleDotEnvNameKeyDown}
|
||||
|
||||
@@ -14,6 +14,7 @@ import BasicAuth from 'components/RequestPane/Auth/BasicAuth';
|
||||
import BearerAuth from 'components/RequestPane/Auth/BearerAuth';
|
||||
import DigestAuth from 'components/RequestPane/Auth/DigestAuth';
|
||||
import NTLMAuth from 'components/RequestPane/Auth/NTLMAuth';
|
||||
import OAuth1 from 'components/RequestPane/Auth/OAuth1';
|
||||
import WsseAuth from 'components/RequestPane/Auth/WsseAuth';
|
||||
import ApiKeyAuth from 'components/RequestPane/Auth/ApiKeyAuth';
|
||||
import AwsV4Auth from 'components/RequestPane/Auth/AwsV4Auth';
|
||||
@@ -143,6 +144,17 @@ const Auth = ({ collection, folder }) => {
|
||||
/>
|
||||
);
|
||||
}
|
||||
case 'oauth1': {
|
||||
return (
|
||||
<OAuth1
|
||||
collection={collection}
|
||||
item={folder}
|
||||
updateAuth={updateFolderAuth}
|
||||
request={request}
|
||||
save={() => handleSave()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case 'wsse': {
|
||||
return (
|
||||
<WsseAuth
|
||||
|
||||
@@ -47,6 +47,11 @@ const AuthMode = ({ collection, folder }) => {
|
||||
label: 'NTLM Auth',
|
||||
onClick: () => onModeChange('ntlm')
|
||||
},
|
||||
{
|
||||
id: 'oauth1',
|
||||
label: 'OAuth 1.0',
|
||||
onClick: () => onModeChange('oauth1')
|
||||
},
|
||||
{
|
||||
id: 'oauth2',
|
||||
label: 'OAuth 2.0',
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
|
||||
.editing-mode {
|
||||
cursor: pointer;
|
||||
color: ${(props) => props.theme.colors.text.yellow};
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: ${(props) => props.theme.bg};
|
||||
padding-bottom: 0.5em;
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { setFolderHeaders } from 'providers/ReduxStore/slices/collections';
|
||||
import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
|
||||
import SingleLineEditor from 'components/SingleLineEditor';
|
||||
import EditableTable from 'components/EditableTable';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
@@ -18,11 +19,21 @@ const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header);
|
||||
const Headers = ({ collection, folder }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
|
||||
const headers = folder.draft
|
||||
? get(folder, 'draft.request.headers', [])
|
||||
: get(folder, 'root.request.headers', []);
|
||||
const [isBulkEditMode, setIsBulkEditMode] = useState(false);
|
||||
|
||||
// Get column widths from Redux
|
||||
const focusedTab = tabs?.find((t) => t.uid === activeTabUid);
|
||||
const folderHeadersWidths = focusedTab?.tableColumnWidths?.['folder-headers'] || {};
|
||||
|
||||
const handleColumnWidthsChange = (tableId, widths) => {
|
||||
dispatch(updateTableColumnWidths({ uid: activeTabUid, tableId, widths }));
|
||||
};
|
||||
|
||||
const toggleBulkEditMode = () => {
|
||||
setIsBulkEditMode(!isBulkEditMode);
|
||||
};
|
||||
@@ -119,11 +130,14 @@ const Headers = ({ collection, folder }) => {
|
||||
Request headers that will be sent with every request inside this folder.
|
||||
</div>
|
||||
<EditableTable
|
||||
tableId="folder-headers"
|
||||
columns={columns}
|
||||
rows={headers}
|
||||
onChange={handleHeadersChange}
|
||||
defaultRow={defaultRow}
|
||||
getRowError={getRowError}
|
||||
columnWidths={folderHeadersWidths}
|
||||
onColumnWidthsChange={(widths) => handleColumnWidthsChange('folder-headers', widths)}
|
||||
/>
|
||||
<div className="flex justify-end mt-2">
|
||||
<button className="text-link select-none" onClick={toggleBulkEditMode}>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import find from 'lodash/find';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import CodeEditor from 'components/CodeEditor';
|
||||
import { updateFolderRequestScript, updateFolderResponseScript } from 'providers/ReduxStore/slices/collections';
|
||||
import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { updateScriptPaneTab } from 'providers/ReduxStore/slices/tabs';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from 'components/Tabs';
|
||||
import StatusDot from 'components/StatusDot';
|
||||
@@ -18,27 +20,25 @@ const Script = ({ collection, folder }) => {
|
||||
const requestScript = folder.draft ? get(folder, 'draft.request.script.req', '') : get(folder, 'root.request.script.req', '');
|
||||
const responseScript = folder.draft ? get(folder, 'draft.request.script.res', '') : get(folder, 'root.request.script.res', '');
|
||||
|
||||
// Default to post-response if pre-request script is empty
|
||||
const getInitialTab = () => {
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
const focusedTab = find(tabs, (t) => t.uid === folder.uid);
|
||||
const scriptPaneTab = focusedTab?.scriptPaneTab;
|
||||
|
||||
// Default to post-response if pre-request script is empty (only when scriptPaneTab is null/undefined)
|
||||
const getDefaultTab = () => {
|
||||
const hasPreRequestScript = requestScript && requestScript.trim().length > 0;
|
||||
return hasPreRequestScript ? 'pre-request' : 'post-response';
|
||||
};
|
||||
|
||||
const [activeTab, setActiveTab] = useState(getInitialTab);
|
||||
const prevFolderUidRef = useRef(folder.uid);
|
||||
const activeTab = scriptPaneTab || getDefaultTab();
|
||||
|
||||
const setActiveTab = (tab) => {
|
||||
dispatch(updateScriptPaneTab({ uid: folder.uid, scriptPaneTab: tab }));
|
||||
};
|
||||
|
||||
const { displayedTheme } = useTheme();
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
|
||||
// Update active tab only when switching to a different folder
|
||||
useEffect(() => {
|
||||
if (prevFolderUidRef.current !== folder.uid) {
|
||||
prevFolderUidRef.current = folder.uid;
|
||||
const hasPreRequestScript = requestScript && requestScript.trim().length > 0;
|
||||
setActiveTab(hasPreRequestScript ? 'pre-request' : 'post-response');
|
||||
}
|
||||
}, [folder.uid, requestScript]);
|
||||
|
||||
// Refresh CodeMirror when tab becomes visible
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
|
||||
@@ -2,6 +2,12 @@ import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
max-width: 800px;
|
||||
position: relative;
|
||||
|
||||
.markdown-body {
|
||||
height: auto !important;
|
||||
overflow-y: visible !important;
|
||||
}
|
||||
|
||||
div.tabs {
|
||||
div.tab {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
|
||||
import MultiLineEditor from 'components/MultiLineEditor';
|
||||
import InfoTip from 'components/InfoTip';
|
||||
import EditableTable from 'components/EditableTable';
|
||||
@@ -13,6 +14,16 @@ import { setFolderVars } from 'providers/ReduxStore/slices/collections/index';
|
||||
const VarsTable = ({ folder, collection, vars, varType }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
|
||||
|
||||
// Get column widths from Redux
|
||||
const focusedTab = tabs?.find((t) => t.uid === activeTabUid);
|
||||
const folderVarsWidths = focusedTab?.tableColumnWidths?.['folder-vars'] || {};
|
||||
|
||||
const handleColumnWidthsChange = (tableId, widths) => {
|
||||
dispatch(updateTableColumnWidths({ uid: activeTabUid, tableId, widths }));
|
||||
};
|
||||
|
||||
const onSave = () => dispatch(saveFolderRoot(collection.uid, folder.uid));
|
||||
|
||||
@@ -74,11 +85,14 @@ const VarsTable = ({ folder, collection, vars, varType }) => {
|
||||
return (
|
||||
<StyledWrapper className="w-full">
|
||||
<EditableTable
|
||||
tableId="folder-vars"
|
||||
columns={columns}
|
||||
rows={vars}
|
||||
onChange={handleVarsChange}
|
||||
defaultRow={defaultRow}
|
||||
getRowError={getRowError}
|
||||
columnWidths={folderVarsWidths}
|
||||
onColumnWidthsChange={(widths) => handleColumnWidthsChange('folder-vars', widths)}
|
||||
/>
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -77,27 +77,27 @@ const FolderSettings = ({ collection, folder }) => {
|
||||
<StyledWrapper className="flex flex-col h-full overflow-auto">
|
||||
<div className="flex flex-col h-full relative px-4 py-4">
|
||||
<div className="flex flex-wrap items-center tabs" role="tablist">
|
||||
<div className={getTabClassname('headers')} role="tab" onClick={() => setTab('headers')}>
|
||||
<div className={getTabClassname('headers')} role="tab" data-testid="folder-settings-tab-headers" onClick={() => setTab('headers')}>
|
||||
Headers
|
||||
{activeHeadersCount > 0 && <sup className="ml-1 font-medium">{activeHeadersCount}</sup>}
|
||||
</div>
|
||||
<div className={getTabClassname('script')} role="tab" onClick={() => setTab('script')}>
|
||||
<div className={getTabClassname('script')} role="tab" data-testid="folder-settings-tab-script" onClick={() => setTab('script')}>
|
||||
Script
|
||||
{hasScripts && <StatusDot />}
|
||||
</div>
|
||||
<div className={getTabClassname('test')} role="tab" onClick={() => setTab('test')}>
|
||||
<div className={getTabClassname('test')} role="tab" data-testid="folder-settings-tab-test" onClick={() => setTab('test')}>
|
||||
Test
|
||||
{hasTests && <StatusDot />}
|
||||
</div>
|
||||
<div className={getTabClassname('vars')} role="tab" onClick={() => setTab('vars')}>
|
||||
<div className={getTabClassname('vars')} role="tab" data-testid="folder-settings-tab-vars" onClick={() => setTab('vars')}>
|
||||
Vars
|
||||
{activeVarsCount > 0 && <sup className="ml-1 font-medium">{activeVarsCount}</sup>}
|
||||
</div>
|
||||
<div className={getTabClassname('auth')} role="tab" onClick={() => setTab('auth')}>
|
||||
<div className={getTabClassname('auth')} role="tab" data-testid="folder-settings-tab-auth" onClick={() => setTab('auth')}>
|
||||
Auth
|
||||
{hasAuth && <StatusDot />}
|
||||
</div>
|
||||
<div className={getTabClassname('docs')} role="tab" onClick={() => setTab('docs')}>
|
||||
<div className={getTabClassname('docs')} role="tab" data-testid="folder-settings-tab-docs" onClick={() => setTab('docs')}>
|
||||
Docs
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import {
|
||||
IconSearch,
|
||||
@@ -13,6 +13,7 @@ import { addTab, focusTab } from 'providers/ReduxStore/slices/tabs';
|
||||
import { toggleCollectionItem, toggleCollection } from 'providers/ReduxStore/slices/collections';
|
||||
import { mountCollection } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { getDefaultRequestPaneTab } from 'utils/collections';
|
||||
import { normalizePath } from 'utils/common/path';
|
||||
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';
|
||||
@@ -26,9 +27,21 @@ const GlobalSearchModal = ({ isOpen, onClose }) => {
|
||||
const debounceTimeoutRef = useRef(null);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const collections = useSelector((state) => state.collections.collections);
|
||||
const allCollections = useSelector((state) => state.collections.collections);
|
||||
const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces);
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
|
||||
const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid);
|
||||
|
||||
const collections = useMemo(() => {
|
||||
if (!activeWorkspace) return allCollections;
|
||||
|
||||
const workspacePaths = new Set(
|
||||
activeWorkspace.collections?.map((wc) => normalizePath(wc.path)) || []
|
||||
);
|
||||
return allCollections.filter((c) => workspacePaths.has(normalizePath(c.pathname)));
|
||||
}, [activeWorkspace, allCollections, workspaces]);
|
||||
|
||||
const createCollectionResults = () => {
|
||||
const collectionResults = collections.map((collection) => ({
|
||||
type: SEARCH_TYPES.COLLECTION,
|
||||
@@ -389,6 +402,7 @@ const GlobalSearchModal = ({ isOpen, onClose }) => {
|
||||
aria-activedescendant={results.length > 0 ? `search-result-${selectedIndex}` : undefined}
|
||||
role="combobox"
|
||||
aria-autocomplete="list"
|
||||
data-testid="global-search-input"
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
|
||||
@@ -45,26 +45,6 @@ class MultiLineEditor extends Component {
|
||||
readOnly: this.props.readOnly,
|
||||
tabindex: 0,
|
||||
extraKeys: {
|
||||
'Ctrl-Enter': () => {
|
||||
if (this.props.onRun) {
|
||||
this.props.onRun();
|
||||
}
|
||||
},
|
||||
'Cmd-Enter': () => {
|
||||
if (this.props.onRun) {
|
||||
this.props.onRun();
|
||||
}
|
||||
},
|
||||
'Cmd-S': () => {
|
||||
if (this.props.onSave) {
|
||||
this.props.onSave();
|
||||
}
|
||||
},
|
||||
'Ctrl-S': () => {
|
||||
if (this.props.onSave) {
|
||||
this.props.onSave();
|
||||
}
|
||||
},
|
||||
'Cmd-F': () => {},
|
||||
'Ctrl-F': () => {},
|
||||
// Tabbing disabled to make tabindex work
|
||||
@@ -90,8 +70,15 @@ class MultiLineEditor extends Component {
|
||||
|
||||
setupLinkAware(this.editor);
|
||||
|
||||
// Add mousetrap calss so Mousetrap captures shortcuts even when Codemirror is focused
|
||||
const cmInput = this.editor.getInputField();
|
||||
if (cmInput) {
|
||||
cmInput.classList.add('mousetrap');
|
||||
}
|
||||
|
||||
this.editor.setValue(String(this.props.value) || '');
|
||||
this.editor.on('change', this._onEdit);
|
||||
this.editor.on('blur', this._onBlur);
|
||||
this.addOverlay(variables);
|
||||
|
||||
// Initialize masking if this is a secret field
|
||||
@@ -99,6 +86,12 @@ class MultiLineEditor extends Component {
|
||||
this._enableMaskedEditor(this.props.isSecret);
|
||||
}
|
||||
|
||||
_onBlur = () => {
|
||||
if (this.editor) {
|
||||
this.editor.setCursor(this.editor.getCursor());
|
||||
}
|
||||
};
|
||||
|
||||
_onEdit = () => {
|
||||
if (!this.ignoreChangeEvent && this.editor) {
|
||||
this.cachedValue = this.editor.getValue();
|
||||
@@ -154,20 +147,13 @@ class MultiLineEditor extends Component {
|
||||
this.editor.setOption('readOnly', this.props.readOnly);
|
||||
}
|
||||
if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue && this.editor) {
|
||||
// TODO: temporary fix for keeping cursor state when auto save and new line insertion collide PR#7098
|
||||
const nextValue = String(this.props.value ?? '');
|
||||
const currentValue = this.editor.getValue();
|
||||
if (this.editor.hasFocus?.() && currentValue !== nextValue) {
|
||||
this.cachedValue = currentValue;
|
||||
} else {
|
||||
const cursor = this.editor.getCursor();
|
||||
this.cachedValue = nextValue;
|
||||
this.editor.setValue(nextValue);
|
||||
this.editor.setCursor(cursor);
|
||||
// Re-apply masking after setValue() since it destroys all CodeMirror marks
|
||||
if (this.maskedEditor && this.maskedEditor.isEnabled()) {
|
||||
this.maskedEditor.update();
|
||||
}
|
||||
const cursor = this.editor.getCursor();
|
||||
this.cachedValue = String(this.props.value);
|
||||
this.editor.setValue(String(this.props.value) || '');
|
||||
this.editor.setCursor(cursor);
|
||||
// Re-apply masking after setValue() since it destroys all CodeMirror marks
|
||||
if (this.maskedEditor && this.maskedEditor.isEnabled()) {
|
||||
this.maskedEditor.update();
|
||||
}
|
||||
}
|
||||
if (!isEqual(this.props.isSecret, prevProps.isSecret)) {
|
||||
@@ -193,7 +179,11 @@ class MultiLineEditor extends Component {
|
||||
this.maskedEditor.destroy();
|
||||
this.maskedEditor = null;
|
||||
}
|
||||
this.editor.getWrapperElement().remove();
|
||||
if (this.editor) {
|
||||
this.editor.off('change', this._onEdit);
|
||||
this.editor.off('blur', this._onBlur);
|
||||
this.editor.getWrapperElement().remove();
|
||||
}
|
||||
}
|
||||
|
||||
addOverlay = (variables) => {
|
||||
|
||||
@@ -80,6 +80,10 @@ const ConnectSpecForm = ({ sourceUrl, setSourceUrl, isLoading, error, setError,
|
||||
setError('The selected file is not a valid OpenAPI 3.x specification');
|
||||
return;
|
||||
}
|
||||
if (data.swagger && String(data.swagger).startsWith('2')) {
|
||||
setError('Swagger 2.0 is not supported. Please convert your spec to OpenAPI 3.x.');
|
||||
return;
|
||||
}
|
||||
const filePath = window.ipcRenderer.getFilePath(file);
|
||||
if (filePath) setSourceUrl(filePath);
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,53 +1,353 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
min-height: 0;
|
||||
max-height: calc(100% - 30px);
|
||||
|
||||
max-width: 80%;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
|
||||
table {
|
||||
width: 80%;
|
||||
border-collapse: collapse;
|
||||
|
||||
thead,
|
||||
td {
|
||||
border: 1px solid ${(props) => props.theme.table.border};
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 0px;
|
||||
}
|
||||
|
||||
.section-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.section-actions-divider {
|
||||
width: 1px;
|
||||
height: 18px;
|
||||
background: ${(props) => props.theme.input.border};
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.section-divider {
|
||||
height: 1px;
|
||||
background: ${(props) => props.theme.input.border};
|
||||
}
|
||||
|
||||
.tables-container {
|
||||
overflow-y: auto;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
|
||||
thead {
|
||||
color: ${(props) => props.theme.table.thead.color};
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
&.tables-disabled {
|
||||
opacity: 0.45;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 6px 10px;
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
}
|
||||
|
||||
thead th {
|
||||
font-weight: 500;
|
||||
padding: 10px;
|
||||
text-align: left;
|
||||
border: 1px solid ${(props) => props.theme.table.border};
|
||||
}
|
||||
}
|
||||
|
||||
.table-container {
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border-radius: ${(props) => props.theme.border.radius.base};
|
||||
border: solid 1px ${(props) => props.theme.border.border0};
|
||||
}
|
||||
|
||||
.key-button {
|
||||
display: inline-block;
|
||||
color: ${(props) => props.theme.table.input.color};
|
||||
opacity: 0.7;
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
}
|
||||
|
||||
thead {
|
||||
color: ${(props) => props.theme.table.thead.color} !important;
|
||||
background: ${(props) => props.theme.table.striped};
|
||||
user-select: none;
|
||||
|
||||
td {
|
||||
padding: 5px 10px !important;
|
||||
border: none !important;
|
||||
border-bottom: solid 1px ${(props) => props.theme.border.border0} !important;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
thead td:first-child,
|
||||
tbody td:first-child {
|
||||
width: 35%;
|
||||
}
|
||||
|
||||
thead td:last-child,
|
||||
tbody td:last-child {
|
||||
width: 45%;
|
||||
}
|
||||
|
||||
tbody {
|
||||
tr {
|
||||
transition: background 0.1s ease;
|
||||
height: 30px;
|
||||
td {
|
||||
padding: 0px 10px !important;
|
||||
border: none !important;
|
||||
vertical-align: middle;
|
||||
background: transparent;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
}
|
||||
|
||||
tr:hover:not(.row-editing) td {
|
||||
background: ${(props) => props.theme.background.surface0};
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
tr.row-editing td {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
tr.section-heading-row td {
|
||||
font-weight: 700;
|
||||
padding: 6px 10px !important;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
tr.section-heading-row:hover td {
|
||||
background: transparent;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
tr.section-last-row td {
|
||||
border-bottom: none !important;
|
||||
}
|
||||
|
||||
tr.section-spacer-row {
|
||||
height: 8px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
tr.section-spacer-row td {
|
||||
padding: 0 !important;
|
||||
height: 8px;
|
||||
line-height: 8px;
|
||||
font-size: 0;
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
border-bottom: solid 1px ${(props) => props.theme.border.border0} !important;
|
||||
}
|
||||
|
||||
tr.section-spacer-row:hover td {
|
||||
background: transparent !important;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.keybinding-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.keybinding-row .edit-btn,
|
||||
.keybinding-row .reset-btn {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.button-placeholder {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.keybinding-row:hover .edit-btn {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.shortcut-wrap {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 260px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.shortcut-input {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 24px;
|
||||
min-width: 200px;
|
||||
max-width: 200px;
|
||||
flex-shrink: 0;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.shortcut-input--editing {
|
||||
outline: 1px solid ${(props) => props.theme.status.warning.border};
|
||||
border-radius: 4px;
|
||||
padding: 1px 5px;
|
||||
font-family: monospace;
|
||||
margin-right: 8px;
|
||||
border: 1px solid #ccc;
|
||||
border-bottom: 1.44px solid ${(props) => props.theme.table.input.border};
|
||||
min-width: 100%;
|
||||
max-width: 100%;
|
||||
padding: 0 8px;
|
||||
caret-color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
.shortcut-input--error.shortcut-input--editing {
|
||||
outline: 1px solid ${(props) => props.theme.status.danger.border};
|
||||
min-width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.shortcut-input--readonly {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.shortcut-text {
|
||||
font-size: 12px;
|
||||
color: ${(props) => props.theme.table.input.color};
|
||||
}
|
||||
|
||||
.shortcut-pills {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.shortcut-separator {
|
||||
color: ${(props) => props.theme.table.thead.color};
|
||||
margin: 0 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.keycap {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 2px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
background: ${(props) => props.theme.background.base};
|
||||
color: ${(props) => props.theme.table.input.color};
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
tbody tr.row-success td,
|
||||
tbody tr.row-success:hover td {
|
||||
background: ${(props) => props.theme.status.success.background} !important;
|
||||
}
|
||||
|
||||
tbody tr.row-error td,
|
||||
tbody tr.row-error:hover td {
|
||||
background: ${(props) => props.theme.status.danger.background} !important;
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
color: ${(props) => props.theme.status.success.text};
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
color: ${(props) => props.theme.status.danger.text};
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.input-error-icon {
|
||||
color: ${(props) => props.theme.status.danger.text};
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@keyframes blink-caret {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
.editing-caret {
|
||||
display: inline-block;
|
||||
width: 1px;
|
||||
height: 12px;
|
||||
background: ${(props) => props.theme.text};
|
||||
margin-left: 1px;
|
||||
vertical-align: middle;
|
||||
animation: blink-caret 1s step-end infinite;
|
||||
}
|
||||
|
||||
.edit-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: ${(props) => props.theme.table.thead.color};
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
opacity: 0.6;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.reset-btn {
|
||||
background: transparent;
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
color: ${(props) => props.theme.table.thead.color};
|
||||
border-radius: 6px;
|
||||
padding: 0px 6px;
|
||||
cursor: pointer;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
background: transparent;
|
||||
color: ${(props) => props.theme.table.thead.color};
|
||||
border-radius: 6px;
|
||||
padding: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pencil-icon {
|
||||
color: ${(props) => props.theme.table.thead.color};
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.shortcut-input--error {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.tooltip-mod.tooltip-mod--error {
|
||||
color: ${(props) => props.theme.status.danger.text} !important;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 12px 2px;
|
||||
color: ${(props) => props.theme.text};
|
||||
opacity: 0.8;
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,7 @@ const StyledWrapper = styled.div`
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
|
||||
|
||||
.settings-label {
|
||||
width: 100px;
|
||||
}
|
||||
@@ -26,6 +26,57 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
}
|
||||
|
||||
.pac-mode-toggle {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
border-radius: ${(props) => props.theme.border.radius.base};
|
||||
overflow: hidden;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.pac-mode-btn {
|
||||
height: 34px;
|
||||
padding: 0.1rem 0.6rem;
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
font-weight: 500;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
white-space: nowrap;
|
||||
|
||||
&.active {
|
||||
background: ${(props) => props.theme.button.secondary.bg};
|
||||
color: ${(props) => props.theme.button.secondary.color};
|
||||
}
|
||||
|
||||
&:hover:not(.active) {
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
}
|
||||
|
||||
.pac-source-input {
|
||||
width: 265px;
|
||||
}
|
||||
|
||||
.pac-file-btn {
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.pac-hint {
|
||||
font-size: ${(props) => props.theme.font.size.xs};
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
margin-top: 4px;
|
||||
padding-left: 100px;
|
||||
}
|
||||
|
||||
.system-proxy-settings {
|
||||
label {
|
||||
color: ${(props) => props.theme.colors.text.yellow};
|
||||
|
||||
@@ -17,7 +17,22 @@ const ProxySettings = ({ close }) => {
|
||||
|
||||
const proxySchema = Yup.object({
|
||||
disabled: Yup.boolean().optional(),
|
||||
inherit: Yup.boolean().required(),
|
||||
source: Yup.string().oneOf(['manual', 'pac', 'inherit']).required(),
|
||||
pac: Yup.object({
|
||||
source: Yup.string()
|
||||
.optional()
|
||||
.test('pac-url', 'Specify a valid PAC URL', (value) => {
|
||||
if (!value) return true;
|
||||
try {
|
||||
const u = new URL(value);
|
||||
return u.protocol === 'http:' || u.protocol === 'https:' || u.protocol === 'file:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
.max(2048)
|
||||
.nullable()
|
||||
}).optional(),
|
||||
config: Yup.object({
|
||||
protocol: Yup.string().required().oneOf(['http', 'https', 'socks4', 'socks5']),
|
||||
hostname: Yup.string().max(1024),
|
||||
@@ -39,7 +54,10 @@ const ProxySettings = ({ close }) => {
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
disabled: preferences.proxy.disabled || false,
|
||||
inherit: preferences.proxy.inherit || false,
|
||||
source: preferences.proxy.source || 'manual',
|
||||
pac: {
|
||||
source: preferences.proxy.pac?.source || ''
|
||||
},
|
||||
config: {
|
||||
protocol: preferences.proxy.config?.protocol || 'http',
|
||||
hostname: preferences.proxy.config?.hostname || '',
|
||||
@@ -86,15 +104,26 @@ const ProxySettings = ({ close }) => {
|
||||
);
|
||||
|
||||
const [passwordVisible, setPasswordVisible] = useState(false);
|
||||
const [proxyMode, setProxyMode] = useState(() => {
|
||||
if (preferences.proxy.disabled) return 'off';
|
||||
if (preferences.proxy.source === 'pac') return 'pac';
|
||||
if (preferences.proxy.source === 'inherit') return 'inherit';
|
||||
return 'manual';
|
||||
});
|
||||
const [pacInputMode, setPacInputMode] = useState(() =>
|
||||
preferences.proxy.pac?.source?.startsWith('file://') ? 'file' : 'url'
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (formik.dirty && formik.isValid) {
|
||||
// Don't auto-save PAC mode until a URL or file is actually selected.
|
||||
if (proxyMode === 'pac' && !formik.values.pac.source) return;
|
||||
debouncedSave(formik.values);
|
||||
}
|
||||
return () => {
|
||||
debouncedSave.flush();
|
||||
};
|
||||
}, [formik.values, formik.dirty, formik.isValid, debouncedSave]);
|
||||
}, [formik.values, formik.dirty, formik.isValid, debouncedSave, proxyMode]);
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
@@ -110,10 +139,10 @@ const ProxySettings = ({ close }) => {
|
||||
type="radio"
|
||||
name="mode"
|
||||
value="off"
|
||||
checked={formik.values.disabled === true}
|
||||
checked={proxyMode === 'off'}
|
||||
onChange={(e) => {
|
||||
setProxyMode('off');
|
||||
formik.setFieldValue('disabled', true);
|
||||
formik.setFieldValue('inherit', false);
|
||||
}}
|
||||
className="mr-1 cursor-pointer"
|
||||
/>
|
||||
@@ -123,11 +152,12 @@ const ProxySettings = ({ close }) => {
|
||||
<input
|
||||
type="radio"
|
||||
name="mode"
|
||||
value="on"
|
||||
checked={formik.values.disabled === false && formik.values.inherit === false}
|
||||
value="manual"
|
||||
checked={proxyMode === 'manual'}
|
||||
onChange={(e) => {
|
||||
setProxyMode('manual');
|
||||
formik.setFieldValue('disabled', false);
|
||||
formik.setFieldValue('inherit', false);
|
||||
formik.setFieldValue('source', 'manual');
|
||||
}}
|
||||
className="mr-1 cursor-pointer"
|
||||
/>
|
||||
@@ -137,24 +167,40 @@ const ProxySettings = ({ close }) => {
|
||||
<input
|
||||
type="radio"
|
||||
name="mode"
|
||||
value="system"
|
||||
checked={formik.values.disabled === false && formik.values.inherit === true}
|
||||
value="inherit"
|
||||
checked={proxyMode === 'inherit'}
|
||||
onChange={(e) => {
|
||||
setProxyMode('inherit');
|
||||
formik.setFieldValue('disabled', false);
|
||||
formik.setFieldValue('inherit', true);
|
||||
formik.setFieldValue('source', 'inherit');
|
||||
}}
|
||||
className="mr-1 cursor-pointer"
|
||||
/>
|
||||
System Proxy
|
||||
</label>
|
||||
<label className="flex items-center ml-4 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="mode"
|
||||
value="pac"
|
||||
checked={proxyMode === 'pac'}
|
||||
onChange={(e) => {
|
||||
setProxyMode('pac');
|
||||
formik.setFieldValue('disabled', false);
|
||||
formik.setFieldValue('source', 'pac');
|
||||
}}
|
||||
className="mr-1 cursor-pointer"
|
||||
/>
|
||||
PAC
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{formik.values.disabled === false && formik.values.inherit === true ? (
|
||||
{proxyMode === 'inherit' ? (
|
||||
<div className="mb-3 pt-1 text-muted system-proxy-settings">
|
||||
<SystemProxy />
|
||||
</div>
|
||||
) : null}
|
||||
{formik.values.disabled === false && formik.values.inherit === false ? (
|
||||
{proxyMode === 'manual' ? (
|
||||
<>
|
||||
<div className="mb-3 flex items-center">
|
||||
<label className="settings-label" htmlFor="protocol">
|
||||
@@ -335,6 +381,79 @@ const ProxySettings = ({ close }) => {
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
{proxyMode === 'pac' ? (
|
||||
<>
|
||||
<div className="mb-3">
|
||||
<div className="flex items-center">
|
||||
<label className="settings-label">PAC</label>
|
||||
<div className="pac-mode-toggle">
|
||||
<button
|
||||
type="button"
|
||||
className={`pac-mode-btn ${pacInputMode === 'url' ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setPacInputMode('url');
|
||||
formik.setFieldValue('pac.source', '');
|
||||
}}
|
||||
>
|
||||
URL
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`pac-mode-btn ${pacInputMode === 'file' ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setPacInputMode('file');
|
||||
formik.setFieldValue('pac.source', '');
|
||||
}}
|
||||
>
|
||||
File
|
||||
</button>
|
||||
</div>
|
||||
{pacInputMode === 'url' ? (
|
||||
<input
|
||||
id="pac.source"
|
||||
type="text"
|
||||
name="pac.source"
|
||||
className="block textbox pac-source-input"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
onChange={formik.handleChange}
|
||||
value={formik.values.pac.source || ''}
|
||||
placeholder="https://example.com/proxy.pac"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="textbox pac-source-input pac-file-btn"
|
||||
onClick={() => {
|
||||
window.ipcRenderer
|
||||
.invoke('renderer:browse-pac-file')
|
||||
.then((fileUrl) => {
|
||||
if (fileUrl) {
|
||||
formik.setFieldValue('pac.source', fileUrl);
|
||||
}
|
||||
})
|
||||
.catch(() => toast.error('Failed to open file picker'));
|
||||
}}
|
||||
>
|
||||
{formik.values.pac.source
|
||||
? decodeURIComponent(formik.values.pac.source.split('/').pop())
|
||||
: 'Choose file...'}
|
||||
</button>
|
||||
)}
|
||||
{formik.touched.pac?.source && formik.errors.pac?.source ? (
|
||||
<div className="ml-3 text-red-500">{formik.errors.pac.source}</div>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="pac-hint">
|
||||
{pacInputMode === 'url'
|
||||
? 'Enter the URL to your PAC file'
|
||||
: 'Supports .pac files for automatic proxy configuration'}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</form>
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@ import styled from 'styled-components';
|
||||
const StyledWrapper = styled.div`
|
||||
div.tabs {
|
||||
padding: 12px;
|
||||
min-width: 160px;
|
||||
min-width: 180px;
|
||||
|
||||
div.tab {
|
||||
display: flex;
|
||||
@@ -38,7 +38,7 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
|
||||
section.tab-panel {
|
||||
min-height: 70vh;
|
||||
max-height: calc(100% - 55px);
|
||||
overflow-y: auto;
|
||||
flex-grow: 1;
|
||||
padding: 12px;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { moveAssertion, setRequestAssertions } from 'providers/ReduxStore/slices/collections';
|
||||
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
|
||||
import SingleLineEditor from 'components/SingleLineEditor';
|
||||
import AssertionOperator from './AssertionOperator';
|
||||
import EditableTable from 'components/EditableTable';
|
||||
@@ -54,8 +55,18 @@ const isUnaryOperator = (operator) => unaryOperators.includes(operator);
|
||||
const Assertions = ({ item, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
|
||||
const assertions = item.draft ? get(item, 'draft.request.assertions') : get(item, 'request.assertions');
|
||||
|
||||
// Get column widths from Redux
|
||||
const focusedTab = tabs?.find((t) => t.uid === activeTabUid);
|
||||
const assertionsWidths = focusedTab?.tableColumnWidths?.['assertions'] || {};
|
||||
|
||||
const handleColumnWidthsChange = (tableId, widths) => {
|
||||
dispatch(updateTableColumnWidths({ uid: activeTabUid, tableId, widths }));
|
||||
};
|
||||
|
||||
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
|
||||
const handleRun = () => dispatch(sendRequest(item, collection.uid));
|
||||
|
||||
@@ -157,6 +168,7 @@ const Assertions = ({ item, collection }) => {
|
||||
return (
|
||||
<StyledWrapper className="w-full">
|
||||
<EditableTable
|
||||
tableId="assertions"
|
||||
columns={columns}
|
||||
rows={assertions || []}
|
||||
onChange={handleAssertionsChange}
|
||||
@@ -164,6 +176,8 @@ const Assertions = ({ item, collection }) => {
|
||||
reorderable={true}
|
||||
onReorder={handleAssertionDrag}
|
||||
testId="assertions-table"
|
||||
columnWidths={assertionsWidths}
|
||||
onColumnWidthsChange={(widths) => handleColumnWidthsChange('assertions', widths)}
|
||||
/>
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -47,6 +47,11 @@ const AuthMode = ({ item, collection }) => {
|
||||
label: 'NTLM Auth',
|
||||
onClick: () => onModeChange('ntlm')
|
||||
},
|
||||
{
|
||||
id: 'oauth1',
|
||||
label: 'OAuth 1.0',
|
||||
onClick: () => onModeChange('oauth1')
|
||||
},
|
||||
{
|
||||
id: 'oauth2',
|
||||
label: 'OAuth 2.0',
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import styled from 'styled-components';
|
||||
import { rgba } from 'polished';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
.oauth1-icon-container {
|
||||
background-color: ${(props) => rgba(props.theme.primary.solid, 0.1)};
|
||||
}
|
||||
|
||||
label {
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
color: ${(props) => props.theme.colors.text.subtext1};
|
||||
}
|
||||
|
||||
.oauth1-section-label {
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
.single-line-editor-wrapper {
|
||||
max-width: 400px;
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 3px;
|
||||
border: solid 1px ${(props) => props.theme.input.border};
|
||||
background-color: ${(props) => props.theme.input.bg};
|
||||
}
|
||||
|
||||
.oauth1-dropdown-selector {
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
padding: 0.2rem 0px;
|
||||
border-radius: 3px;
|
||||
border: solid 1px ${(props) => props.theme.input.border};
|
||||
background-color: ${(props) => props.theme.input.bg};
|
||||
min-width: 100px;
|
||||
|
||||
.dropdown {
|
||||
width: fit-content;
|
||||
min-width: 100px;
|
||||
|
||||
div[data-tippy-root] {
|
||||
width: fit-content;
|
||||
min-width: 100px;
|
||||
}
|
||||
.tippy-box {
|
||||
width: fit-content;
|
||||
max-width: none !important;
|
||||
min-width: 100px;
|
||||
|
||||
.tippy-content {
|
||||
width: fit-content;
|
||||
max-width: none !important;
|
||||
min-width: 100px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.oauth1-dropdown-label {
|
||||
width: fit-content;
|
||||
justify-content: space-between;
|
||||
padding: 0 0.5rem;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
padding: 0.2rem 0.6rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
.private-key-editor-wrapper {
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 3px;
|
||||
border: solid 1px ${(props) => props.theme.input.border};
|
||||
background-color: ${(props) => props.theme.input.bg};
|
||||
max-width: 400px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
input[type='checkbox'] {
|
||||
cursor: pointer;
|
||||
accent-color: ${(props) => props.theme.primary.solid};
|
||||
}
|
||||
|
||||
.transition-transform {
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.rotate-90 {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
`;
|
||||
|
||||
export default Wrapper;
|
||||
@@ -0,0 +1,439 @@
|
||||
import React, { useState } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import path from 'utils/common/path';
|
||||
import { IconSettings, IconShieldLock, IconAdjustmentsHorizontal, IconCaretDown, IconChevronRight, IconFile, IconX, IconUpload } from '@tabler/icons';
|
||||
import MenuDropdown from 'ui/MenuDropdown';
|
||||
import SingleLineEditor from 'components/SingleLineEditor';
|
||||
import MultiLineEditor from 'components/MultiLineEditor';
|
||||
import SensitiveFieldWarning from 'components/SensitiveFieldWarning';
|
||||
import { useDetectSensitiveField } from 'hooks/useDetectSensitiveField';
|
||||
import toast from 'react-hot-toast';
|
||||
import { sendRequest, browseFiles } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const signatureMethodLabels = {
|
||||
'HMAC-SHA1': 'HMAC-SHA1',
|
||||
'HMAC-SHA256': 'HMAC-SHA256',
|
||||
'HMAC-SHA512': 'HMAC-SHA512',
|
||||
'RSA-SHA1': 'RSA-SHA1',
|
||||
'RSA-SHA256': 'RSA-SHA256',
|
||||
'RSA-SHA512': 'RSA-SHA512',
|
||||
'PLAINTEXT': 'PLAINTEXT'
|
||||
};
|
||||
|
||||
const placementLabels = {
|
||||
header: 'Header',
|
||||
query: 'Query Params',
|
||||
body: 'Body'
|
||||
};
|
||||
|
||||
const OAuth1 = ({ item = {}, collection, request, save, updateAuth }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
const oauth1 = get(request, 'auth.oauth1', {});
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
|
||||
const { isSensitive } = useDetectSensitiveField(collection);
|
||||
const consumerSecretSensitive = isSensitive(oauth1.consumerSecret);
|
||||
const tokenSecretSensitive = isSensitive(oauth1.accessTokenSecret);
|
||||
const privateKeySensitive = isSensitive(oauth1.privateKey);
|
||||
|
||||
const handleRun = item?.uid ? () => dispatch(sendRequest(item, collection.uid)) : undefined;
|
||||
const handleSave = () => save();
|
||||
|
||||
const handleChange = (field, value) => {
|
||||
dispatch(
|
||||
updateAuth({
|
||||
mode: 'oauth1',
|
||||
collectionUid: collection.uid,
|
||||
itemUid: item.uid,
|
||||
content: {
|
||||
...oauth1,
|
||||
[field]: value
|
||||
}
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const handlePrivateKeyChange = (val) => {
|
||||
if (val && /^@file\(/.test(val.trim())) {
|
||||
toast.error('File references should be added using the "Upload File" button below');
|
||||
return;
|
||||
}
|
||||
handleChange('privateKey', val);
|
||||
};
|
||||
|
||||
const handleBrowse = () => {
|
||||
dispatch(browseFiles([], []))
|
||||
.then((filePaths) => {
|
||||
if (filePaths && filePaths.length > 0) {
|
||||
let filePath = filePaths[0];
|
||||
const collectionDir = collection.pathname;
|
||||
filePath = path.relative(collectionDir, filePath);
|
||||
dispatch(
|
||||
updateAuth({
|
||||
mode: 'oauth1',
|
||||
collectionUid: collection.uid,
|
||||
itemUid: item.uid,
|
||||
content: {
|
||||
...oauth1,
|
||||
privateKey: filePath,
|
||||
privateKeyType: 'file'
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((error) => console.error(error));
|
||||
};
|
||||
|
||||
const handleClearFile = () => {
|
||||
dispatch(
|
||||
updateAuth({
|
||||
mode: 'oauth1',
|
||||
collectionUid: collection.uid,
|
||||
itemUid: item.uid,
|
||||
content: {
|
||||
...oauth1,
|
||||
privateKey: '',
|
||||
privateKeyType: 'text'
|
||||
}
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const privateKeyValue = oauth1.privateKey || '';
|
||||
const isFileRef = oauth1.privateKeyType === 'file';
|
||||
const fileName = isFileRef ? path.basename(privateKeyValue) : '';
|
||||
|
||||
return (
|
||||
<StyledWrapper className="mt-2 flex w-full gap-4 flex-col">
|
||||
{/* Configuration Section */}
|
||||
<div className="flex items-center gap-2.5 mt-2">
|
||||
<div className="flex items-center px-2.5 py-1.5 oauth1-icon-container rounded-md">
|
||||
<IconSettings size={14} className="oauth1-icon" />
|
||||
</div>
|
||||
<span className="oauth1-section-label">
|
||||
Configuration
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 w-full">
|
||||
<label className="block min-w-[140px]">Consumer Key</label>
|
||||
<div className="single-line-editor-wrapper flex-1">
|
||||
<SingleLineEditor
|
||||
value={oauth1.consumerKey || ''}
|
||||
theme={storedTheme}
|
||||
onSave={handleSave}
|
||||
onChange={(val) => handleChange('consumerKey', val)}
|
||||
onRun={handleRun}
|
||||
collection={collection}
|
||||
item={item}
|
||||
isCompact
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!oauth1.signatureMethod?.startsWith('RSA-') && (
|
||||
<div className="flex items-center gap-4 w-full">
|
||||
<label className="block min-w-[140px]">Consumer Secret</label>
|
||||
<div className="single-line-editor-wrapper flex-1 flex items-center">
|
||||
<SingleLineEditor
|
||||
value={oauth1.consumerSecret || ''}
|
||||
theme={storedTheme}
|
||||
onSave={handleSave}
|
||||
onChange={(val) => handleChange('consumerSecret', val)}
|
||||
onRun={handleRun}
|
||||
collection={collection}
|
||||
item={item}
|
||||
isSecret={true}
|
||||
isCompact
|
||||
/>
|
||||
{consumerSecretSensitive.showWarning && <SensitiveFieldWarning fieldName="oauth1-consumer-secret" warningMessage={consumerSecretSensitive.warningMessage} />}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4 w-full">
|
||||
<label className="block min-w-[140px]">Token</label>
|
||||
<div className="single-line-editor-wrapper flex-1">
|
||||
<SingleLineEditor
|
||||
value={oauth1.accessToken || ''}
|
||||
theme={storedTheme}
|
||||
onSave={handleSave}
|
||||
onChange={(val) => handleChange('accessToken', val)}
|
||||
onRun={handleRun}
|
||||
collection={collection}
|
||||
item={item}
|
||||
isCompact
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 w-full">
|
||||
<label className="block min-w-[140px]">Token Secret</label>
|
||||
<div className="single-line-editor-wrapper flex-1 flex items-center">
|
||||
<SingleLineEditor
|
||||
value={oauth1.accessTokenSecret || ''}
|
||||
theme={storedTheme}
|
||||
onSave={handleSave}
|
||||
onChange={(val) => handleChange('accessTokenSecret', val)}
|
||||
onRun={handleRun}
|
||||
collection={collection}
|
||||
item={item}
|
||||
isSecret={true}
|
||||
isCompact
|
||||
/>
|
||||
{tokenSecretSensitive.showWarning && <SensitiveFieldWarning fieldName="oauth1-token-secret" warningMessage={tokenSecretSensitive.warningMessage} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Signature Section */}
|
||||
<div className="flex items-center gap-2.5 mt-2">
|
||||
<div className="flex items-center px-2.5 py-1.5 oauth1-icon-container rounded-md">
|
||||
<IconShieldLock size={14} className="oauth1-icon" />
|
||||
</div>
|
||||
<span className="oauth1-section-label">
|
||||
Signature
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 w-full">
|
||||
<label className="block min-w-[140px]">Signature Method</label>
|
||||
<div className="inline-flex items-center cursor-pointer oauth1-dropdown-selector">
|
||||
<MenuDropdown
|
||||
items={Object.entries(signatureMethodLabels).map(([value, label]) => ({
|
||||
id: value,
|
||||
label,
|
||||
onClick: () => handleChange('signatureMethod', value)
|
||||
}))}
|
||||
selectedItemId={oauth1.signatureMethod}
|
||||
placement="bottom-end"
|
||||
>
|
||||
<div className="flex items-center justify-end oauth1-dropdown-label select-none">
|
||||
{signatureMethodLabels[oauth1.signatureMethod] || 'HMAC-SHA1'}
|
||||
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
|
||||
</div>
|
||||
</MenuDropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{oauth1.signatureMethod?.startsWith('RSA-') && (
|
||||
<div className="flex items-start gap-4 w-full">
|
||||
<label className="block min-w-[140px] mt-1">Private Key</label>
|
||||
{isFileRef ? (
|
||||
<div className="private-key-editor-wrapper flex-1 flex items-center gap-2">
|
||||
<IconFile size={16} className="oauth1-icon flex-shrink-0" />
|
||||
<span className="truncate flex-1" title={privateKeyValue}>{fileName}</span>
|
||||
<button
|
||||
className="flex-shrink-0 oauth1-icon cursor-pointer"
|
||||
onClick={handleClearFile}
|
||||
title="Clear file"
|
||||
type="button"
|
||||
>
|
||||
<IconX size={14} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<div className="private-key-editor-wrapper flex-1 flex items-center">
|
||||
<MultiLineEditor
|
||||
value={privateKeyValue}
|
||||
theme={storedTheme}
|
||||
onSave={handleSave}
|
||||
onChange={handlePrivateKeyChange}
|
||||
onRun={handleRun}
|
||||
collection={collection}
|
||||
item={item}
|
||||
isSecret={true}
|
||||
allowNewlines={true}
|
||||
/>
|
||||
{privateKeySensitive.showWarning && <SensitiveFieldWarning fieldName="oauth1-private-key" warningMessage={privateKeySensitive.warningMessage} />}
|
||||
</div>
|
||||
<div className="flex flex-row gap-2">
|
||||
<button
|
||||
className="flex items-center gap-1 oauth1-icon cursor-pointer text-link"
|
||||
onClick={handleBrowse}
|
||||
title="Select file"
|
||||
type="button"
|
||||
>
|
||||
<IconUpload size={14} />
|
||||
<span className="text-xs">Upload File</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4 w-full">
|
||||
<label className="block min-w-[140px]">Add Params To</label>
|
||||
<div className="inline-flex items-center cursor-pointer oauth1-dropdown-selector">
|
||||
<MenuDropdown
|
||||
items={Object.entries(placementLabels).map(([value, label]) => ({
|
||||
id: value,
|
||||
label,
|
||||
onClick: () => handleChange('placement', value)
|
||||
}))}
|
||||
selectedItemId={oauth1.placement}
|
||||
placement="bottom-end"
|
||||
>
|
||||
<div className="flex items-center justify-end oauth1-dropdown-label select-none">
|
||||
{placementLabels[oauth1.placement] || 'Header'}
|
||||
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
|
||||
</div>
|
||||
</MenuDropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{oauth1.placement === 'body' && (
|
||||
<div className="flex items-center gap-4 w-full">
|
||||
<label className="block min-w-[140px]"></label>
|
||||
<span className="text-xs opacity-60">
|
||||
Body placement requires a form-urlencoded body. Non-form payloads will be replaced with OAuth parameters.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4 w-full">
|
||||
<label className="block min-w-[140px]"></label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={oauth1.includeBodyHash || false}
|
||||
onChange={(e) => handleChange('includeBodyHash', e.target.checked)}
|
||||
/>
|
||||
<label
|
||||
className="block cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.preventDefault(); handleChange('includeBodyHash', !oauth1.includeBodyHash);
|
||||
}}
|
||||
>
|
||||
Include Body Hash
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Advanced Section (collapsible) */}
|
||||
<div
|
||||
className="flex items-center gap-2.5 mt-2 cursor-pointer select-none"
|
||||
onClick={() => setAdvancedOpen(!advancedOpen)}
|
||||
>
|
||||
<div className="flex items-center px-2.5 py-1.5 oauth1-icon-container rounded-md">
|
||||
<IconAdjustmentsHorizontal size={14} className="oauth1-icon" />
|
||||
</div>
|
||||
<span className="oauth1-section-label">
|
||||
Advanced
|
||||
</span>
|
||||
<IconChevronRight
|
||||
size={14}
|
||||
className={`oauth1-icon transition-transform ${advancedOpen ? 'rotate-90' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{advancedOpen && (
|
||||
<>
|
||||
<div className="flex items-center gap-4 w-full">
|
||||
<label className="block min-w-[140px]">Callback URL</label>
|
||||
<div className="single-line-editor-wrapper flex-1">
|
||||
<SingleLineEditor
|
||||
value={oauth1.callbackUrl || ''}
|
||||
theme={storedTheme}
|
||||
onSave={handleSave}
|
||||
onChange={(val) => handleChange('callbackUrl', val)}
|
||||
onRun={handleRun}
|
||||
collection={collection}
|
||||
item={item}
|
||||
isCompact
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 w-full">
|
||||
<label className="block min-w-[140px]">Verifier</label>
|
||||
<div className="single-line-editor-wrapper flex-1">
|
||||
<SingleLineEditor
|
||||
value={oauth1.verifier || ''}
|
||||
theme={storedTheme}
|
||||
onSave={handleSave}
|
||||
onChange={(val) => handleChange('verifier', val)}
|
||||
onRun={handleRun}
|
||||
collection={collection}
|
||||
item={item}
|
||||
isCompact
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 w-full">
|
||||
<label className="block min-w-[140px]">Timestamp</label>
|
||||
<div className="single-line-editor-wrapper flex-1">
|
||||
<SingleLineEditor
|
||||
value={oauth1.timestamp || ''}
|
||||
theme={storedTheme}
|
||||
onSave={handleSave}
|
||||
onChange={(val) => handleChange('timestamp', val)}
|
||||
onRun={handleRun}
|
||||
collection={collection}
|
||||
item={item}
|
||||
isCompact
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 w-full">
|
||||
<label className="block min-w-[140px]">Nonce</label>
|
||||
<div className="single-line-editor-wrapper flex-1">
|
||||
<SingleLineEditor
|
||||
value={oauth1.nonce || ''}
|
||||
theme={storedTheme}
|
||||
onSave={handleSave}
|
||||
onChange={(val) => handleChange('nonce', val)}
|
||||
onRun={handleRun}
|
||||
collection={collection}
|
||||
item={item}
|
||||
isCompact
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 w-full">
|
||||
<label className="block min-w-[140px]">Version</label>
|
||||
<div className="single-line-editor-wrapper flex-1">
|
||||
<SingleLineEditor
|
||||
value={oauth1.version || ''}
|
||||
theme={storedTheme}
|
||||
onSave={handleSave}
|
||||
onChange={(val) => handleChange('version', val)}
|
||||
onRun={handleRun}
|
||||
collection={collection}
|
||||
item={item}
|
||||
isCompact
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 w-full">
|
||||
<label className="block min-w-[140px]">Realm</label>
|
||||
<div className="single-line-editor-wrapper flex-1">
|
||||
<SingleLineEditor
|
||||
value={oauth1.realm || ''}
|
||||
theme={storedTheme}
|
||||
onSave={handleSave}
|
||||
onChange={(val) => handleChange('realm', val)}
|
||||
onRun={handleRun}
|
||||
collection={collection}
|
||||
item={item}
|
||||
isCompact
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default OAuth1;
|
||||
@@ -6,6 +6,7 @@ import BasicAuth from './BasicAuth';
|
||||
import DigestAuth from './DigestAuth';
|
||||
import WsseAuth from './WsseAuth';
|
||||
import NTLMAuth from './NTLMAuth';
|
||||
import OAuth1 from './OAuth1';
|
||||
import { updateAuth } from 'providers/ReduxStore/slices/collections';
|
||||
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { useDispatch } from 'react-redux';
|
||||
@@ -90,6 +91,9 @@ const Auth = ({ item, collection }) => {
|
||||
case 'ntlm': {
|
||||
return <NTLMAuth collection={collection} item={item} request={request} save={save} updateAuth={updateAuth} />;
|
||||
}
|
||||
case 'oauth1': {
|
||||
return <OAuth1 collection={collection} item={item} request={request} save={save} updateAuth={updateAuth} />;
|
||||
}
|
||||
case 'oauth2': {
|
||||
return <OAuth2 collection={collection} item={item} request={request} save={save} updateAuth={updateAuth} />;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import {
|
||||
moveFormUrlEncodedParam,
|
||||
@@ -8,14 +8,25 @@ import {
|
||||
} from 'providers/ReduxStore/slices/collections';
|
||||
import MultiLineEditor from 'components/MultiLineEditor';
|
||||
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
|
||||
import EditableTable from 'components/EditableTable';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const FormUrlEncodedParams = ({ item, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
|
||||
const params = item.draft ? get(item, 'draft.request.body.formUrlEncoded') : get(item, 'request.body.formUrlEncoded');
|
||||
|
||||
// Get column widths from Redux
|
||||
const focusedTab = tabs?.find((t) => t.uid === activeTabUid);
|
||||
const formUrlEncodedWidths = focusedTab?.tableColumnWidths?.['form-url-encoded'] || {};
|
||||
|
||||
const handleColumnWidthsChange = (tableId, widths) => {
|
||||
dispatch(updateTableColumnWidths({ uid: activeTabUid, tableId, widths }));
|
||||
};
|
||||
|
||||
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
|
||||
const handleRun = () => dispatch(sendRequest(item, collection.uid));
|
||||
|
||||
@@ -72,12 +83,15 @@ const FormUrlEncodedParams = ({ item, collection }) => {
|
||||
return (
|
||||
<StyledWrapper className="w-full">
|
||||
<EditableTable
|
||||
tableId="form-url-encoded"
|
||||
columns={columns}
|
||||
rows={params || []}
|
||||
onChange={handleParamsChange}
|
||||
defaultRow={defaultRow}
|
||||
reorderable={true}
|
||||
onReorder={handleParamDrag}
|
||||
columnWidths={formUrlEncodedWidths}
|
||||
onColumnWidthsChange={(widths) => handleColumnWidthsChange('form-url-encoded', widths)}
|
||||
/>
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.variables-section {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.variables-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 3px 10px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
font-size: 12px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
.variables-chevron {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.variables-dragbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 10px;
|
||||
cursor: row-resize;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
border-top: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.border};
|
||||
}
|
||||
|
||||
&:hover::after {
|
||||
border-top: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.activeBorder};
|
||||
}
|
||||
}
|
||||
|
||||
div.graphql-query-builder-container {
|
||||
height: 100%;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
div.query-builder-dragbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 10px;
|
||||
min-width: 10px;
|
||||
cursor: col-resize;
|
||||
background: transparent;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: 1px;
|
||||
border-left: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.border};
|
||||
}
|
||||
|
||||
&:hover::after {
|
||||
border-left: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.activeBorder};
|
||||
}
|
||||
}
|
||||
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -1,10 +1,15 @@
|
||||
import React, { useEffect, useState, useCallback, useMemo, useRef } from 'react';
|
||||
import React, { useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import find from 'lodash/find';
|
||||
import get from 'lodash/get';
|
||||
import classnames from 'classnames';
|
||||
import { IconWand, IconDots, IconBook, IconDownload, IconRefresh, IconFile, IconChevronDown, IconChevronRight } from '@tabler/icons';
|
||||
import IconSidebarToggle from 'components/Icons/IconSidebarToggle';
|
||||
import ActionIcon from 'ui/ActionIcon';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { updateRequestPaneTab } from 'providers/ReduxStore/slices/tabs';
|
||||
import { updateRequestPaneTab, updateQueryBuilderOpen, updateQueryBuilderWidth, updateVariablesPaneOpen, updateVariablesPaneHeight } from 'providers/ReduxStore/slices/tabs';
|
||||
import QueryEditor from 'components/RequestPane/QueryEditor';
|
||||
import QueryBuilder from 'components/RequestPane/QueryBuilder';
|
||||
import MenuDropdown from 'ui/MenuDropdown';
|
||||
import Auth from 'components/RequestPane/Auth';
|
||||
import GraphQLVariables from 'components/RequestPane/GraphQLVariables';
|
||||
import RequestHeaders from 'components/RequestPane/RequestHeaders';
|
||||
@@ -13,10 +18,12 @@ import Assertions from 'components/RequestPane/Assertions';
|
||||
import Script from 'components/RequestPane/Script';
|
||||
import Tests from 'components/RequestPane/Tests';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { updateRequestGraphqlQuery } from 'providers/ReduxStore/slices/collections';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { updateRequestGraphqlQuery, updateRequestGraphqlVariables } from 'providers/ReduxStore/slices/collections';
|
||||
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import Documentation from 'components/Documentation/index';
|
||||
import GraphQLSchemaActions from '../GraphQLSchemaActions/index';
|
||||
import useGraphqlSchema from '../GraphQLSchemaActions/useGraphqlSchema';
|
||||
import { findEnvironmentInCollection } from 'utils/collections';
|
||||
import HeightBoundContainer from 'ui/HeightBoundContainer';
|
||||
import Settings from 'components/RequestPane/Settings';
|
||||
import ResponsiveTabs from 'ui/ResponsiveTabs';
|
||||
@@ -24,7 +31,6 @@ import AuthMode from '../Auth/AuthMode/index';
|
||||
|
||||
const TAB_CONFIG = [
|
||||
{ key: 'query', label: 'Query' },
|
||||
{ key: 'variables', label: 'Variables' },
|
||||
{ key: 'headers', label: 'Headers' },
|
||||
{ key: 'auth', label: 'Auth' },
|
||||
{ key: 'vars', label: 'Vars' },
|
||||
@@ -40,6 +46,16 @@ const GraphQLRequestPane = ({ item, collection, onSchemaLoad, toggleDocs, handle
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
|
||||
const requestPaneTab = focusedTab?.requestPaneTab;
|
||||
const showQueryBuilder = focusedTab?.queryBuilderOpen || false;
|
||||
const queryBuilderWidth = focusedTab?.queryBuilderWidth || 320;
|
||||
const variablesOpen = focusedTab?.variablesPaneOpen || false;
|
||||
const variablesHeight = focusedTab?.variablesPaneHeight || 150;
|
||||
const queryBuilderDraggingRef = useRef(false);
|
||||
const variablesDraggingRef = useRef(false);
|
||||
const queryBuilderContainerRef = useRef(null);
|
||||
const queryEditorRef = useRef(null);
|
||||
|
||||
const query = item.draft
|
||||
? get(item, 'draft.request.body.graphql.query', '')
|
||||
@@ -49,16 +65,70 @@ const GraphQLRequestPane = ({ item, collection, onSchemaLoad, toggleDocs, handle
|
||||
: get(item, 'request.body.graphql.variables', '');
|
||||
|
||||
const { displayedTheme } = useTheme();
|
||||
const [schema, setSchema] = useState(null);
|
||||
const schemaActionsRef = useRef(null);
|
||||
|
||||
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
|
||||
const requestPaneTab = focusedTab?.requestPaneTab;
|
||||
const url = item.draft ? get(item, 'draft.request.url', '') : get(item, 'request.url', '');
|
||||
const pathname = item.draft ? get(item, 'draft.pathname', '') : get(item, 'pathname', '');
|
||||
const uid = item.draft ? get(item, 'draft.uid', '') : get(item, 'uid', '');
|
||||
const environment = findEnvironmentInCollection(collection, collection.activeEnvironmentUid);
|
||||
const request = item.draft ? { ...item.draft.request, pathname, uid } : { ...item.request, pathname, uid };
|
||||
|
||||
const { schema, schemaSource, loadSchema, isLoading: isSchemaLoading, error: schemaError } = useGraphqlSchema(url, environment, request, collection);
|
||||
|
||||
const schemaActionsRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
onSchemaLoad(schema);
|
||||
}, [schema, onSchemaLoad]);
|
||||
|
||||
const toggleQueryBuilder = useCallback(() => {
|
||||
dispatch(updateQueryBuilderOpen({ uid: item.uid, queryBuilderOpen: !showQueryBuilder }));
|
||||
}, [dispatch, item.uid, showQueryBuilder]);
|
||||
|
||||
const variablesOpenRef = useRef(variablesOpen);
|
||||
variablesOpenRef.current = variablesOpen;
|
||||
|
||||
const handleMouseMove = useCallback((e) => {
|
||||
if (queryBuilderDraggingRef.current && queryBuilderContainerRef.current) {
|
||||
e.preventDefault();
|
||||
const containerRect = queryBuilderContainerRef.current.getBoundingClientRect();
|
||||
const newWidth = e.clientX - containerRect.left;
|
||||
const maxWidth = Math.min(600, containerRect.width * 0.5);
|
||||
dispatch(updateQueryBuilderWidth({ uid: item.uid, queryBuilderWidth: Math.max(200, Math.min(newWidth, maxWidth)) }));
|
||||
}
|
||||
if (variablesDraggingRef.current && queryBuilderContainerRef.current) {
|
||||
e.preventDefault();
|
||||
const containerRect = queryBuilderContainerRef.current.getBoundingClientRect();
|
||||
// Subtract the header height (~30px) from the drag calculation
|
||||
const newHeight = containerRect.bottom - e.clientY - 30;
|
||||
if (newHeight < 40) {
|
||||
dispatch(updateVariablesPaneOpen({ uid: item.uid, variablesPaneOpen: false }));
|
||||
} else {
|
||||
if (!variablesOpenRef.current) dispatch(updateVariablesPaneOpen({ uid: item.uid, variablesPaneOpen: true }));
|
||||
dispatch(updateVariablesPaneHeight({ uid: item.uid, variablesPaneHeight: Math.max(80, Math.min(newHeight, containerRect.height * 0.6)) }));
|
||||
}
|
||||
}
|
||||
}, [dispatch, item.uid]);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
queryBuilderDraggingRef.current = false;
|
||||
variablesDraggingRef.current = false;
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
}, [handleMouseMove]);
|
||||
|
||||
const startDrag = useCallback((ref) => {
|
||||
ref.current = true;
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
}, [handleMouseMove, handleMouseUp]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [handleMouseMove, handleMouseUp]);
|
||||
|
||||
const onQueryChange = useCallback(
|
||||
(value) => {
|
||||
dispatch(
|
||||
@@ -72,6 +142,19 @@ const GraphQLRequestPane = ({ item, collection, onSchemaLoad, toggleDocs, handle
|
||||
[dispatch, item.uid, collection.uid]
|
||||
);
|
||||
|
||||
const onVariablesChange = useCallback(
|
||||
(value) => {
|
||||
dispatch(
|
||||
updateRequestGraphqlVariables({
|
||||
variables: value,
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid
|
||||
})
|
||||
);
|
||||
},
|
||||
[dispatch, item.uid, collection.uid]
|
||||
);
|
||||
|
||||
const onRun = useCallback(
|
||||
() => dispatch(sendRequest(item, collection.uid)),
|
||||
[dispatch, item, collection.uid]
|
||||
@@ -91,25 +174,77 @@ const GraphQLRequestPane = ({ item, collection, onSchemaLoad, toggleDocs, handle
|
||||
|
||||
const allTabs = useMemo(() => TAB_CONFIG.map(({ key, label }) => ({ key, label })), []);
|
||||
|
||||
const handlePrettify = useCallback(() => {
|
||||
if (queryEditorRef.current?.beautifyRequestBody) {
|
||||
queryEditorRef.current.beautifyRequestBody();
|
||||
}
|
||||
if (variables) {
|
||||
try {
|
||||
const pretty = JSON.stringify(JSON.parse(variables), null, 2);
|
||||
if (pretty !== variables) {
|
||||
onVariablesChange(pretty);
|
||||
}
|
||||
} catch {
|
||||
// Variables JSON is invalid, skip prettifying
|
||||
}
|
||||
}
|
||||
}, [variables, onVariablesChange]);
|
||||
|
||||
const tabPanel = useMemo(() => {
|
||||
switch (requestPaneTab) {
|
||||
case 'query':
|
||||
return (
|
||||
<QueryEditor
|
||||
collection={collection}
|
||||
theme={displayedTheme}
|
||||
schema={schema}
|
||||
onSave={onSave}
|
||||
value={query}
|
||||
onRun={onRun}
|
||||
onEdit={onQueryChange}
|
||||
onClickReference={handleGqlClickReference}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
/>
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex-1 min-h-0">
|
||||
<QueryEditor
|
||||
ref={queryEditorRef}
|
||||
collection={collection}
|
||||
theme={displayedTheme}
|
||||
schema={schema}
|
||||
onSave={onSave}
|
||||
value={query}
|
||||
onRun={onRun}
|
||||
onEdit={onQueryChange}
|
||||
onClickReference={handleGqlClickReference}
|
||||
onPrettifyQuery={handlePrettify}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="variables-section"
|
||||
style={variablesOpen ? { height: `${variablesHeight}px`, minHeight: `${variablesHeight}px` } : {}}
|
||||
>
|
||||
<div
|
||||
className="variables-dragbar"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
startDrag(variablesDraggingRef);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="variables-header"
|
||||
onClick={() => dispatch(updateVariablesPaneOpen({ uid: item.uid, variablesPaneOpen: !variablesOpen }))}
|
||||
aria-expanded={variablesOpen}
|
||||
>
|
||||
<span className="variables-chevron">
|
||||
{variablesOpen ? (
|
||||
<IconChevronDown size={14} strokeWidth={2} />
|
||||
) : (
|
||||
<IconChevronRight size={14} strokeWidth={2} />
|
||||
)}
|
||||
</span>
|
||||
<span>Variables</span>
|
||||
</button>
|
||||
{variablesOpen && (
|
||||
<div className="flex-1 min-h-0 relative">
|
||||
<GraphQLVariables item={item} variables={variables} collection={collection} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case 'variables':
|
||||
return <GraphQLVariables item={item} variables={variables} collection={collection} />;
|
||||
case 'headers':
|
||||
return <RequestHeaders item={item} collection={collection} />;
|
||||
case 'auth':
|
||||
@@ -129,7 +264,30 @@ const GraphQLRequestPane = ({ item, collection, onSchemaLoad, toggleDocs, handle
|
||||
default:
|
||||
return <div className="mt-4">404 | Not found</div>;
|
||||
}
|
||||
}, [requestPaneTab, item, collection, displayedTheme, schema, onSave, query, onRun, onQueryChange, handleGqlClickReference, preferences, variables]);
|
||||
}, [requestPaneTab, item, collection, displayedTheme, schema, onSave, query, onRun, onQueryChange, handleGqlClickReference, handlePrettify, preferences, variables, variablesOpen, variablesHeight, dispatch]);
|
||||
|
||||
const queryMenuItems = useMemo(() => [
|
||||
{
|
||||
id: 'docs',
|
||||
label: 'Docs',
|
||||
leftSection: IconBook,
|
||||
onClick: toggleDocs
|
||||
},
|
||||
{
|
||||
id: 'schema-introspection',
|
||||
label: schema && schemaSource === 'introspection' ? 'Refresh from Introspection' : 'Load from Introspection',
|
||||
leftSection: schema && schemaSource === 'introspection' ? IconRefresh : IconDownload,
|
||||
onClick: () => loadSchema('introspection'),
|
||||
disabled: isSchemaLoading
|
||||
},
|
||||
{
|
||||
id: 'schema-file',
|
||||
label: 'Load from File',
|
||||
leftSection: IconFile,
|
||||
onClick: () => loadSchema('file'),
|
||||
disabled: isSchemaLoading
|
||||
}
|
||||
], [toggleDocs, schema, schemaSource, loadSchema, isSchemaLoading]);
|
||||
|
||||
if (!activeTabUid || !focusedTab?.uid || !requestPaneTab) {
|
||||
return <div className="pb-4 px-4">An error occurred!</div>;
|
||||
@@ -140,13 +298,29 @@ const GraphQLRequestPane = ({ item, collection, onSchemaLoad, toggleDocs, handle
|
||||
<AuthMode item={item} collection={collection} />
|
||||
</div>
|
||||
) : requestPaneTab === 'query' ? (
|
||||
<div ref={schemaActionsRef}>
|
||||
<GraphQLSchemaActions item={item} collection={collection} onSchemaLoad={setSchema} toggleDocs={toggleDocs} />
|
||||
<div ref={schemaActionsRef} className="flex items-center gap-2">
|
||||
<ActionIcon
|
||||
label="Prettify"
|
||||
onClick={handlePrettify}
|
||||
>
|
||||
<IconWand size={14} strokeWidth={1.5} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
label={showQueryBuilder ? 'Hide Query Builder' : 'Show Query Builder'}
|
||||
onClick={toggleQueryBuilder}
|
||||
>
|
||||
<IconSidebarToggle collapsed={!showQueryBuilder} size={16} strokeWidth={1.5} />
|
||||
</ActionIcon>
|
||||
<MenuDropdown items={queryMenuItems} placement="bottom-end">
|
||||
<ActionIcon label="More actions">
|
||||
<IconDots size={16} strokeWidth={1.5} />
|
||||
</ActionIcon>
|
||||
</MenuDropdown>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full relative">
|
||||
<StyledWrapper className="flex flex-col h-full relative">
|
||||
<ResponsiveTabs
|
||||
tabs={allTabs}
|
||||
activeTab={requestPaneTab}
|
||||
@@ -155,10 +329,33 @@ const GraphQLRequestPane = ({ item, collection, onSchemaLoad, toggleDocs, handle
|
||||
rightContentRef={rightContent ? schemaActionsRef : null}
|
||||
/>
|
||||
|
||||
<section className={classnames('flex w-full flex-1 mt-4')}>
|
||||
<HeightBoundContainer>{tabPanel}</HeightBoundContainer>
|
||||
<section ref={queryBuilderContainerRef} className={classnames('flex w-full flex-1 mt-4 min-h-0')}>
|
||||
{requestPaneTab === 'query' && showQueryBuilder && (
|
||||
<>
|
||||
<div className="graphql-query-builder-container" style={{ width: `${queryBuilderWidth}px`, minWidth: `${queryBuilderWidth}px` }}>
|
||||
<QueryBuilder
|
||||
schema={schema}
|
||||
onQueryChange={onQueryChange}
|
||||
editorValue={query}
|
||||
onVariablesChange={onVariablesChange}
|
||||
variablesValue={variables}
|
||||
loadSchema={loadSchema}
|
||||
isSchemaLoading={isSchemaLoading}
|
||||
schemaError={schemaError}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="query-builder-dragbar"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
startDrag(queryBuilderDraggingRef);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<HeightBoundContainer style={{ minWidth: 200 }}>{tabPanel}</HeightBoundContainer>
|
||||
</section>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -5,10 +5,6 @@ import CodeEditor from 'components/CodeEditor';
|
||||
import { updateRequestGraphqlVariables } from 'providers/ReduxStore/slices/collections';
|
||||
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { IconWand } from '@tabler/icons';
|
||||
import toast from 'react-hot-toast';
|
||||
import { prettifyJsonString } from 'utils/common/index';
|
||||
|
||||
const GraphQLVariables = ({ variables, item, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -16,24 +12,6 @@ const GraphQLVariables = ({ variables, item, collection }) => {
|
||||
const { displayedTheme } = useTheme();
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
|
||||
const onPrettify = () => {
|
||||
if (!variables) return;
|
||||
try {
|
||||
const prettyVariables = prettifyJsonString(variables);
|
||||
dispatch(
|
||||
updateRequestGraphqlVariables({
|
||||
variables: prettyVariables,
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid
|
||||
})
|
||||
);
|
||||
toast.success('Variables prettified');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Error occurred while prettifying GraphQL variables');
|
||||
}
|
||||
};
|
||||
|
||||
const onEdit = (value) => {
|
||||
dispatch(
|
||||
updateRequestGraphqlVariables({
|
||||
@@ -48,28 +26,19 @@ const GraphQLVariables = ({ variables, item, collection }) => {
|
||||
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className="btn-add-param text-link px-4 py-4 select-none absolute right-0 z-10"
|
||||
onClick={onPrettify}
|
||||
title="Prettify"
|
||||
>
|
||||
<IconWand size={20} strokeWidth={1.5} />
|
||||
</button>
|
||||
<CodeEditor
|
||||
collection={collection}
|
||||
value={variables || ''}
|
||||
theme={displayedTheme}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
onEdit={onEdit}
|
||||
mode="application/json"
|
||||
onRun={onRun}
|
||||
onSave={onSave}
|
||||
enableVariableHighlighting={true}
|
||||
showHintsFor={['variables']}
|
||||
/>
|
||||
</>
|
||||
<CodeEditor
|
||||
collection={collection}
|
||||
value={variables || ''}
|
||||
theme={displayedTheme}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
onEdit={onEdit}
|
||||
mode="application/json"
|
||||
onRun={onRun}
|
||||
onSave={onSave}
|
||||
enableVariableHighlighting={true}
|
||||
showHintsFor={['variables']}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ const Wrapper = styled.div`
|
||||
transition: color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => props.theme.colors.text.link};
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ const Wrapper = styled.div`
|
||||
}
|
||||
|
||||
.file-value-cell {
|
||||
padding: 4px 0;
|
||||
width: 100%;
|
||||
|
||||
.file-name {
|
||||
font-size: 12px;
|
||||
@@ -33,6 +33,8 @@ const Wrapper = styled.div`
|
||||
}
|
||||
|
||||
.value-cell {
|
||||
width: 100%;
|
||||
|
||||
.flex-1 {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { IconUpload, IconX, IconFile } from '@tabler/icons';
|
||||
import {
|
||||
@@ -11,6 +11,7 @@ import { browseFiles } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import MultiLineEditor from 'components/MultiLineEditor';
|
||||
import SingleLineEditor from 'components/SingleLineEditor';
|
||||
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
|
||||
import EditableTable from 'components/EditableTable';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import path from 'utils/common/path';
|
||||
@@ -19,8 +20,18 @@ import { isWindowsOS } from 'utils/common/platform';
|
||||
const MultipartFormParams = ({ item, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
|
||||
const params = item.draft ? get(item, 'draft.request.body.multipartForm') : get(item, 'request.body.multipartForm');
|
||||
|
||||
// Get column widths from Redux
|
||||
const focusedTab = tabs?.find((t) => t.uid === activeTabUid);
|
||||
const multipartFormWidths = focusedTab?.tableColumnWidths?.['multipart-form'] || {};
|
||||
|
||||
const handleColumnWidthsChange = (tableId, widths) => {
|
||||
dispatch(updateTableColumnWidths({ uid: activeTabUid, tableId, widths }));
|
||||
};
|
||||
|
||||
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
|
||||
const handleRun = () => dispatch(sendRequest(item, collection.uid));
|
||||
|
||||
@@ -54,12 +65,21 @@ const MultipartFormParams = ({ item, collection }) => {
|
||||
const currentParams = item.draft
|
||||
? get(item, 'draft.request.body.multipartForm')
|
||||
: get(item, 'request.body.multipartForm');
|
||||
const updatedParams = (currentParams || []).map((p) => {
|
||||
if (p.uid === row.uid) {
|
||||
return { ...p, type: 'file', value: processedPaths };
|
||||
}
|
||||
return p;
|
||||
});
|
||||
const existsInParams = (currentParams || []).some((p) => p.uid === row.uid);
|
||||
let updatedParams;
|
||||
if (existsInParams) {
|
||||
updatedParams = currentParams.map((p) => {
|
||||
if (p.uid === row.uid) {
|
||||
return { ...p, type: 'file', value: processedPaths };
|
||||
}
|
||||
return p;
|
||||
});
|
||||
} else {
|
||||
updatedParams = [
|
||||
...(currentParams || []),
|
||||
{ uid: row.uid, name: row.name || '', enabled: true, type: 'file', value: processedPaths, contentType: '' }
|
||||
];
|
||||
}
|
||||
handleParamsChange(updatedParams);
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -122,18 +142,22 @@ const MultipartFormParams = ({ item, collection }) => {
|
||||
name: 'Value',
|
||||
placeholder: 'Value',
|
||||
width: '35%',
|
||||
render: ({ row, value, onChange, isLastEmptyRow }) => {
|
||||
render: ({ row, value, onChange }) => {
|
||||
const isFile = row.type === 'file';
|
||||
const fileName = isFile ? getFileName(value) : null;
|
||||
const hasTextValue = !isFile && value && value.length > 0;
|
||||
|
||||
if (fileName) {
|
||||
return (
|
||||
<div className="flex items-center file-value-cell">
|
||||
<IconFile size={16} className="text-muted mr-1" />
|
||||
<span className="file-name flex-1 truncate" title={Array.isArray(value) ? value.join(', ') : value}>
|
||||
{fileName}
|
||||
</span>
|
||||
<div className="file-name flex-1 truncate" title={Array.isArray(value) ? value.join(', ') : value}>
|
||||
<SingleLineEditor
|
||||
theme={storedTheme}
|
||||
value={fileName}
|
||||
readOnly={true}
|
||||
collection={collection}
|
||||
item={item}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="clear-file-btn ml-1"
|
||||
onClick={() => handleClearFile(row)}
|
||||
@@ -160,15 +184,13 @@ const MultipartFormParams = ({ item, collection }) => {
|
||||
placeholder={!value ? 'Value' : ''}
|
||||
/>
|
||||
</div>
|
||||
{!hasTextValue && !isLastEmptyRow && (
|
||||
<button
|
||||
className="upload-btn ml-1"
|
||||
onClick={() => handleBrowseFiles(row, onChange)}
|
||||
title="Select file"
|
||||
>
|
||||
<IconUpload size={16} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="upload-btn ml-1"
|
||||
onClick={() => handleBrowseFiles(row, onChange)}
|
||||
title="Select file"
|
||||
>
|
||||
<IconUpload size={16} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -202,12 +224,15 @@ const MultipartFormParams = ({ item, collection }) => {
|
||||
return (
|
||||
<StyledWrapper className="w-full">
|
||||
<EditableTable
|
||||
tableId="multipart-form"
|
||||
columns={columns}
|
||||
rows={params || []}
|
||||
onChange={handleParamsChange}
|
||||
defaultRow={defaultRow}
|
||||
reorderable={true}
|
||||
onReorder={handleParamDrag}
|
||||
columnWidths={multipartFormWidths}
|
||||
onColumnWidthsChange={(widths) => handleColumnWidthsChange('multipart-form', widths)}
|
||||
/>
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import React from 'react';
|
||||
import { IconAlertTriangle } from '@tabler/icons';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import Button from 'ui/Button/index';
|
||||
|
||||
class QueryBuilderErrorBoundary extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
this.reset = this.reset.bind(this);
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error) {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error, errorInfo) {
|
||||
console.error('[QueryBuilder] Unexpected render error:', error, errorInfo);
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.setState({ hasError: false, error: null });
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="schema-empty-state">
|
||||
<IconAlertTriangle size={32} strokeWidth={1.5} className="empty-state-icon warning" />
|
||||
<div className="empty-state-title">Something went wrong</div>
|
||||
<div className="empty-state-description">
|
||||
The Query Builder encountered an unexpected error. Try reloading the schema or manually using the editor.
|
||||
</div>
|
||||
<Button color="secondary" onClick={this.reset}>
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default QueryBuilderErrorBoundary;
|
||||
@@ -0,0 +1,506 @@
|
||||
import React, { useCallback, useState, useMemo, useRef } from 'react';
|
||||
import { IconChevronRight, IconChevronDown, IconTrash, IconInfoCircle } from '@tabler/icons';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { getInputObjectFields } from 'utils/graphql/queryBuilder';
|
||||
|
||||
const ListArgValueInput = ({ values, onChange, field, indent }) => {
|
||||
const [items, setItems] = useState(() => {
|
||||
const vals = Array.isArray(values) ? values : (values ? [values] : []);
|
||||
const mapped = vals.map((v) => ({ id: nanoid(), value: v }));
|
||||
return [...mapped, { id: nanoid(), value: '' }];
|
||||
});
|
||||
const lastExternalRef = useRef(values);
|
||||
|
||||
// Sync internal items when values prop changes externally (e.g. editor edits)
|
||||
if (values !== lastExternalRef.current) {
|
||||
lastExternalRef.current = values;
|
||||
const vals = Array.isArray(values) ? values : (values ? [values] : []);
|
||||
const filledValues = items.filter((i) => i.value !== '').map((i) => i.value);
|
||||
if (JSON.stringify(vals) !== JSON.stringify(filledValues)) {
|
||||
const mapped = vals.map((v) => ({ id: nanoid(), value: v }));
|
||||
setItems([...mapped, { id: nanoid(), value: '' }]);
|
||||
}
|
||||
}
|
||||
|
||||
const handleItemChange = (id, newValue) => {
|
||||
let nextItems = items.map((item) => (item.id === id ? { ...item, value: newValue } : item));
|
||||
const lastItem = nextItems[nextItems.length - 1];
|
||||
if (lastItem && lastItem.value !== '') {
|
||||
nextItems = [...nextItems, { id: nanoid(), value: '' }];
|
||||
}
|
||||
setItems(nextItems);
|
||||
onChange(nextItems.filter((item) => item.value !== '').map((item) => item.value));
|
||||
};
|
||||
|
||||
const handleRemove = (id) => {
|
||||
const nextItems = items.filter((item) => item.id !== id);
|
||||
setItems(nextItems);
|
||||
onChange(nextItems.filter((item) => item.value !== '').map((item) => item.value));
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{items.map((item, index) => {
|
||||
const isEmptyRow = index === items.length - 1 && item.value === '';
|
||||
return (
|
||||
<div key={item.id} className="arg-row" style={{ paddingLeft: indent }} onClick={(e) => e.stopPropagation()}>
|
||||
<ArgValueInput value={item.value} onChange={(v) => handleItemChange(item.id, v)} field={field} />
|
||||
{isEmptyRow ? (
|
||||
<span className="list-arg-remove-spacer" />
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="list-arg-remove"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRemove(item.id);
|
||||
}}
|
||||
aria-label="Remove item"
|
||||
>
|
||||
<IconTrash size={13} strokeWidth={1.5} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ArgValueInput = ({ value, onChange, field }) => {
|
||||
if (field.isEnum && field.enumValues) {
|
||||
return (
|
||||
<select value={value} onChange={(e) => onChange(e.target.value)} onClick={(e) => e.stopPropagation()}>
|
||||
<option value="">Select option</option>
|
||||
{field.enumValues.map((v) => (
|
||||
<option key={v} value={v}>{v}</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
if (field.isBoolean) {
|
||||
return (
|
||||
<select value={value} onChange={(e) => onChange(e.target.value)} onClick={(e) => e.stopPropagation()}>
|
||||
<option value="">Select option</option>
|
||||
<option value="true">true</option>
|
||||
<option value="false">false</option>
|
||||
</select>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
placeholder="Enter value"
|
||||
className="mousetrap"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const InputObjectFields = ({ namedType, parentKey, fieldPath, indent, argValues, enabledArgs, onToggleInputField, onSetInputFieldValue }) => {
|
||||
const [expandedFields, setExpandedFields] = useState(new Set());
|
||||
const fields = useMemo(() => getInputObjectFields(namedType), [namedType]);
|
||||
|
||||
if (!fields || fields.length === 0) return null;
|
||||
|
||||
return fields.map((field) => {
|
||||
const fieldKey = `${parentKey}.${field.name}`;
|
||||
const isEnabled = enabledArgs ? enabledArgs.has(fieldKey) : false;
|
||||
const isExpanded = expandedFields.has(field.name);
|
||||
const value = argValues.get(fieldKey) ?? '';
|
||||
|
||||
const toggleExpand = (e) => {
|
||||
e.stopPropagation();
|
||||
setExpandedFields((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(field.name)) next.delete(field.name);
|
||||
else next.add(field.name);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const isListOfInputObject = field.isList && field.isInputObject;
|
||||
const isExpandable = field.isInputObject && !isListOfInputObject;
|
||||
|
||||
return (
|
||||
<React.Fragment key={field.name}>
|
||||
<div className="arg-row" style={{ paddingLeft: indent }} onClick={isExpandable ? toggleExpand : (e) => e.stopPropagation()}>
|
||||
{isExpandable ? (
|
||||
<button type="button" className="field-chevron input-object-chevron" onClick={toggleExpand} aria-label={isExpanded ? 'Collapse' : 'Expand'}>
|
||||
{isExpanded ? (
|
||||
<IconChevronDown size={12} strokeWidth={2} />
|
||||
) : (
|
||||
<IconChevronRight size={12} strokeWidth={2} />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<span className="input-object-chevron-spacer" />
|
||||
)}
|
||||
<input
|
||||
type="checkbox"
|
||||
className="field-checkbox mousetrap"
|
||||
checked={isEnabled}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
const willEnable = !isEnabled;
|
||||
onToggleInputField(fieldKey, fieldPath);
|
||||
if (isExpandable && willEnable) {
|
||||
setExpandedFields((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(field.name);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<span className="arg-name">{field.name}</span>
|
||||
{field.isRequired && <span className="arg-required">!</span>}
|
||||
{(!isEnabled || field.isInputObject) && <span className="field-type">{field.typeLabel}</span>}
|
||||
{isListOfInputObject && (
|
||||
<span className="list-complex-unsupported" title="List arguments for complex types are not currently supported.">
|
||||
<IconInfoCircle size={13} strokeWidth={1.5} />
|
||||
</span>
|
||||
)}
|
||||
{!field.isInputObject && isEnabled && (
|
||||
<ArgValueInput value={value} onChange={(v) => onSetInputFieldValue(fieldKey, v)} field={field} />
|
||||
)}
|
||||
</div>
|
||||
{isExpandable && isExpanded && (
|
||||
<InputObjectFields
|
||||
namedType={field.namedType}
|
||||
parentKey={fieldKey}
|
||||
fieldPath={fieldPath}
|
||||
indent={indent + 20}
|
||||
argValues={argValues}
|
||||
enabledArgs={enabledArgs}
|
||||
onToggleInputField={onToggleInputField}
|
||||
onSetInputFieldValue={onSetInputFieldValue}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const FieldNode = ({
|
||||
field,
|
||||
depth,
|
||||
isChecked,
|
||||
isExpanded,
|
||||
onToggleCheck,
|
||||
onToggleExpand,
|
||||
argValues,
|
||||
enabledArgs,
|
||||
onToggleArg,
|
||||
onArgChange,
|
||||
onToggleInputField,
|
||||
onSetInputFieldValue,
|
||||
hasChildren
|
||||
}) => {
|
||||
const indent = depth * 20;
|
||||
|
||||
const handleCheck = useCallback(
|
||||
(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleCheck(field.path, field);
|
||||
},
|
||||
[field, onToggleCheck]
|
||||
);
|
||||
|
||||
const hasArgs = field.args && field.args.length > 0;
|
||||
const canExpand = !field.isLeaf || hasArgs;
|
||||
|
||||
const handleExpand = useCallback(
|
||||
(e) => {
|
||||
e.stopPropagation();
|
||||
if (canExpand) {
|
||||
onToggleExpand(field.path);
|
||||
}
|
||||
},
|
||||
[field.path, canExpand, onToggleExpand]
|
||||
);
|
||||
|
||||
// Union member type row (e.g. "... on Human")
|
||||
if (field.isUnionMember) {
|
||||
return (
|
||||
<div
|
||||
className="field-node"
|
||||
role="treeitem"
|
||||
aria-expanded={isExpanded}
|
||||
onClick={handleExpand}
|
||||
tabIndex={0}
|
||||
>
|
||||
<span className="field-indent" style={{ width: indent }} />
|
||||
<span className="field-chevron">
|
||||
{isExpanded ? (
|
||||
<IconChevronDown size={14} strokeWidth={2} />
|
||||
) : (
|
||||
<IconChevronRight size={14} strokeWidth={2} />
|
||||
)}
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="field-checkbox mousetrap"
|
||||
checked={isChecked}
|
||||
onChange={handleCheck}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<span className="union-label">... on {field.name}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const showSections = isExpanded && (hasArgs || hasChildren);
|
||||
const sectionIndent = (depth + 1) * 20;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="field-node"
|
||||
role="treeitem"
|
||||
aria-expanded={canExpand ? isExpanded : undefined}
|
||||
onClick={handleExpand}
|
||||
tabIndex={0}
|
||||
>
|
||||
<span className="field-indent" style={{ width: indent }} />
|
||||
<span className="field-chevron">
|
||||
{canExpand ? (
|
||||
isExpanded ? (
|
||||
<IconChevronDown size={14} strokeWidth={2} />
|
||||
) : (
|
||||
<IconChevronRight size={14} strokeWidth={2} />
|
||||
)
|
||||
) : null}
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="field-checkbox mousetrap"
|
||||
checked={isChecked}
|
||||
onChange={handleCheck}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<span className="field-name">{field.name}</span>
|
||||
<span className="field-separator">:</span>
|
||||
<span className="field-type">{field.typeLabel}</span>
|
||||
</div>
|
||||
|
||||
{showSections && hasArgs && (
|
||||
<>
|
||||
<div className="section-header" style={{ paddingLeft: sectionIndent }}>
|
||||
ARGUMENTS
|
||||
</div>
|
||||
{field.args.map((arg) => {
|
||||
const argKey = `${field.path}.${arg.name}`;
|
||||
const isArgEnabled = enabledArgs ? enabledArgs.has(argKey) : false;
|
||||
const argValue = argValues.get(argKey) ?? '';
|
||||
|
||||
// List of input objects: show unsupported message
|
||||
if (arg.isList && arg.isInputObject) {
|
||||
return (
|
||||
<div key={arg.name} className="arg-row" style={{ paddingLeft: sectionIndent + 8 }} onClick={(e) => e.stopPropagation()}>
|
||||
<span className="input-object-chevron-spacer" />
|
||||
<input
|
||||
type="checkbox"
|
||||
className="field-checkbox mousetrap"
|
||||
checked={isArgEnabled}
|
||||
onChange={() => onToggleArg && onToggleArg(field.path, arg.name)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<span className="arg-name">{arg.name}</span>
|
||||
{arg.isRequired && <span className="arg-required">!</span>}
|
||||
<span className="field-type">{arg.typeLabel}</span>
|
||||
<span className="list-complex-unsupported" title="List arguments for complex types are not currently supported.">
|
||||
<IconInfoCircle size={13} strokeWidth={1.5} />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Input object arg: render as expandable with children
|
||||
if (arg.isInputObject) {
|
||||
return (
|
||||
<InputObjectArgRow
|
||||
key={arg.name}
|
||||
arg={arg}
|
||||
argKey={argKey}
|
||||
fieldPath={field.path}
|
||||
isArgEnabled={isArgEnabled}
|
||||
sectionIndent={sectionIndent}
|
||||
argValues={argValues}
|
||||
enabledArgs={enabledArgs}
|
||||
onToggleArg={onToggleArg}
|
||||
onToggleInputField={onToggleInputField}
|
||||
onSetInputFieldValue={onSetInputFieldValue}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (arg.isList && !arg.isInputObject) {
|
||||
return (
|
||||
<ListArgRow
|
||||
key={arg.name}
|
||||
arg={arg}
|
||||
fieldPath={field.path}
|
||||
isArgEnabled={isArgEnabled}
|
||||
argValue={argValue}
|
||||
sectionIndent={sectionIndent}
|
||||
onToggleArg={onToggleArg}
|
||||
onArgChange={onArgChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={arg.name} className="arg-row" style={{ paddingLeft: sectionIndent + 8 }} onClick={(e) => e.stopPropagation()}>
|
||||
<span className="input-object-chevron-spacer" />
|
||||
<input
|
||||
type="checkbox"
|
||||
className="field-checkbox mousetrap"
|
||||
checked={isArgEnabled}
|
||||
onChange={() => onToggleArg && onToggleArg(field.path, arg.name)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<span className="arg-name">{arg.name}</span>
|
||||
{arg.isRequired && <span className="arg-required">!</span>}
|
||||
{!isArgEnabled && <span className="field-type">{arg.typeLabel}</span>}
|
||||
{isArgEnabled && (
|
||||
<ArgValueInput value={argValue} onChange={(v) => onArgChange(field.path, arg.name, v)} field={arg} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
{showSections && hasChildren && hasArgs && (
|
||||
<div className="section-header" style={{ paddingLeft: sectionIndent }}>
|
||||
FIELDS
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const InputObjectArgRow = ({ arg, argKey, fieldPath, isArgEnabled, sectionIndent, argValues, enabledArgs, onToggleArg, onToggleInputField, onSetInputFieldValue }) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const toggleExpand = (e) => {
|
||||
e.stopPropagation();
|
||||
setIsExpanded((prev) => !prev);
|
||||
};
|
||||
|
||||
const handleCheck = (e) => {
|
||||
e.stopPropagation();
|
||||
const willEnable = !isArgEnabled;
|
||||
onToggleArg && onToggleArg(fieldPath, arg.name);
|
||||
// Auto-expand when checking only
|
||||
if (willEnable) {
|
||||
setIsExpanded(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="arg-row"
|
||||
style={{ paddingLeft: sectionIndent + 8 }}
|
||||
onClick={toggleExpand}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
aria-expanded={isExpanded}
|
||||
>
|
||||
<span className="field-chevron input-object-chevron">
|
||||
{isExpanded ? (
|
||||
<IconChevronDown size={12} strokeWidth={2} />
|
||||
) : (
|
||||
<IconChevronRight size={12} strokeWidth={2} />
|
||||
)}
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="field-checkbox mousetrap"
|
||||
checked={isArgEnabled}
|
||||
onChange={handleCheck}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<span className="arg-name">{arg.name}</span>
|
||||
{arg.isRequired && <span className="arg-required">!</span>}
|
||||
<span className="field-type">{arg.typeLabel}</span>
|
||||
</div>
|
||||
{isExpanded && arg.namedType && (
|
||||
<InputObjectFields
|
||||
namedType={arg.namedType}
|
||||
parentKey={argKey}
|
||||
fieldPath={fieldPath}
|
||||
indent={sectionIndent + 28}
|
||||
argValues={argValues}
|
||||
enabledArgs={enabledArgs}
|
||||
onToggleInputField={onToggleInputField}
|
||||
onSetInputFieldValue={onSetInputFieldValue}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ListArgRow = ({ arg, fieldPath, isArgEnabled, argValue, sectionIndent, onToggleArg, onArgChange }) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const toggleExpand = (e) => {
|
||||
e.stopPropagation();
|
||||
setIsExpanded((prev) => !prev);
|
||||
};
|
||||
|
||||
const handleCheck = (e) => {
|
||||
e.stopPropagation();
|
||||
const willEnable = !isArgEnabled;
|
||||
onToggleArg && onToggleArg(fieldPath, arg.name);
|
||||
if (willEnable) {
|
||||
setIsExpanded(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="arg-row"
|
||||
style={{ paddingLeft: sectionIndent + 8 }}
|
||||
onClick={toggleExpand}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
aria-expanded={isExpanded}
|
||||
>
|
||||
<span className="field-chevron input-object-chevron">
|
||||
{isExpanded ? (
|
||||
<IconChevronDown size={12} strokeWidth={2} />
|
||||
) : (
|
||||
<IconChevronRight size={12} strokeWidth={2} />
|
||||
)}
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="field-checkbox mousetrap"
|
||||
checked={isArgEnabled}
|
||||
onChange={handleCheck}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<span className="arg-name">{arg.name}</span>
|
||||
{arg.isRequired && <span className="arg-required">!</span>}
|
||||
<span className="field-type">{arg.typeLabel}</span>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<ListArgValueInput
|
||||
values={argValue}
|
||||
onChange={(v) => onArgChange(fieldPath, arg.name, v)}
|
||||
field={arg}
|
||||
indent={sectionIndent + 28}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(FieldNode);
|
||||
@@ -0,0 +1,56 @@
|
||||
import React, { useMemo, memo } from 'react';
|
||||
import { getNamedType } from 'graphql';
|
||||
import FieldNode from './FieldNode';
|
||||
import { getFieldChildren } from 'utils/graphql/queryBuilder';
|
||||
|
||||
const QueryBuilderTree = ({ fields, unionTypes, ...treeProps }) => {
|
||||
return (
|
||||
<>
|
||||
{unionTypes && unionTypes.map((ut) => (
|
||||
<TreeNode key={ut.path} field={ut} isUnion {...treeProps} />
|
||||
))}
|
||||
|
||||
{(fields || []).map((field) => (
|
||||
<TreeNode key={field.path} field={field} {...treeProps} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const TreeNode = memo(({ field, isUnion = false, depth, selections, expandedPaths, ...restProps }) => {
|
||||
const isChecked = selections.has(field.path);
|
||||
const isExpanded = expandedPaths.has(field.path);
|
||||
const namedType = isUnion ? field.namedType : getNamedType(field.type);
|
||||
|
||||
const children = useMemo(() => {
|
||||
if (isUnion ? !isExpanded : (field.isLeaf || !isExpanded)) return null;
|
||||
return getFieldChildren(namedType, field.path);
|
||||
}, [isUnion, field.isLeaf, isExpanded, namedType, field.path]);
|
||||
|
||||
const hasChildren = !!(children && (children.fields?.length > 0 || children.unionTypes?.length > 0));
|
||||
|
||||
return (
|
||||
<>
|
||||
<FieldNode
|
||||
field={field}
|
||||
depth={depth}
|
||||
isChecked={isChecked}
|
||||
isExpanded={isExpanded}
|
||||
hasChildren={hasChildren}
|
||||
{...restProps}
|
||||
/>
|
||||
{isExpanded && children && (
|
||||
<QueryBuilderTree
|
||||
fields={children.fields || []}
|
||||
unionTypes={children.unionTypes}
|
||||
depth={depth + 1}
|
||||
selections={selections}
|
||||
expandedPaths={expandedPaths}
|
||||
{...restProps}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default QueryBuilderTree;
|
||||
@@ -0,0 +1,388 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
color: ${(props) => props.theme.text};
|
||||
outline: none;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.query-builder-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 8px;
|
||||
flex-shrink: 0;
|
||||
gap: 6px;
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
border-radius: 4px;
|
||||
background: ${(props) => props.theme.input.bg};
|
||||
color: ${(props) => props.theme.text};
|
||||
font-size: 12px;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: ${(props) => props.theme.input.focusBorder};
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.sync-error-banner {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
margin: 4px 8px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid ${(props) => props.theme.colors.text.danger}30;
|
||||
background: ${(props) => props.theme.colors.text.danger}08;
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
|
||||
.sync-error-icon {
|
||||
color: ${(props) => props.theme.colors.text.danger};
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.sync-error-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
|
||||
strong {
|
||||
color: ${(props) => props.theme.text};
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
code {
|
||||
background: ${(props) => props.theme.background.surface0};
|
||||
padding: 0px 3px;
|
||||
border-radius: 2px;
|
||||
font-size: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.query-builder-tree {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: auto;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.root-type-disabled {
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.root-type-node {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
text-align: left;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background: ${(props) => props.theme.background.surface0};
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background: none;
|
||||
}
|
||||
}
|
||||
|
||||
.root-type-name {
|
||||
font-weight: 600;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
.root-type-count {
|
||||
margin-left: auto;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.field-chevron {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.5;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.field-node {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 4px 8px 4px 4px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
width: fit-content;
|
||||
min-width: 100%;
|
||||
outline: none;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background: ${(props) => props.theme.background.surface0};
|
||||
}
|
||||
|
||||
.field-indent {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.field-checkbox {
|
||||
margin: 0 6px 0 0;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
accent-color: ${(props) => props.theme.colors.accent};
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.field-name {
|
||||
color: ${(props) => props.theme.text};
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.field-separator {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
margin: 0 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.field-type {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.union-label {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.section-header {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
padding: 6px 8px 4px;
|
||||
letter-spacing: 0.5px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.arg-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 3px 8px;
|
||||
font-size: 13px;
|
||||
min-width: 0;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background: ${(props) => props.theme.background.surface0};
|
||||
}
|
||||
|
||||
.input-object-chevron {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.5;
|
||||
margin-right: 2px;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.input-object-chevron-spacer {
|
||||
width: 14px;
|
||||
flex-shrink: 0;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.field-type {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.field-checkbox {
|
||||
margin: 0 6px 0 0;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
accent-color: ${(props) => props.theme.colors.accent};
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.arg-name {
|
||||
color: ${(props) => props.theme.text};
|
||||
flex-shrink: 0;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.arg-required {
|
||||
color: ${(props) => props.theme.colors.text.danger};
|
||||
font-weight: 700;
|
||||
margin-right: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
input:not(.field-checkbox), select {
|
||||
padding: 3px 8px;
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
border-radius: 4px;
|
||||
background: ${(props) => props.theme.input.bg};
|
||||
color: ${(props) => props.theme.text};
|
||||
font-size: 12px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
cursor: text;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: ${(props) => props.theme.input.focusBorder};
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.list-complex-unsupported {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
margin-left: 8px;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.list-arg-remove,
|
||||
.list-arg-remove-spacer {
|
||||
width: 17px;
|
||||
flex-shrink: 0;
|
||||
margin-left: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.list-arg-remove {
|
||||
cursor: pointer;
|
||||
opacity: 0.4;
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
color: ${(props) => props.theme.colors.text.danger};
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.schema-empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 24px 20px;
|
||||
text-align: center;
|
||||
gap: 12px;
|
||||
|
||||
.empty-state-icon {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
opacity: 0.6;
|
||||
|
||||
&.warning {
|
||||
color: ${(props) => props.theme.colors.text.danger};
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
.empty-state-description {
|
||||
font-size: 12px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
line-height: 1.5;
|
||||
max-width: 240px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.empty-state-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
max-width: 240px;
|
||||
|
||||
button {
|
||||
border-color: ${(props) => props.theme.border.border1};
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,239 @@
|
||||
import React, { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { IconCloudDownload, IconFileUpload, IconAlertTriangle, IconChevronRight, IconChevronDown } from '@tabler/icons';
|
||||
import { getRootFields } from 'utils/graphql/queryBuilder';
|
||||
import useQueryBuilder from 'hooks/useQueryBuilder';
|
||||
import QueryBuilderTree from './QueryBuilderTree';
|
||||
import ErrorBoundary from './ErrorBoundary';
|
||||
import Button from 'ui/Button';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const QueryBuilder = ({ schema, onQueryChange, editorValue, onVariablesChange, variablesValue, loadSchema, isSchemaLoading, schemaError }) => {
|
||||
const {
|
||||
selections,
|
||||
expandedPaths,
|
||||
argValues,
|
||||
enabledArgs,
|
||||
availableRootTypes,
|
||||
syncError,
|
||||
toggleField,
|
||||
toggleExpand,
|
||||
toggleArg,
|
||||
setArgValue,
|
||||
toggleInputField,
|
||||
setInputFieldValue
|
||||
} = useQueryBuilder(schema, onQueryChange, editorValue, onVariablesChange, variablesValue);
|
||||
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [expandedRootTypes, setExpandedRootTypes] = useState(() => new Set(availableRootTypes));
|
||||
|
||||
useEffect(() => {
|
||||
if (schema) {
|
||||
setExpandedRootTypes(new Set(availableRootTypes));
|
||||
}
|
||||
}, [schema]);
|
||||
|
||||
const effectiveExpandedRootTypes = useMemo(() => {
|
||||
if (searchText.trim()) return new Set(availableRootTypes);
|
||||
return expandedRootTypes;
|
||||
}, [searchText, expandedRootTypes, availableRootTypes]);
|
||||
|
||||
const toggleRootType = useCallback((type) => {
|
||||
setExpandedRootTypes((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(type)) {
|
||||
next.delete(type);
|
||||
} else {
|
||||
next.add(type);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const rootFieldsByType = useMemo(() => {
|
||||
const map = {};
|
||||
for (const type of availableRootTypes) {
|
||||
map[type] = getRootFields(schema, type);
|
||||
}
|
||||
return map;
|
||||
}, [schema, availableRootTypes]);
|
||||
|
||||
// Determine which root type is active (has selections) — only one allowed at a time
|
||||
const activeRootType = useMemo(() => {
|
||||
for (const type of availableRootTypes) {
|
||||
for (const path of selections) {
|
||||
if (path.startsWith(type + '.')) return type;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, [selections, availableRootTypes]);
|
||||
|
||||
// Filter fields by search text
|
||||
const filteredFieldsByType = useMemo(() => {
|
||||
if (!searchText.trim()) return rootFieldsByType;
|
||||
const lower = searchText.toLowerCase();
|
||||
const map = {};
|
||||
for (const type of availableRootTypes) {
|
||||
map[type] = (rootFieldsByType[type] || []).filter((f) =>
|
||||
f.name.toLowerCase().includes(lower)
|
||||
);
|
||||
}
|
||||
return map;
|
||||
}, [rootFieldsByType, searchText, availableRootTypes]);
|
||||
|
||||
if (!schema) {
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="schema-empty-state">
|
||||
{schemaError ? (
|
||||
<>
|
||||
<IconAlertTriangle size={32} strokeWidth={1.5} className="empty-state-icon warning" />
|
||||
<div className="empty-state-title">Failed to Load Schema</div>
|
||||
<div className="empty-state-description">{schemaError.message}</div>
|
||||
<div className="empty-state-actions">
|
||||
<Button
|
||||
variant="outline"
|
||||
color="secondary"
|
||||
fullWidth
|
||||
icon={<IconCloudDownload size={16} strokeWidth={1.5} />}
|
||||
loading={isSchemaLoading}
|
||||
disabled={isSchemaLoading}
|
||||
onClick={() => loadSchema('introspection')}
|
||||
>
|
||||
Try Again
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
color="secondary"
|
||||
fullWidth
|
||||
icon={<IconFileUpload size={16} strokeWidth={1.5} />}
|
||||
disabled={isSchemaLoading}
|
||||
onClick={() => loadSchema('file')}
|
||||
>
|
||||
Upload Schema File
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="empty-state-title">No Schema Loaded</div>
|
||||
<div className="empty-state-description">
|
||||
Load a GraphQL schema to explore operations and build queries visually.
|
||||
</div>
|
||||
<div className="empty-state-actions">
|
||||
<Button
|
||||
variant="outline"
|
||||
color="secondary"
|
||||
fullWidth
|
||||
icon={<IconCloudDownload size={16} strokeWidth={1.5} />}
|
||||
loading={isSchemaLoading}
|
||||
disabled={isSchemaLoading}
|
||||
onClick={() => loadSchema('introspection')}
|
||||
>
|
||||
Load from Introspection
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
color="secondary"
|
||||
fullWidth
|
||||
icon={<IconFileUpload size={16} strokeWidth={1.5} />}
|
||||
disabled={isSchemaLoading}
|
||||
onClick={() => loadSchema('file')}
|
||||
>
|
||||
Upload Schema File
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
if (syncError) {
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="sync-error-banner">
|
||||
<IconAlertTriangle size={13} strokeWidth={1.5} className="sync-error-icon" />
|
||||
<div className="sync-error-text">
|
||||
{syncError === 'multiple_operations' ? (
|
||||
<>
|
||||
<strong>Multiple operations detected</strong>
|
||||
<span>The Query Builder supports a single operation at a time. Combine into one operation to sync.</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<StyledWrapper>
|
||||
<div className="query-builder-search">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search operations..."
|
||||
value={searchText}
|
||||
className="mousetrap"
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="query-builder-tree">
|
||||
{availableRootTypes.map((rootType) => {
|
||||
const isExpanded = effectiveExpandedRootTypes.has(rootType);
|
||||
const fields = filteredFieldsByType[rootType] || [];
|
||||
const isDisabled = activeRootType !== null && activeRootType !== rootType;
|
||||
|
||||
return (
|
||||
<div key={rootType} className={isDisabled ? 'root-type-disabled' : ''}>
|
||||
<button
|
||||
type="button"
|
||||
className="root-type-node"
|
||||
onClick={() => !isDisabled && toggleRootType(rootType)}
|
||||
aria-expanded={isExpanded}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<span className="field-chevron">
|
||||
{isExpanded && !isDisabled ? (
|
||||
<IconChevronDown size={14} strokeWidth={2} />
|
||||
) : (
|
||||
<IconChevronRight size={14} strokeWidth={2} />
|
||||
)}
|
||||
</span>
|
||||
<span className="root-type-name">{rootType}</span>
|
||||
<span className="root-type-count">{(rootFieldsByType[rootType] || []).length}</span>
|
||||
</button>
|
||||
{isExpanded && !isDisabled && (
|
||||
fields.length > 0 ? (
|
||||
<QueryBuilderTree
|
||||
fields={fields}
|
||||
depth={1}
|
||||
selections={selections}
|
||||
expandedPaths={expandedPaths}
|
||||
argValues={argValues}
|
||||
enabledArgs={enabledArgs}
|
||||
onToggleCheck={toggleField}
|
||||
onToggleExpand={toggleExpand}
|
||||
onToggleArg={toggleArg}
|
||||
onArgChange={setArgValue}
|
||||
onToggleInputField={toggleInputField}
|
||||
onSetInputFieldValue={setInputFieldValue}
|
||||
/>
|
||||
) : (
|
||||
<div className="empty-state">
|
||||
{searchText ? 'No matching fields.' : 'No fields available.'}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
export default QueryBuilder;
|
||||
@@ -67,6 +67,17 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
|
||||
|
||||
.CodeMirror-matchingbracket {
|
||||
background: ${(props) => props.theme.status.success.background} !important;
|
||||
text-decoration: unset;
|
||||
}
|
||||
|
||||
.CodeMirror-nonmatchingbracket {
|
||||
color: ${(props) => props.theme.colors.text.danger} !important;
|
||||
background: ${(props) => props.theme.status.danger.background} !important;
|
||||
text-decoration: unset;
|
||||
}
|
||||
|
||||
.CodeMirror-search-hint {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
@@ -11,11 +11,9 @@ import MD from 'markdown-it';
|
||||
import { format } from 'prettier/standalone';
|
||||
import prettierPluginGraphql from 'prettier/parser-graphql';
|
||||
import { getAllVariables } from 'utils/collections';
|
||||
import { defineCodeMirrorBrunoVariablesMode } from 'utils/common/codemirror';
|
||||
import { PLACEHOLDER } from 'utils/graphql/queryBuilder';
|
||||
import toast from 'react-hot-toast';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { IconWand } from '@tabler/icons';
|
||||
|
||||
import onHasCompletion from './onHasCompletion';
|
||||
import { setupLinkAware } from 'utils/codemirror/linkAware';
|
||||
|
||||
@@ -105,16 +103,6 @@ export default class QueryEditor extends React.Component {
|
||||
'Alt-Space': () => editor.showHint({ completeSingle: true, container: this._node }),
|
||||
'Shift-Space': () => editor.showHint({ completeSingle: true, container: this._node }),
|
||||
'Shift-Alt-Space': () => editor.showHint({ completeSingle: true, container: this._node }),
|
||||
'Cmd-Enter': () => {
|
||||
if (this.props.onRun) {
|
||||
this.props.onRun();
|
||||
}
|
||||
},
|
||||
'Ctrl-Enter': () => {
|
||||
if (this.props.onRun) {
|
||||
this.props.onRun();
|
||||
}
|
||||
},
|
||||
'Shift-Ctrl-C': () => {
|
||||
if (this.props.onCopyQuery) {
|
||||
this.props.onCopyQuery();
|
||||
@@ -136,18 +124,6 @@ export default class QueryEditor extends React.Component {
|
||||
this.props.onMergeQuery();
|
||||
}
|
||||
},
|
||||
'Cmd-S': () => {
|
||||
if (this.props.onSave) {
|
||||
this.props.onSave();
|
||||
return false;
|
||||
}
|
||||
},
|
||||
'Ctrl-S': () => {
|
||||
if (this.props.onSave) {
|
||||
this.props.onSave();
|
||||
return false;
|
||||
}
|
||||
},
|
||||
'Cmd-F': 'findPersistent',
|
||||
'Ctrl-F': 'findPersistent'
|
||||
}
|
||||
@@ -161,6 +137,12 @@ export default class QueryEditor extends React.Component {
|
||||
this.addOverlay();
|
||||
|
||||
setupLinkAware(editor);
|
||||
|
||||
// Add mousetrap class so Mousetrap captures shortcuts even when CodeMirror is focused
|
||||
const cmInput = editor.getInputField();
|
||||
if (cmInput) {
|
||||
cmInput.classList.add('mousetrap');
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
@@ -176,15 +158,10 @@ export default class QueryEditor extends React.Component {
|
||||
CodeMirror.signal(this.editor, 'change', this.editor);
|
||||
}
|
||||
if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue && this.editor) {
|
||||
// TODO: temporary fix for keeping cursor state when auto save and new line insertion collide PR#7098
|
||||
const nextValue = this.props.value ?? '';
|
||||
const currentValue = this.editor.getValue();
|
||||
if (this.editor.hasFocus?.() && currentValue !== nextValue) {
|
||||
this.cachedValue = currentValue;
|
||||
} else {
|
||||
this.cachedValue = nextValue;
|
||||
this.editor.setValue(nextValue);
|
||||
}
|
||||
const cursor = this.editor.getCursor();
|
||||
this.cachedValue = String(this.props.value);
|
||||
this.editor.setValue(String(this.props.value) || '');
|
||||
this.editor.setCursor(cursor);
|
||||
}
|
||||
|
||||
if (this.props.theme !== prevProps.theme && this.editor) {
|
||||
@@ -206,16 +183,33 @@ export default class QueryEditor extends React.Component {
|
||||
this.editor.off('change', this._onEdit);
|
||||
this.editor.off('keyup', this._onKeyUp);
|
||||
this.editor.off('hasCompletion', this._onHasCompletion);
|
||||
this.editor.off('beforeChange', this._onBeforeChange);
|
||||
// Remove the CodeMirror DOM element so React 18 Strict Mode's
|
||||
// unmount-remount cycle doesn't leave an orphaned instance behind.
|
||||
const wrapper = this.editor.getWrapperElement();
|
||||
if (wrapper && wrapper.parentNode) {
|
||||
wrapper.parentNode.removeChild(wrapper);
|
||||
}
|
||||
this.editor = null;
|
||||
}
|
||||
}
|
||||
|
||||
beautifyRequestBody = () => {
|
||||
try {
|
||||
const prettyQuery = format(this.props.value, {
|
||||
if (!this.editor) return;
|
||||
const currentValue = this.editor.getValue();
|
||||
if (!currentValue || !currentValue.trim()) return;
|
||||
|
||||
// Temporarily fill empty selection sets so prettier can parse the query
|
||||
// First preserve empty input objects (e.g. input: {}), then fill empty selection sets
|
||||
let sanitized = currentValue.replace(/(:\s*)\{\s*\}/g, '$1{ __empty: true }');
|
||||
sanitized = sanitized.replace(/\{\s*\}/g, `{ ${PLACEHOLDER} }`);
|
||||
let prettyQuery = format(sanitized, {
|
||||
parser: 'graphql',
|
||||
plugins: [prettierPluginGraphql]
|
||||
});
|
||||
prettyQuery = prettyQuery.replace(new RegExp(`^\\s*${PLACEHOLDER}\\n`, 'gm'), '');
|
||||
prettyQuery = prettyQuery.replace(/\{\s*__empty:\s*true\s*\}/g, '{}');
|
||||
|
||||
this.editor.setValue(prettyQuery);
|
||||
toast.success('Query prettified');
|
||||
@@ -235,25 +229,15 @@ export default class QueryEditor extends React.Component {
|
||||
|
||||
render() {
|
||||
return (
|
||||
<>
|
||||
<StyledWrapper
|
||||
className="h-full w-full flex flex-col relative graphiql-container"
|
||||
aria-label="Query Editor"
|
||||
font={this.props.font}
|
||||
fontSize={this.props.fontSize}
|
||||
ref={(node) => {
|
||||
this._node = node;
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className="btn-add-param text-link px-4 py-4 select-none absolute top-0 right-0 z-10"
|
||||
onClick={this.beautifyRequestBody}
|
||||
title="prettify"
|
||||
>
|
||||
<IconWand size={20} strokeWidth={1.5} />
|
||||
</button>
|
||||
</StyledWrapper>
|
||||
</>
|
||||
<StyledWrapper
|
||||
className="h-full w-full flex flex-col relative graphiql-container"
|
||||
aria-label="Query Editor"
|
||||
font={this.props.font}
|
||||
fontSize={this.props.fontSize}
|
||||
ref={(node) => {
|
||||
this._node = node;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import InfoTip from 'components/InfoTip';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import {
|
||||
moveQueryParam,
|
||||
updatePathParam,
|
||||
setQueryParams
|
||||
} from 'providers/ReduxStore/slices/collections';
|
||||
import MultiLineEditor from 'components/MultiLineEditor';
|
||||
import { saveRequest, sendRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
|
||||
import MultiLineEditor from 'components/MultiLineEditor';
|
||||
import EditableTable from 'components/EditableTable';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import BulkEditor from '../../BulkEditor';
|
||||
@@ -17,12 +18,23 @@ import BulkEditor from '../../BulkEditor';
|
||||
const QueryParams = ({ item, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
|
||||
const params = item.draft ? get(item, 'draft.request.params') : get(item, 'request.params');
|
||||
const queryParams = params.filter((param) => param.type === 'query');
|
||||
const pathParams = params.filter((param) => param.type === 'path');
|
||||
|
||||
const [isBulkEditMode, setIsBulkEditMode] = useState(false);
|
||||
|
||||
// Get column widths from Redux
|
||||
const focusedTab = tabs?.find((t) => t.uid === activeTabUid);
|
||||
const queryParamsWidths = focusedTab?.tableColumnWidths?.['query-params'] || {};
|
||||
const pathParamsWidths = focusedTab?.tableColumnWidths?.['path-params'] || {};
|
||||
|
||||
const handleColumnWidthsChange = (tableId, widths) => {
|
||||
dispatch(updateTableColumnWidths({ uid: activeTabUid, tableId, widths }));
|
||||
};
|
||||
|
||||
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
|
||||
const handleRun = () => dispatch(sendRequest(item, collection.uid));
|
||||
|
||||
@@ -138,12 +150,15 @@ const QueryParams = ({ item, collection }) => {
|
||||
<div className="flex-1">
|
||||
<div className="mb-3 title text-xs">Query</div>
|
||||
<EditableTable
|
||||
tableId="query-params"
|
||||
columns={queryColumns}
|
||||
rows={queryParams || []}
|
||||
onChange={handleQueryParamsChange}
|
||||
defaultRow={defaultQueryRow}
|
||||
reorderable={true}
|
||||
onReorder={handleQueryParamDrag}
|
||||
columnWidths={queryParamsWidths}
|
||||
onColumnWidthsChange={(widths) => handleColumnWidthsChange('query-params', widths)}
|
||||
/>
|
||||
<div className="flex justify-end mt-2">
|
||||
<button className="btn-action text-link select-none" onClick={toggleBulkEditMode}>
|
||||
@@ -153,7 +168,7 @@ const QueryParams = ({ item, collection }) => {
|
||||
|
||||
<div className="mb-3 title text-xs flex items-stretch">
|
||||
<span>Path</span>
|
||||
<InfoTip infotipId="path-param-InfoTip">
|
||||
<InfoTip className="tooltip-mod" infotipId="path-param-InfoTip">
|
||||
<div>
|
||||
Path variables are automatically added whenever the
|
||||
<code className="font-mono mx-2">:name</code>
|
||||
@@ -166,6 +181,7 @@ const QueryParams = ({ item, collection }) => {
|
||||
</div>
|
||||
{pathParams && pathParams.length > 0 ? (
|
||||
<EditableTable
|
||||
tableId="path-params"
|
||||
columns={pathColumns}
|
||||
rows={pathParams}
|
||||
onChange={() => {}}
|
||||
@@ -173,6 +189,8 @@ const QueryParams = ({ item, collection }) => {
|
||||
showCheckbox={false}
|
||||
showDelete={false}
|
||||
showAddRow={false}
|
||||
columnWidths={pathParamsWidths}
|
||||
onColumnWidthsChange={(widths) => handleColumnWidthsChange('path-params', widths)}
|
||||
/>
|
||||
) : (
|
||||
<div className="title pr-2 py-3 mt-2 text-xs"></div>
|
||||
|
||||
@@ -2,9 +2,13 @@ import styled from 'styled-components';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
height: 2.1rem;
|
||||
border: ${(props) => props.theme.requestTabPanel.url.border};
|
||||
border-radius: ${(props) => props.theme.border.radius.base};
|
||||
|
||||
.url-input-group {
|
||||
border: ${(props) => props.theme.requestTabPanel.url.border};
|
||||
border-radius: ${(props) => props.theme.border.radius.base};
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.infotip {
|
||||
position: relative;
|
||||
@@ -49,6 +53,7 @@ const Wrapper = styled.div`
|
||||
.shortcut {
|
||||
font-size: 0.625rem;
|
||||
}
|
||||
|
||||
`;
|
||||
|
||||
export default Wrapper;
|
||||
|
||||
@@ -16,8 +16,9 @@ import { saveRequest, cancelRequest } from 'providers/ReduxStore/slices/collecti
|
||||
import { getRequestFromCurlCommand } from 'utils/curl';
|
||||
import HttpMethodSelector from './HttpMethodSelector';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { IconDeviceFloppy, IconArrowRight, IconCode, IconSquareRoundedX } from '@tabler/icons';
|
||||
import { IconDeviceFloppy, IconCode } from '@tabler/icons';
|
||||
import SingleLineEditor from 'components/SingleLineEditor';
|
||||
import SendButton from 'components/RequestPane/SendButton';
|
||||
import { isMacOS } from 'utils/common/platform';
|
||||
import { hasRequestChanges } from 'utils/collections';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
@@ -112,6 +113,13 @@ const QueryUrl = ({ item, collection, handleRun }) => {
|
||||
url: request.url
|
||||
}));
|
||||
|
||||
setTimeout(() => {
|
||||
const editor = editorRef.current?.editor;
|
||||
if (editor) {
|
||||
editor.setCursor(0, request.url.length);
|
||||
}
|
||||
}, 0);
|
||||
|
||||
// Update method
|
||||
dispatch(updateRequestMethod({
|
||||
method: request.method.toUpperCase(), // Convert to uppercase
|
||||
@@ -194,6 +202,13 @@ const QueryUrl = ({ item, collection, handleRun }) => {
|
||||
})
|
||||
);
|
||||
|
||||
setTimeout(() => {
|
||||
const editor = editorRef.current?.editor;
|
||||
if (editor) {
|
||||
editor.setCursor(0, request.url.length);
|
||||
}
|
||||
}, 0);
|
||||
|
||||
// Update method
|
||||
if (request.method) {
|
||||
dispatch(
|
||||
@@ -370,76 +385,67 @@ const QueryUrl = ({ item, collection, handleRun }) => {
|
||||
};
|
||||
return (
|
||||
<StyledWrapper className="flex items-center w-full">
|
||||
<div className="flex items-center h-full min-w-fit">
|
||||
<HttpMethodSelector method={method} onMethodSelect={onMethodSelect} />
|
||||
</div>
|
||||
<div
|
||||
id="request-url"
|
||||
className="h-full w-full flex flex-row input-container overflow-auto"
|
||||
>
|
||||
<SingleLineEditor
|
||||
ref={editorRef}
|
||||
value={url}
|
||||
placeholder="Enter URL or paste a cURL request"
|
||||
onSave={(finalValue) => onSave(finalValue)}
|
||||
theme={storedTheme}
|
||||
onChange={(newValue) => onUrlChange(newValue)}
|
||||
onRun={handleRun}
|
||||
onPaste={item.type === 'http-request' ? handleHttpPaste : item.type === 'graphql-request' ? handleGraphqlPaste : null}
|
||||
collection={collection}
|
||||
highlightPathParams={true}
|
||||
item={item}
|
||||
showNewlineArrow={true}
|
||||
/>
|
||||
|
||||
</div>
|
||||
<div className="flex items-center h-full mx-2 gap-3 cursor-pointer" id="send-request" onClick={handleRun}>
|
||||
<div
|
||||
title="Generate Code"
|
||||
className="infotip"
|
||||
onClick={(e) => {
|
||||
handleGenerateCode(e);
|
||||
}}
|
||||
>
|
||||
<IconCode color={theme.requestTabs.icon.color} strokeWidth={1.5} size={20} className="cursor-pointer" />
|
||||
<span className="infotiptext text-xs">Generate Code</span>
|
||||
<div className="flex items-center h-full url-input-group">
|
||||
<div className="flex items-center h-full min-w-fit">
|
||||
<HttpMethodSelector method={method} onMethodSelect={onMethodSelect} />
|
||||
</div>
|
||||
<div
|
||||
title="Save Request"
|
||||
className="infotip"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!hasChanges) return;
|
||||
onSave();
|
||||
}}
|
||||
id="request-url"
|
||||
className="h-full w-full flex flex-row items-center input-container overflow-hidden"
|
||||
>
|
||||
<IconDeviceFloppy
|
||||
color={hasChanges ? theme.draftColor : theme.requestTabs.icon.color}
|
||||
strokeWidth={1.5}
|
||||
size={20}
|
||||
className={`${hasChanges ? 'cursor-pointer' : 'cursor-default'}`}
|
||||
<SingleLineEditor
|
||||
ref={editorRef}
|
||||
value={url}
|
||||
placeholder="Enter URL or paste a cURL request"
|
||||
onSave={(finalValue) => onSave(finalValue)}
|
||||
theme={storedTheme}
|
||||
onChange={(newValue) => onUrlChange(newValue)}
|
||||
onRun={handleRun}
|
||||
onPaste={item.type === 'http-request' ? handleHttpPaste : item.type === 'graphql-request' ? handleGraphqlPaste : null}
|
||||
collection={collection}
|
||||
highlightPathParams={true}
|
||||
item={item}
|
||||
showNewlineArrow={true}
|
||||
/>
|
||||
<span className="infotiptext text-xs">
|
||||
Save <span className="shortcut">({saveShortcut})</span>
|
||||
</span>
|
||||
<div className="flex items-center h-full mx-2 gap-3" id="request-actions">
|
||||
<div
|
||||
title="Generate Code"
|
||||
className="infotip"
|
||||
onClick={(e) => {
|
||||
handleGenerateCode(e);
|
||||
}}
|
||||
>
|
||||
<IconCode color={theme.requestTabs.icon.color} strokeWidth={1.5} size={20} className="cursor-pointer" />
|
||||
<span className="infotiptext text-xs">Generate Code</span>
|
||||
</div>
|
||||
<div
|
||||
title="Save Request"
|
||||
className="infotip"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!hasChanges) return;
|
||||
onSave();
|
||||
}}
|
||||
>
|
||||
<IconDeviceFloppy
|
||||
color={hasChanges ? theme.draftColor : theme.requestTabs.icon.color}
|
||||
strokeWidth={1.5}
|
||||
size={20}
|
||||
className={`${hasChanges ? 'cursor-pointer' : 'cursor-default'}`}
|
||||
/>
|
||||
<span className="infotiptext text-xs">
|
||||
Save <span className="shortcut">({saveShortcut})</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isLoading || item.response?.stream?.running ? (
|
||||
<IconSquareRoundedX
|
||||
color={theme.requestTabPanel.url.iconDanger}
|
||||
strokeWidth={1.5}
|
||||
size={20}
|
||||
data-testid="cancel-request-icon"
|
||||
onClick={handleCancelRequest}
|
||||
/>
|
||||
) : (
|
||||
<IconArrowRight
|
||||
color={theme.requestTabPanel.url.icon}
|
||||
strokeWidth={1.5}
|
||||
size={20}
|
||||
data-testid="send-arrow-icon"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<SendButton
|
||||
isLoading={isLoading || item.response?.stream?.running}
|
||||
onSend={handleRun}
|
||||
onCancel={handleCancelRequest}
|
||||
testId="send-arrow-icon"
|
||||
/>
|
||||
{generateCodeItemModalOpen && (
|
||||
<GenerateCodeItem
|
||||
collectionUid={collection.uid}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import get from 'lodash/get';
|
||||
import find from 'lodash/find';
|
||||
import CodeEditor from 'components/CodeEditor';
|
||||
import FormUrlEncodedParams from 'components/RequestPane/FormUrlEncodedParams';
|
||||
import MultipartFormParams from 'components/RequestPane/MultipartFormParams';
|
||||
@@ -7,6 +8,7 @@ import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { updateRequestBody } from 'providers/ReduxStore/slices/collections';
|
||||
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { updateRequestBodyScrollPosition } from 'providers/ReduxStore/slices/tabs';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import FileBody from '../FileBody/index';
|
||||
|
||||
@@ -16,6 +18,9 @@ const RequestBody = ({ item, collection }) => {
|
||||
const bodyMode = item.draft ? get(item, 'draft.request.body.mode') : get(item, 'request.body.mode');
|
||||
const { displayedTheme } = useTheme();
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
|
||||
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
|
||||
|
||||
const onEdit = (value) => {
|
||||
dispatch(
|
||||
@@ -30,6 +35,15 @@ const RequestBody = ({ item, collection }) => {
|
||||
const onRun = () => dispatch(sendRequest(item, collection.uid));
|
||||
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
|
||||
|
||||
const onScroll = (editor) => {
|
||||
dispatch(
|
||||
updateRequestBodyScrollPosition({
|
||||
uid: focusedTab.uid,
|
||||
scrollY: editor.doc.scrollTop
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
if (['json', 'xml', 'text', 'sparql'].includes(bodyMode)) {
|
||||
let codeMirrorMode = {
|
||||
json: 'application/ld+json',
|
||||
@@ -57,6 +71,8 @@ const RequestBody = ({ item, collection }) => {
|
||||
onEdit={onEdit}
|
||||
onRun={onRun}
|
||||
onSave={onSave}
|
||||
onScroll={onScroll}
|
||||
initialScroll={focusedTab?.requestBodyScrollPosition || 0}
|
||||
mode={codeMirrorMode[bodyMode]}
|
||||
enableVariableHighlighting={true}
|
||||
showHintsFor={['variables']}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { moveRequestHeader, setRequestHeaders } from 'providers/ReduxStore/slices/collections';
|
||||
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
|
||||
import SingleLineEditor from 'components/SingleLineEditor';
|
||||
import EditableTable from 'components/EditableTable';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
@@ -17,9 +18,19 @@ const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header);
|
||||
const RequestHeaders = ({ item, collection, addHeaderText }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
|
||||
const headers = item.draft ? get(item, 'draft.request.headers') : get(item, 'request.headers');
|
||||
const [isBulkEditMode, setIsBulkEditMode] = useState(false);
|
||||
|
||||
// Get column widths from Redux
|
||||
const focusedTab = tabs?.find((t) => t.uid === activeTabUid);
|
||||
const headersWidths = focusedTab?.tableColumnWidths?.['request-headers'] || {};
|
||||
|
||||
const handleColumnWidthsChange = (tableId, widths) => {
|
||||
dispatch(updateTableColumnWidths({ uid: activeTabUid, tableId, widths }));
|
||||
};
|
||||
|
||||
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
|
||||
const handleRun = () => dispatch(sendRequest(item, collection.uid));
|
||||
|
||||
@@ -123,6 +134,7 @@ const RequestHeaders = ({ item, collection, addHeaderText }) => {
|
||||
return (
|
||||
<StyledWrapper className="w-full">
|
||||
<EditableTable
|
||||
tableId="request-headers"
|
||||
columns={columns}
|
||||
rows={headers || []}
|
||||
onChange={handleHeadersChange}
|
||||
@@ -130,6 +142,8 @@ const RequestHeaders = ({ item, collection, addHeaderText }) => {
|
||||
getRowError={getRowError}
|
||||
reorderable={true}
|
||||
onReorder={handleHeaderDrag}
|
||||
columnWidths={headersWidths}
|
||||
onColumnWidthsChange={(widths) => handleColumnWidthsChange('request-headers', widths)}
|
||||
/>
|
||||
<div className="flex justify-end mt-2">
|
||||
<button className="btn-action text-link select-none" onClick={toggleBulkEditMode}>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
display: flex;
|
||||
align-self: stretch;
|
||||
min-width: 4.1rem;
|
||||
flex-shrink: 0;
|
||||
|
||||
> div {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
import Button from 'ui/Button';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const SendButton = ({ isLoading = false, onSend, onCancel, testId = 'send-request-btn' }) => {
|
||||
return (
|
||||
<StyledWrapper className="ml-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={isLoading ? 'outline' : 'filled'}
|
||||
color="primary"
|
||||
data-testid={testId}
|
||||
data-action={isLoading ? 'cancel' : 'send'}
|
||||
onClick={isLoading ? onCancel : onSend}
|
||||
>
|
||||
{isLoading ? 'Cancel' : 'Send'}
|
||||
</Button>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default SendButton;
|
||||
@@ -27,18 +27,20 @@ const Tests = ({ item, collection }) => {
|
||||
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
|
||||
|
||||
return (
|
||||
<CodeEditor
|
||||
collection={collection}
|
||||
value={tests || ''}
|
||||
theme={displayedTheme}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
onEdit={onEdit}
|
||||
mode="javascript"
|
||||
onRun={onRun}
|
||||
onSave={onSave}
|
||||
showHintsFor={['req', 'res', 'bru']}
|
||||
/>
|
||||
<div data-testid="test-script-editor">
|
||||
<CodeEditor
|
||||
collection={collection}
|
||||
value={tests || ''}
|
||||
theme={displayedTheme}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
onEdit={onEdit}
|
||||
mode="javascript"
|
||||
onRun={onRun}
|
||||
onSave={onSave}
|
||||
showHintsFor={['req', 'res', 'bru']}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { moveVar, setRequestVars } from 'providers/ReduxStore/slices/collections';
|
||||
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
|
||||
import MultiLineEditor from 'components/MultiLineEditor';
|
||||
import InfoTip from 'components/InfoTip';
|
||||
import EditableTable from 'components/EditableTable';
|
||||
@@ -13,6 +14,16 @@ import { variableNameRegex } from 'utils/common/regex';
|
||||
const VarsTable = ({ item, collection, vars, varType }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
|
||||
|
||||
// Get column widths from Redux
|
||||
const focusedTab = tabs?.find((t) => t.uid === activeTabUid);
|
||||
const varsWidths = focusedTab?.tableColumnWidths?.['request-vars'] || {};
|
||||
|
||||
const handleColumnWidthsChange = (tableId, widths) => {
|
||||
dispatch(updateTableColumnWidths({ uid: activeTabUid, tableId, widths }));
|
||||
};
|
||||
|
||||
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
|
||||
const handleRun = () => dispatch(sendRequest(item, collection.uid));
|
||||
@@ -57,7 +68,7 @@ const VarsTable = ({ item, collection, vars, varType }) => {
|
||||
name: varType === 'request' ? 'Value' : (
|
||||
<div className="flex items-center">
|
||||
<span>Expr</span>
|
||||
<InfoTip content="You can write any valid JS expression here" infotipId={`request-${varType}-var`} />
|
||||
<InfoTip className="tooltip-mod" content="You can write any valid JS expression here" infotipId={`request-${varType}-var`} />
|
||||
</div>
|
||||
),
|
||||
placeholder: varType === 'request' ? 'Value' : 'Expr',
|
||||
@@ -85,6 +96,7 @@ const VarsTable = ({ item, collection, vars, varType }) => {
|
||||
return (
|
||||
<StyledWrapper className="w-full">
|
||||
<EditableTable
|
||||
tableId="request-vars"
|
||||
columns={columns}
|
||||
rows={vars || []}
|
||||
onChange={handleVarsChange}
|
||||
@@ -92,6 +104,8 @@ const VarsTable = ({ item, collection, vars, varType }) => {
|
||||
getRowError={getRowError}
|
||||
reorderable={true}
|
||||
onReorder={handleVarDrag}
|
||||
columnWidths={varsWidths}
|
||||
onColumnWidthsChange={(widths) => handleColumnWidthsChange('request-vars', widths)}
|
||||
/>
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -93,12 +93,12 @@ const WSAuth = ({ item, collection }) => {
|
||||
case 'inherit': {
|
||||
const source = getEffectiveAuthSource();
|
||||
|
||||
// Check if inherited auth is OAuth2 - not supported for WebSockets
|
||||
if (source?.auth?.mode === 'oauth2') {
|
||||
// Check if inherited auth is OAuth1/OAuth2 - not supported for WebSockets
|
||||
if (source?.auth?.mode === 'oauth1' || source?.auth?.mode === 'oauth2') {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-row w-full mt-2 gap-2">
|
||||
OAuth 2 not <strong>yet</strong> supported by WebSockets. Using no auth instead.
|
||||
{source.auth.mode === 'oauth1' ? 'OAuth 1.0' : 'OAuth 2'} not <strong>yet</strong> supported by WebSockets. Using no auth instead.
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -3,12 +3,12 @@ import styled from 'styled-components';
|
||||
const StyledWrapper = styled.div`
|
||||
height: 2.1rem;
|
||||
position: relative;
|
||||
border: ${(props) => props.theme.requestTabPanel.url.border};
|
||||
border-radius: ${(props) => props.theme.border.radius.base};
|
||||
|
||||
.input-container {
|
||||
background-color: ${(props) => props.theme.requestTabPanel.url.bg};
|
||||
border: ${(props) => props.theme.requestTabPanel.url.border};
|
||||
border-radius: ${(props) => props.theme.border.radius.base};
|
||||
position: relative;
|
||||
|
||||
input {
|
||||
background-color: ${(props) => props.theme.requestTabPanel.url.bg};
|
||||
@@ -99,6 +99,7 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { IconArrowRight, IconDeviceFloppy, IconPlugConnected, IconPlugConnectedX } from '@tabler/icons';
|
||||
import { IconDeviceFloppy, IconPlugConnected, IconPlugConnectedX } from '@tabler/icons';
|
||||
import SendButton from 'components/RequestPane/SendButton';
|
||||
import classnames from 'classnames';
|
||||
import SingleLineEditor from 'components/SingleLineEditor/index';
|
||||
import { requestUrlChanged } from 'providers/ReduxStore/slices/collections';
|
||||
@@ -123,7 +124,7 @@ const WsQueryUrl = ({ item, collection, handleRun }) => {
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="flex items-center h-full">
|
||||
<div className="flex items-center input-container flex-1 w-full h-full relative">
|
||||
<div className="flex items-center input-container flex-1 min-w-0 h-full relative">
|
||||
<div className="flex items-center justify-center px-[10px]">
|
||||
<span className="text-xs font-medium method-ws">WS</span>
|
||||
</div>
|
||||
@@ -187,15 +188,14 @@ const WsQueryUrl = ({ item, collection, handleRun }) => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div data-testid="run-button" className="cursor-pointer" onClick={handleRunClick}>
|
||||
<IconArrowRight color={theme.requestTabPanel.url.icon} strokeWidth={1.5} size={20} />
|
||||
</div>
|
||||
</div>
|
||||
{connectionStatus === CONNECTION_STATUS.CONNECTED && <div className="connection-status-strip"></div>}
|
||||
</div>
|
||||
<SendButton
|
||||
onSend={handleRunClick}
|
||||
testId="run-button"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{connectionStatus === CONNECTION_STATUS.CONNECTED && <div className="connection-status-strip"></div>}
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,7 +8,8 @@ import GrpcRequestPane from 'components/RequestPane/GrpcRequestPane/index';
|
||||
import ResponsePane from 'components/ResponsePane';
|
||||
import GrpcResponsePane from 'components/ResponsePane/GrpcResponsePane';
|
||||
import { findItemInCollection } from 'utils/collections';
|
||||
import { cancelRequest, sendRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { sendRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { updateGqlDocsOpen } from 'providers/ReduxStore/slices/tabs';
|
||||
import RequestNotFound from './RequestNotFound';
|
||||
import QueryUrl from 'components/RequestPane/QueryUrl/index';
|
||||
import GrpcQueryUrl from 'components/RequestPane/GrpcQueryUrl/index';
|
||||
@@ -31,6 +32,8 @@ import WsQueryUrl from 'components/RequestPane/WsQueryUrl';
|
||||
import WSRequestPane from 'components/RequestPane/WSRequestPane';
|
||||
import WSResponsePane from 'components/ResponsePane/WsResponsePane';
|
||||
import { useTabPaneBoundaries } from 'hooks/useTabPaneBoundaries/index';
|
||||
import useKeybinding from 'hooks/useKeybinding';
|
||||
import { ScopedPersistenceProvider } from 'hooks/usePersistedState/PersistedScopeProvider';
|
||||
import ResponseExample from 'components/ResponseExample';
|
||||
import WorkspaceOverview from 'components/WorkspaceHome/WorkspaceOverview';
|
||||
import Preferences from 'components/Preferences';
|
||||
@@ -57,6 +60,12 @@ const RequestTabPanel = () => {
|
||||
const isVerticalLayout = preferences?.layout?.responsePaneOrientation === 'vertical';
|
||||
const isConsoleOpen = useSelector((state) => state.logs.isConsoleOpen);
|
||||
|
||||
const isRequestTab = focusedTab && ['request', 'grpc-request', 'ws-request', 'graphql-request'].includes(focusedTab.type);
|
||||
useKeybinding('sendRequest', () => {
|
||||
handleRun();
|
||||
return false;
|
||||
}, { enabled: !!isRequestTab, deps: [isRequestTab] });
|
||||
|
||||
// Use ref to avoid stale closure in event handlers
|
||||
const isVerticalLayoutRef = useRef(isVerticalLayout);
|
||||
useEffect(() => {
|
||||
@@ -92,18 +101,24 @@ const RequestTabPanel = () => {
|
||||
const mainSectionRef = useRef(null);
|
||||
|
||||
const [schema, setSchema] = useState(null);
|
||||
const [showGqlDocs, setShowGqlDocs] = useState(false);
|
||||
|
||||
// Get gqlDocsOpen from Redux for persistence across tab switches
|
||||
const showGqlDocs = focusedTab?.gqlDocsOpen || false;
|
||||
|
||||
const onSchemaLoad = useCallback((schema) => setSchema(schema), []);
|
||||
const toggleDocs = useCallback(() => setShowGqlDocs((prev) => !prev), []);
|
||||
const toggleDocs = useCallback((value = null) => {
|
||||
const newValue = value !== null ? !!value : !showGqlDocs;
|
||||
dispatch(updateGqlDocsOpen({ uid: activeTabUid, gqlDocsOpen: newValue }));
|
||||
}, [dispatch, activeTabUid, showGqlDocs]);
|
||||
|
||||
const handleGqlClickReference = useCallback((reference) => {
|
||||
if (docExplorerRef.current) {
|
||||
docExplorerRef.current.showDocForReference(reference);
|
||||
}
|
||||
if (!showGqlDocs) {
|
||||
setShowGqlDocs(true);
|
||||
dispatch(updateGqlDocsOpen({ uid: activeTabUid, gqlDocsOpen: true }));
|
||||
}
|
||||
}, []);
|
||||
}, [dispatch, activeTabUid, showGqlDocs]);
|
||||
|
||||
const handleMouseMove = useCallback((e) => {
|
||||
if (!draggingRef.current || !mainSectionRef.current) return;
|
||||
@@ -284,20 +299,13 @@ const RequestTabPanel = () => {
|
||||
toast.error('Please enter a valid WebSocket URL');
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.response?.stream?.running) {
|
||||
dispatch(cancelRequest(item.cancelTokenUid, item, collection)).catch((err) =>
|
||||
toast.custom((t) => <NetworkError onClose={() => toast.dismiss(t.id)} />, {
|
||||
duration: 5000
|
||||
}));
|
||||
} else if (item.requestState !== 'sending' && item.requestState !== 'queued') {
|
||||
if (item.requestState !== 'sending' && item.requestState !== 'queued') {
|
||||
dispatch(sendRequest(item, collection.uid)).catch((err) =>
|
||||
toast.custom((t) => <NetworkError onClose={() => toast.dismiss(t.id)} />, {
|
||||
duration: 5000
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const renderQueryUrl = () => {
|
||||
if (isGrpcRequest) {
|
||||
return <GrpcQueryUrl item={item} collection={collection} handleRun={handleRun} />;
|
||||
@@ -353,50 +361,52 @@ const RequestTabPanel = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper
|
||||
className={`flex flex-col flex-grow relative ${dragging ? 'dragging' : ''} ${
|
||||
isVerticalLayout ? 'vertical-layout' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="pt-3 pb-3 px-4">
|
||||
{renderQueryUrl()}
|
||||
</div>
|
||||
<section ref={mainSectionRef} className={`main flex ${isVerticalLayout ? 'flex-col' : ''} flex-grow pb-4 relative overflow-auto`}>
|
||||
<section className="request-pane">
|
||||
<ScopedPersistenceProvider scope={focusedTab.uid}>
|
||||
<StyledWrapper
|
||||
className={`flex flex-col flex-grow relative ${dragging ? 'dragging' : ''} ${
|
||||
isVerticalLayout ? 'vertical-layout' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="pt-3 pb-3 px-4">
|
||||
{renderQueryUrl()}
|
||||
</div>
|
||||
<section ref={mainSectionRef} className={`main flex ${isVerticalLayout ? 'flex-col' : ''} flex-grow pb-4 relative overflow-auto`}>
|
||||
<section className="request-pane" data-testid="request-pane">
|
||||
<div
|
||||
className="px-4 h-full"
|
||||
style={requestPaneStyle}
|
||||
>
|
||||
{renderRequestPane()}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div
|
||||
className="px-4 h-full"
|
||||
style={requestPaneStyle}
|
||||
className="dragbar-wrapper"
|
||||
onDoubleClick={(e) => {
|
||||
e.preventDefault();
|
||||
resetPaneBoundaries();
|
||||
}}
|
||||
onMouseDown={handleDragbarMouseDown}
|
||||
>
|
||||
{renderRequestPane()}
|
||||
<div className="dragbar-handle" />
|
||||
</div>
|
||||
|
||||
<section className="response-pane flex-grow overflow-x-auto" data-testid="response-pane">
|
||||
{renderResponsePane()}
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<div
|
||||
className="dragbar-wrapper"
|
||||
onDoubleClick={(e) => {
|
||||
e.preventDefault();
|
||||
resetPaneBoundaries();
|
||||
}}
|
||||
onMouseDown={handleDragbarMouseDown}
|
||||
>
|
||||
<div className="dragbar-handle" />
|
||||
</div>
|
||||
|
||||
<section className="response-pane flex-grow overflow-x-auto">
|
||||
{renderResponsePane()}
|
||||
</section>
|
||||
</section>
|
||||
|
||||
{item.type === 'graphql-request' ? (
|
||||
<div className={`graphql-docs-explorer-container ${showGqlDocs ? '' : 'hidden'}`}>
|
||||
<DocExplorer schema={schema} ref={(r) => (docExplorerRef.current = r)}>
|
||||
<button className="mr-2" onClick={toggleDocs} aria-label="Close Documentation Explorer">
|
||||
{'\u2715'}
|
||||
</button>
|
||||
</DocExplorer>
|
||||
</div>
|
||||
) : null}
|
||||
</StyledWrapper>
|
||||
{item.type === 'graphql-request' ? (
|
||||
<div className={`graphql-docs-explorer-container ${showGqlDocs ? '' : 'hidden'}`}>
|
||||
<DocExplorer schema={schema} ref={(r) => (docExplorerRef.current = r)}>
|
||||
<button className="mr-2" data-testid="graphql-docs-close-button" onClick={() => toggleDocs(false)} aria-label="Close Documentation Explorer">
|
||||
{'\u2715'}
|
||||
</button>
|
||||
</DocExplorer>
|
||||
</div>
|
||||
) : null}
|
||||
</StyledWrapper>
|
||||
</ScopedPersistenceProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -580,14 +580,14 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
|
||||
)}
|
||||
{/* Runner - always visible */}
|
||||
<ToolHint text="Runner" toolhintId="RunnerToolhintId" place="bottom">
|
||||
<ActionIcon onClick={handleRun} aria-label="Runner" size="sm">
|
||||
<ActionIcon onClick={handleRun} aria-label="Runner" size="sm" data-testid="runner">
|
||||
<IconRun size={16} strokeWidth={1.5} />
|
||||
</ActionIcon>
|
||||
</ToolHint>
|
||||
{/* JS Sandbox Mode - always visible */}
|
||||
<JsSandboxMode collection={collection} />
|
||||
{/* Overflow menu */}
|
||||
<MenuDropdown items={overflowMenuItems} placement="bottom-end">
|
||||
<MenuDropdown items={overflowMenuItems} placement="bottom-end" data-testid="more-actions">
|
||||
<ActionIcon label="More actions" size="sm" style={{ border: `1px solid ${theme.border.border1}`, borderRadius: theme.border.radius.base, width: 24, marginRight: 4, marginLeft: 4 }}>
|
||||
<IconDots size={16} strokeWidth={1.5} />
|
||||
</ActionIcon>
|
||||
|
||||
@@ -43,6 +43,13 @@ const ExampleTab = ({ tab, collection }) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Prevent the browser's autoscroll (triggered on middle-button mousedown)
|
||||
const handleMouseDown = (e) => {
|
||||
if (e.button === 1) {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = (e) => {
|
||||
if (e.button === 1) {
|
||||
e.preventDefault();
|
||||
@@ -59,6 +66,7 @@ const ExampleTab = ({ tab, collection }) => {
|
||||
return (
|
||||
<StyledWrapper
|
||||
className="flex items-center justify-between tab-container"
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseUp={(e) => {
|
||||
if (e.button === 1) {
|
||||
e.preventDefault();
|
||||
@@ -105,6 +113,7 @@ const ExampleTab = ({ tab, collection }) => {
|
||||
className={`flex items-center tab-label ${tab.preview ? 'italic' : ''}`}
|
||||
onContextMenu={handleRightClick}
|
||||
onDoubleClick={() => dispatch(makeTabPermanent({ uid: tab.uid }))}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseUp={(e) => {
|
||||
if (!hasChanges) return handleMouseUp(e);
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useCallback, useState, useRef, Fragment, useMemo, useEffect } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
|
||||
import { saveRequest, saveCollectionRoot, saveFolderRoot, saveEnvironment, closeTabs } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { saveRequest, saveCollectionRoot, saveFolderRoot, saveEnvironment, saveCollectionSettings, closeTabs } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import useKeybinding from 'hooks/useKeybinding';
|
||||
import { deleteRequestDraft, deleteCollectionDraft, deleteFolderDraft, clearEnvironmentsDraft } from 'providers/ReduxStore/slices/collections';
|
||||
import { clearGlobalEnvironmentDraft } from 'providers/ReduxStore/slices/global-environments';
|
||||
import { saveGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
|
||||
@@ -21,6 +22,7 @@ import NewRequest from 'components/Sidebar/NewRequest/index';
|
||||
import GradientCloseButton from './GradientCloseButton';
|
||||
import { flattenItems } from 'utils/collections/index';
|
||||
import { closeWsConnection } from 'utils/network/index';
|
||||
import { getInvalidVariableNames } from 'utils/common/variables';
|
||||
import ExampleTab from '../ExampleTab';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
@@ -102,6 +104,13 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
|
||||
menuDropdownRef.current?.show();
|
||||
};
|
||||
|
||||
// Prevent the browser's autoscroll (triggered on middle-button mousedown)
|
||||
const handleMouseDown = (e) => {
|
||||
if (e.button === 1) {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = (e) => {
|
||||
if (e.button === 1) {
|
||||
e.preventDefault();
|
||||
@@ -167,6 +176,78 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
|
||||
const globalEnvironmentDraft = useSelector((state) => state.globalEnvironments.globalEnvironmentDraft);
|
||||
const hasGlobalEnvironmentDraft = tab.type === 'global-environment-settings' && globalEnvironmentDraft;
|
||||
|
||||
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
|
||||
const isActive = tab.uid === activeTabUid;
|
||||
|
||||
// Close tab shortcut — draft-aware, only active for the focused tab
|
||||
useKeybinding('closeTab', () => {
|
||||
if (tab.type === 'request' || tab.type === 'grpc-request' || tab.type === 'ws-request' || tab.type === 'graphql-request') {
|
||||
if (hasChanges) {
|
||||
setShowConfirmClose(true);
|
||||
} else {
|
||||
if (item?.type === 'ws-request') {
|
||||
closeWsConnection(item.uid);
|
||||
}
|
||||
dispatch(closeTabs({ tabUids: [tab.uid] }));
|
||||
}
|
||||
} else if (tab.type === 'collection-settings') {
|
||||
if (collection?.draft) {
|
||||
setShowConfirmCollectionClose(true);
|
||||
} else {
|
||||
dispatch(closeTabs({ tabUids: [tab.uid] }));
|
||||
}
|
||||
} else if (tab.type === 'folder-settings') {
|
||||
if (folder?.draft) {
|
||||
setShowConfirmFolderClose(true);
|
||||
} else {
|
||||
dispatch(closeTabs({ tabUids: [tab.uid] }));
|
||||
}
|
||||
} else if (tab.type === 'environment-settings') {
|
||||
if (collection?.environmentsDraft) {
|
||||
setShowConfirmEnvironmentClose(true);
|
||||
} else {
|
||||
dispatch(closeTabs({ tabUids: [tab.uid] }));
|
||||
}
|
||||
} else if (tab.type === 'global-environment-settings') {
|
||||
if (globalEnvironmentDraft) {
|
||||
setShowConfirmGlobalEnvironmentClose(true);
|
||||
} else {
|
||||
dispatch(closeTabs({ tabUids: [tab.uid] }));
|
||||
}
|
||||
} else {
|
||||
dispatch(closeTabs({ tabUids: [tab.uid] }));
|
||||
}
|
||||
return false;
|
||||
}, { enabled: isActive, deps: [isActive, tab, hasChanges, item, collection, folder, globalEnvironmentDraft] });
|
||||
|
||||
// Save shortcut — tab-type-aware, only active for the focused tab
|
||||
useKeybinding('save', () => {
|
||||
if (tab.type === 'environment-settings') {
|
||||
if (collection?.environmentsDraft) {
|
||||
const { environmentUid, variables } = collection.environmentsDraft;
|
||||
if (environmentUid?.startsWith('dotenv:')) {
|
||||
window.dispatchEvent(new Event('dotenv-save'));
|
||||
} else {
|
||||
dispatch(saveEnvironment(variables, environmentUid, collection.uid));
|
||||
}
|
||||
}
|
||||
} else if (tab.type === 'global-environment-settings') {
|
||||
if (globalEnvironmentDraft) {
|
||||
const { environmentUid, variables } = globalEnvironmentDraft;
|
||||
dispatch(saveGlobalEnvironment({ variables, environmentUid }));
|
||||
}
|
||||
} else if (tab.type === 'folder-settings') {
|
||||
if (folder) {
|
||||
dispatch(saveFolderRoot(collection.uid, folder.uid));
|
||||
}
|
||||
} else if (tab.type === 'collection-settings') {
|
||||
dispatch(saveCollectionSettings(collection.uid));
|
||||
} else if (item && item.uid) {
|
||||
dispatch(saveRequest(tab.uid, tab.collectionUid));
|
||||
}
|
||||
return false;
|
||||
}, { enabled: isActive, deps: [isActive, tab, item, collection, folder, globalEnvironmentDraft] });
|
||||
|
||||
const handleCloseEnvironmentSettings = (event) => {
|
||||
if (!collection?.environmentsDraft) {
|
||||
return handleCloseClick(event);
|
||||
@@ -191,6 +272,7 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
|
||||
return (
|
||||
<StyledWrapper
|
||||
className={`flex items-center justify-between tab-container px-2 ${tab.preview ? 'italic' : ''}`}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseUp={handleMouseUp}
|
||||
>
|
||||
{showConfirmCollectionClose && tab.type === 'collection-settings' && (
|
||||
@@ -279,6 +361,11 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
|
||||
window.addEventListener('dotenv-save-failed', onFailed, { once: true });
|
||||
window.dispatchEvent(new Event('dotenv-save'));
|
||||
} else if (draft?.environmentUid && draft?.variables) {
|
||||
const invalidNames = getInvalidVariableNames(draft.variables);
|
||||
if (invalidNames.length > 0) {
|
||||
toast.error(`Invalid variable name(s): ${invalidNames.join(', ')}`);
|
||||
return;
|
||||
}
|
||||
dispatch(saveEnvironment(draft.variables, draft.environmentUid, collection.uid))
|
||||
.then(() => {
|
||||
dispatch(clearEnvironmentsDraft({ collectionUid: collection.uid }));
|
||||
@@ -325,6 +412,11 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
|
||||
window.addEventListener('dotenv-save-failed', onFailed, { once: true });
|
||||
window.dispatchEvent(new Event('dotenv-save'));
|
||||
} else if (draft?.environmentUid && draft?.variables) {
|
||||
const invalidNames = getInvalidVariableNames(draft.variables);
|
||||
if (invalidNames.length > 0) {
|
||||
toast.error(`Invalid variable name(s): ${invalidNames.join(', ')}`);
|
||||
return;
|
||||
}
|
||||
dispatch(saveGlobalEnvironment({ variables: draft.variables, environmentUid: draft.environmentUid }))
|
||||
.then(() => {
|
||||
dispatch(clearGlobalEnvironmentDraft());
|
||||
@@ -378,6 +470,7 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
|
||||
return (
|
||||
<StyledWrapper
|
||||
className="flex items-center justify-between tab-container"
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseUp={(e) => {
|
||||
if (e.button === 1) {
|
||||
e.preventDefault();
|
||||
@@ -434,6 +527,7 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
|
||||
className={`flex items-baseline tab-label ${tab.preview ? 'italic' : ''}`}
|
||||
onContextMenu={handleRightClick}
|
||||
onDoubleClick={() => dispatch(makeTabPermanent({ uid: tab.uid }))}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseUp={(e) => {
|
||||
if (!hasChanges) return handleMouseUp(e);
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { useState, useMemo, useCallback } from 'react';
|
||||
import { get } from 'lodash';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { updateResponseExampleFileBodyParams } from 'providers/ReduxStore/slices/collections';
|
||||
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
|
||||
import mime from 'mime-types';
|
||||
import path from 'utils/common/path';
|
||||
import EditableTable from 'components/EditableTable';
|
||||
@@ -14,6 +15,16 @@ import RadioButton from 'components/RadioButton';
|
||||
const ResponseExampleFileBody = ({ item, collection, exampleUid, editMode = false }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
|
||||
|
||||
// Get column widths from Redux
|
||||
const focusedTab = tabs?.find((t) => t.uid === activeTabUid);
|
||||
const fileBodyWidths = focusedTab?.tableColumnWidths?.['example-file-body'] || {};
|
||||
|
||||
const handleColumnWidthsChange = (tableId, widths) => {
|
||||
dispatch(updateTableColumnWidths({ uid: activeTabUid, tableId, widths }));
|
||||
};
|
||||
|
||||
// Get file data from the specific example
|
||||
const params = useMemo(() => {
|
||||
@@ -180,6 +191,9 @@ const ResponseExampleFileBody = ({ item, collection, exampleUid, editMode = fals
|
||||
return (
|
||||
<StyledWrapper className="w-full mt-4">
|
||||
<EditableTable
|
||||
tableId="example-file-body"
|
||||
columnWidths={fileBodyWidths}
|
||||
onColumnWidthsChange={(widths) => handleColumnWidthsChange('example-file-body', widths)}
|
||||
columns={columns}
|
||||
rows={params || []}
|
||||
onChange={handleParamsChange}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user