mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-03 01:18:32 +00:00
Compare commits
21 Commits
dependabot
...
v3.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0470e8d1a7 | ||
|
|
031b373bac | ||
|
|
586fd6b7f6 | ||
|
|
51765da0b1 | ||
|
|
5b02aad92a | ||
|
|
606d03180f | ||
|
|
a86551ad27 | ||
|
|
60f8611dd7 | ||
|
|
09be7131cc | ||
|
|
d45a975335 | ||
|
|
6f82eae80f | ||
|
|
ab2326deb3 | ||
|
|
5a4d337ed3 | ||
|
|
cc197e0c30 | ||
|
|
9fa6acca4e | ||
|
|
da892243d2 | ||
|
|
994b60678e | ||
|
|
e001b6ba51 | ||
|
|
59453536a6 | ||
|
|
7fc4ff274d | ||
|
|
663ece708e |
2
.github/CODEOWNERS
vendored
2
.github/CODEOWNERS
vendored
@@ -1 +1 @@
|
||||
* @helloanoop @maintainer-bruno @bijin-bruno @lohit-bruno @naman-bruno @sid-bruno
|
||||
* @helloanoop @maintainer-bruno @bijin-bruno @lohit-bruno @naman-bruno
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
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
|
||||
@@ -1,30 +0,0 @@
|
||||
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
|
||||
@@ -1,15 +0,0 @@
|
||||
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
|
||||
@@ -1,16 +0,0 @@
|
||||
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: $!"
|
||||
@@ -1,17 +0,0 @@
|
||||
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
|
||||
@@ -1,30 +0,0 @@
|
||||
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
|
||||
@@ -1,16 +0,0 @@
|
||||
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: $!"
|
||||
@@ -1,17 +0,0 @@
|
||||
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
|
||||
@@ -1,34 +0,0 @@
|
||||
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 }
|
||||
@@ -1,14 +0,0 @@
|
||||
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
79
.github/workflows/auth-tests.yml
vendored
@@ -1,79 +0,0 @@
|
||||
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/flaky-test-detector.yml
vendored
2
.github/workflows/flaky-test-detector.yml
vendored
@@ -73,7 +73,7 @@ jobs:
|
||||
|
||||
- name: Post PR comment
|
||||
if: hashFiles('pr-comment.md') != ''
|
||||
uses: actions/github-script@v9
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
|
||||
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@v3
|
||||
uses: dorny/test-reporter@v2
|
||||
if: success() || failure()
|
||||
with:
|
||||
name: Test Report
|
||||
|
||||
@@ -75,8 +75,6 @@ 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.getByTestId('send-arrow-icon').click();
|
||||
await page.locator('#send-request').getByRole('img').nth(2).click();
|
||||
|
||||
// Verify response
|
||||
await expect(page.getByRole('main')).toContainText('200 OK');
|
||||
|
||||
@@ -178,8 +178,7 @@ module.exports = runESMImports().then(() => defineConfig([
|
||||
}
|
||||
},
|
||||
rules: {
|
||||
'no-undef': 'error',
|
||||
'no-case-declarations': 'error'
|
||||
'no-undef': 'error'
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
10323
package-lock.json
generated
10323
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.9.1",
|
||||
"@opencollection/types": "~0.8.0",
|
||||
"@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.39.4",
|
||||
"eslint": "^9.26.0",
|
||||
"eslint-plugin-diff": "^2.0.3",
|
||||
"fs-extra": "^11.1.1",
|
||||
"globals": "^16.1.0",
|
||||
@@ -82,7 +82,6 @@
|
||||
"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"
|
||||
@@ -93,9 +92,7 @@
|
||||
]
|
||||
},
|
||||
"overrides": {
|
||||
"axios":"1.13.6",
|
||||
"rollup": "3.30.0",
|
||||
"pbkdf2":"3.1.5",
|
||||
"rollup": "3.29.5",
|
||||
"electron-store": {
|
||||
"conf": {
|
||||
"json-schema-typed": "8.0.1"
|
||||
@@ -106,4 +103,4 @@
|
||||
"ajv": "^8.17.1",
|
||||
"git-url-parse": "^14.1.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"presets": ["@babel/preset-env", "@babel/preset-react", "@babel/preset-typescript"],
|
||||
"presets": ["@babel/preset-env", "@babel/preset-react"],
|
||||
"plugins": [["styled-components", { "ssr": true }]]
|
||||
}
|
||||
@@ -39,7 +39,7 @@
|
||||
"github-markdown-css": "^5.2.0",
|
||||
"graphiql": "3.7.1",
|
||||
"graphql": "^16.6.0",
|
||||
"graphql-request": "4.2.0",
|
||||
"graphql-request": "^3.7.0",
|
||||
"hexy": "^0.3.5",
|
||||
"httpsnippet": "^3.0.9",
|
||||
"i18next": "24.1.2",
|
||||
@@ -100,10 +100,9 @@
|
||||
"@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",
|
||||
"@rsbuild/plugin-node-polyfill": "^1.2.0",
|
||||
"@rsbuild/plugin-react": "^1.0.7",
|
||||
"@rsbuild/plugin-sass": "^1.1.0",
|
||||
"@rsbuild/plugin-styled-components": "1.1.0",
|
||||
|
||||
@@ -61,17 +61,6 @@ 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,6 +57,16 @@ 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,14 +151,8 @@ const StyledWrapper = styled.div`
|
||||
|
||||
//matching bracket fix
|
||||
.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;
|
||||
background: #5cc0b48c !important;
|
||||
text-decoration:unset;
|
||||
}
|
||||
|
||||
.cm-search-line-highlight {
|
||||
|
||||
@@ -74,6 +74,26 @@ 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();
|
||||
@@ -197,12 +217,6 @@ 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,10 +233,18 @@ 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) {
|
||||
const cursor = this.editor.getCursor();
|
||||
this.cachedValue = String(this?.props?.value ?? '');
|
||||
this.editor.setValue(String(this.props.value) || '');
|
||||
this.editor.setCursor(cursor);
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.editor) {
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
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;
|
||||
@@ -1,55 +0,0 @@
|
||||
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;
|
||||
@@ -1,140 +0,0 @@
|
||||
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,11 +51,6 @@ const AuthMode = ({ collection }) => {
|
||||
label: 'NTLM Auth',
|
||||
onClick: () => onModeChange('ntlm')
|
||||
},
|
||||
{
|
||||
id: 'oauth1',
|
||||
label: 'OAuth 1.0',
|
||||
onClick: () => onModeChange('oauth1')
|
||||
},
|
||||
{
|
||||
id: 'oauth2',
|
||||
label: 'OAuth 2.0',
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
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,7 +12,6 @@ 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 }) => {
|
||||
@@ -38,9 +37,6 @@ const Auth = ({ collection }) => {
|
||||
case 'ntlm': {
|
||||
return <NTLMAuth collection={collection} />;
|
||||
}
|
||||
case 'oauth1': {
|
||||
return <OAuth1 collection={collection} />;
|
||||
}
|
||||
case 'oauth2': {
|
||||
return <OAuth2 collection={collection} />;
|
||||
}
|
||||
|
||||
@@ -1,25 +1,9 @@
|
||||
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,10 +1,9 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useDispatch } 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';
|
||||
@@ -19,21 +18,11 @@ 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);
|
||||
};
|
||||
@@ -125,14 +114,11 @@ 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,11 +1,9 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import React, { useState, 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';
|
||||
@@ -20,24 +18,27 @@ 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', '');
|
||||
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
const focusedTab = find(tabs, (t) => t.uid === collection.uid);
|
||||
const scriptPaneTab = focusedTab?.scriptPaneTab;
|
||||
|
||||
const getDefaultTab = () => {
|
||||
// Default to post-response if pre-request script is empty
|
||||
const getInitialTab = () => {
|
||||
const hasPreRequestScript = requestScript && requestScript.trim().length > 0;
|
||||
return hasPreRequestScript ? 'pre-request' : 'post-response';
|
||||
};
|
||||
|
||||
const activeTab = scriptPaneTab || getDefaultTab();
|
||||
|
||||
const setActiveTab = (tab) => {
|
||||
dispatch(updateScriptPaneTab({ uid: collection.uid, scriptPaneTab: tab }));
|
||||
};
|
||||
const [activeTab, setActiveTab] = useState(getInitialTab);
|
||||
const prevCollectionUidRef = useRef(collection.uid);
|
||||
|
||||
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,10 +1,6 @@
|
||||
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;
|
||||
@@ -28,8 +24,7 @@ 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;
|
||||
}
|
||||
@@ -50,7 +45,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,8 +1,7 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useDispatch } 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';
|
||||
@@ -14,16 +13,6 @@ 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));
|
||||
|
||||
@@ -79,14 +68,11 @@ 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" data-testid="collection-settings-tab-overview" onClick={() => setTab('overview')}>
|
||||
<div className={getTabClassname('overview')} role="tab" onClick={() => setTab('overview')}>
|
||||
Overview
|
||||
</div>
|
||||
<div className={getTabClassname('headers')} role="tab" data-testid="collection-settings-tab-headers" onClick={() => setTab('headers')}>
|
||||
<div className={getTabClassname('headers')} role="tab" onClick={() => setTab('headers')}>
|
||||
Headers
|
||||
{activeHeadersCount > 0 && <sup className="ml-1 font-medium">{activeHeadersCount}</sup>}
|
||||
</div>
|
||||
<div className={getTabClassname('vars')} role="tab" data-testid="collection-settings-tab-vars" onClick={() => setTab('vars')}>
|
||||
<div className={getTabClassname('vars')} role="tab" onClick={() => setTab('vars')}>
|
||||
Vars
|
||||
{activeVarsCount > 0 && <sup className="ml-1 font-medium">{activeVarsCount}</sup>}
|
||||
</div>
|
||||
<div className={getTabClassname('auth')} role="tab" data-testid="collection-settings-tab-auth" onClick={() => setTab('auth')}>
|
||||
<div className={getTabClassname('auth')} role="tab" onClick={() => setTab('auth')}>
|
||||
Auth
|
||||
{authMode !== 'none' && <StatusDot />}
|
||||
</div>
|
||||
<div className={getTabClassname('script')} role="tab" data-testid="collection-settings-tab-script" onClick={() => setTab('script')}>
|
||||
<div className={getTabClassname('script')} role="tab" onClick={() => setTab('script')}>
|
||||
Script
|
||||
{hasScripts && <StatusDot />}
|
||||
</div>
|
||||
<div className={getTabClassname('tests')} role="tab" data-testid="collection-settings-tab-tests" onClick={() => setTab('tests')}>
|
||||
<div className={getTabClassname('tests')} role="tab" onClick={() => setTab('tests')}>
|
||||
Tests
|
||||
{hasTests && <StatusDot />}
|
||||
</div>
|
||||
<div className={getTabClassname('presets')} role="tab" data-testid="collection-settings-tab-presets" onClick={() => setTab('presets')}>
|
||||
<div className={getTabClassname('presets')} role="tab" onClick={() => setTab('presets')}>
|
||||
Presets
|
||||
{hasPresets && <StatusDot />}
|
||||
</div>
|
||||
<div className={getTabClassname('proxy')} role="tab" data-testid="collection-settings-tab-proxy" onClick={() => setTab('proxy')}>
|
||||
<div className={getTabClassname('proxy')} role="tab" onClick={() => setTab('proxy')}>
|
||||
Proxy
|
||||
{Object.keys(proxyConfig).length > 0 && proxyEnabled && <StatusDot />}
|
||||
</div>
|
||||
<div className={getTabClassname('clientCert')} role="tab" data-testid="collection-settings-tab-clientCert" onClick={() => setTab('clientCert')}>
|
||||
<div className={getTabClassname('clientCert')} role="tab" onClick={() => setTab('clientCert')}>
|
||||
Client Certificates
|
||||
{clientCertConfig.length > 0 && <StatusDot />}
|
||||
</div>
|
||||
<div className={getTabClassname('protobuf')} role="tab" data-testid="collection-settings-tab-protobuf" onClick={() => setTab('protobuf')}>
|
||||
<div className={getTabClassname('protobuf')} role="tab" 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, idx) => {
|
||||
{sessions.map((session) => {
|
||||
const { name } = getSessionDisplayInfo(session);
|
||||
return (
|
||||
<ToolHint
|
||||
@@ -125,7 +125,6 @@ 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">
|
||||
@@ -134,7 +133,6 @@ 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,20 +1,8 @@
|
||||
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,8 +1,6 @@
|
||||
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';
|
||||
@@ -14,15 +12,12 @@ import StyledWrapper from './StyledWrapper';
|
||||
const Documentation = ({ item, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { displayedTheme } = useTheme();
|
||||
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 [isEditing, setIsEditing] = useState(false);
|
||||
const docs = item.draft ? get(item, 'draft.request.docs') : get(item, 'request.docs');
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
|
||||
const toggleViewMode = () => {
|
||||
dispatch(updateDocsEditing({ uid: activeTabUid, docsEditing: !isEditing }));
|
||||
setIsEditing((prev) => !prev);
|
||||
};
|
||||
|
||||
const onEdit = (value) => {
|
||||
|
||||
@@ -63,7 +63,7 @@ const StyledWrapper = styled.div`
|
||||
height: 100%;
|
||||
cursor: col-resize;
|
||||
background: transparent;
|
||||
z-index: 10;
|
||||
z-index: 100;
|
||||
|
||||
&:hover,
|
||||
&.resizing {
|
||||
|
||||
@@ -7,7 +7,6 @@ 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,
|
||||
@@ -21,20 +20,20 @@ const EditableTable = ({
|
||||
reorderable = false,
|
||||
onReorder,
|
||||
showAddRow = true,
|
||||
testId = 'editable-table',
|
||||
columnWidths,
|
||||
onColumnWidthsChange
|
||||
testId = 'editable-table'
|
||||
}) => {
|
||||
const tableRef = useRef(null);
|
||||
const emptyRowUidRef = useRef(null);
|
||||
const [hoveredRow, setHoveredRow] = useState(null);
|
||||
const [resizing, setResizing] = useState(null);
|
||||
const [tableHeight, setTableHeight] = useState(0);
|
||||
const widths = columnWidths || {};
|
||||
|
||||
const handleColumnWidthsChange = useCallback((newWidths) => {
|
||||
onColumnWidthsChange?.(newWidths);
|
||||
}, [onColumnWidthsChange]);
|
||||
const [columnWidths, setColumnWidths] = useState(() => {
|
||||
const initialWidths = {};
|
||||
columns.forEach((col) => {
|
||||
initialWidths[col.key] = col.width || 'auto';
|
||||
});
|
||||
return initialWidths;
|
||||
});
|
||||
|
||||
const handleResizeStart = useCallback((e, columnKey) => {
|
||||
e.preventDefault();
|
||||
@@ -60,13 +59,11 @@ const EditableTable = ({
|
||||
const maxShrink = startWidth - MIN_COLUMN_WIDTH;
|
||||
const clampedDiff = Math.max(-maxShrink, Math.min(maxGrow, diff));
|
||||
|
||||
const newWidths = {
|
||||
...widths,
|
||||
setColumnWidths((prev) => ({
|
||||
...prev,
|
||||
[columnKey]: `${startWidth + clampedDiff}px`,
|
||||
[nextColumnKey]: `${nextColumnStartWidth - clampedDiff}px`
|
||||
};
|
||||
|
||||
handleColumnWidthsChange(newWidths);
|
||||
}));
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
@@ -91,7 +88,7 @@ const EditableTable = ({
|
||||
});
|
||||
|
||||
if (Object.keys(newWidths).length > 0) {
|
||||
handleColumnWidthsChange({ ...widths, ...newWidths });
|
||||
setColumnWidths((prev) => ({ ...prev, ...newWidths }));
|
||||
}
|
||||
}
|
||||
setResizing(null);
|
||||
@@ -101,7 +98,7 @@ const EditableTable = ({
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
}, [columns, showCheckbox, widths, handleColumnWidthsChange]);
|
||||
}, [columns, showCheckbox]);
|
||||
|
||||
// Track table height for resize handles
|
||||
useEffect(() => {
|
||||
@@ -121,8 +118,8 @@ const EditableTable = ({
|
||||
}, [rows.length]);
|
||||
|
||||
const getColumnWidth = useCallback((column) => {
|
||||
return widths[column.key] || column.width || 'auto';
|
||||
}, [widths]);
|
||||
return columnWidths[column.key] || column.width || 'auto';
|
||||
}, [columnWidths]);
|
||||
|
||||
const createEmptyRow = useCallback(() => {
|
||||
const newUid = uuid();
|
||||
|
||||
@@ -3,8 +3,7 @@ import { TableVirtuoso } from 'react-virtuoso';
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import { IconTrash, IconAlertCircle, IconInfoCircle } from '@tabler/icons';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
|
||||
import { useSelector } from 'react-redux';
|
||||
import MultiLineEditor from 'components/MultiLineEditor/index';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { uuid } from 'utils/common';
|
||||
@@ -45,42 +44,14 @@ 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);
|
||||
|
||||
// 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 [columnWidths, setColumnWidths] = useState({ 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();
|
||||
@@ -102,24 +73,21 @@ const EnvironmentVariablesTable = ({
|
||||
const maxShrink = startWidth - MIN_COLUMN_WIDTH;
|
||||
const clampedDiff = Math.max(-maxShrink, Math.min(maxGrow, diff));
|
||||
|
||||
const newWidths = {
|
||||
setColumnWidths({
|
||||
[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);
|
||||
@@ -142,12 +110,6 @@ 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,12 +8,11 @@ const CollapsibleSection = ({
|
||||
onToggle,
|
||||
badge,
|
||||
actions,
|
||||
children,
|
||||
testId
|
||||
children
|
||||
}) => {
|
||||
return (
|
||||
<StyledWrapper className={expanded ? 'expanded' : 'collapsed'}>
|
||||
<div className="section-header" onClick={onToggle} data-testid={testId}>
|
||||
<div className="section-header" onClick={onToggle}>
|
||||
<div className="section-title-wrapper">
|
||||
<IconChevronRight
|
||||
size={14}
|
||||
|
||||
@@ -44,7 +44,6 @@ 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" data-testid="dotenv-raw-editor">
|
||||
<div className="raw-editor-container">
|
||||
<CodeEditor
|
||||
collection={collection}
|
||||
item={item}
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import { uuid } from 'utils/common';
|
||||
import { utils } from '@usebruno/common';
|
||||
|
||||
export const variablesToRaw = (variables) => {
|
||||
return utils.jsonToDotenv(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');
|
||||
};
|
||||
|
||||
export const rawToVariables = (rawContent) => {
|
||||
@@ -28,16 +37,9 @@ export const rawToVariables = (rawContent) => {
|
||||
const name = trimmedLine.substring(0, equalIndex).trim();
|
||||
let value = trimmedLine.substring(equalIndex + 1);
|
||||
|
||||
if (value.startsWith('\'') && value.endsWith('\'')) {
|
||||
// Single-quoted values are fully literal in dotenv — no unescaping
|
||||
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith('\'') && value.endsWith('\''))) {
|
||||
value = value.slice(1, -1);
|
||||
} 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');
|
||||
value = value.replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\');
|
||||
}
|
||||
|
||||
if (name) {
|
||||
|
||||
@@ -46,7 +46,7 @@ const EnvironmentListContent = ({
|
||||
</div>
|
||||
</ToolHint>
|
||||
<div className="dropdown-item configure-button">
|
||||
<button onClick={onSettingsClick} id="configure-env" data-testid="configure-env">
|
||||
<button onClick={onSettingsClick} id="configure-env">
|
||||
<IconSettings size={16} strokeWidth={1.5} />
|
||||
<span>Configure</span>
|
||||
</button>
|
||||
|
||||
@@ -103,7 +103,6 @@ const EnvironmentVariables = ({ environment, setIsModified, collection, searchQu
|
||||
|
||||
return (
|
||||
<EnvironmentVariablesTable
|
||||
key={environment?.uid}
|
||||
environment={environment}
|
||||
collection={collection}
|
||||
onSave={handleSave}
|
||||
|
||||
@@ -736,7 +736,6 @@ const EnvironmentList = ({
|
||||
|
||||
<CollapsibleSection
|
||||
title=".env Files"
|
||||
testId="dotenv-files-section"
|
||||
expanded={dotEnvExpanded}
|
||||
onToggle={() => setDotEnvExpanded(!dotEnvExpanded)}
|
||||
badge={dotEnvFiles.length}
|
||||
@@ -745,7 +744,6 @@ const EnvironmentList = ({
|
||||
className="btn-action"
|
||||
onClick={handleCreateDotEnvInlineClick}
|
||||
title="Create .env file"
|
||||
data-testid="create-dotenv-file"
|
||||
>
|
||||
<IconPlus size={14} strokeWidth={1.5} />
|
||||
</button>
|
||||
@@ -770,7 +768,6 @@ const EnvironmentList = ({
|
||||
ref={dotEnvInputRef}
|
||||
type="text"
|
||||
className="environment-name-input"
|
||||
data-testid="dotenv-name-input"
|
||||
value={newDotEnvName}
|
||||
onChange={handleDotEnvNameChange}
|
||||
onKeyDown={handleDotEnvNameKeyDown}
|
||||
|
||||
@@ -14,7 +14,6 @@ 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';
|
||||
@@ -144,17 +143,6 @@ const Auth = ({ collection, folder }) => {
|
||||
/>
|
||||
);
|
||||
}
|
||||
case 'oauth1': {
|
||||
return (
|
||||
<OAuth1
|
||||
collection={collection}
|
||||
item={folder}
|
||||
updateAuth={updateFolderAuth}
|
||||
request={request}
|
||||
save={() => handleSave()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case 'wsse': {
|
||||
return (
|
||||
<WsseAuth
|
||||
|
||||
@@ -47,11 +47,6 @@ 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,18 +1,9 @@
|
||||
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,10 +1,9 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useDispatch } 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';
|
||||
@@ -19,21 +18,11 @@ 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);
|
||||
};
|
||||
@@ -130,14 +119,11 @@ 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,11 +1,9 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import React, { useState, 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';
|
||||
@@ -20,25 +18,27 @@ 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', '');
|
||||
|
||||
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 = () => {
|
||||
// Default to post-response if pre-request script is empty
|
||||
const getInitialTab = () => {
|
||||
const hasPreRequestScript = requestScript && requestScript.trim().length > 0;
|
||||
return hasPreRequestScript ? 'pre-request' : 'post-response';
|
||||
};
|
||||
|
||||
const activeTab = scriptPaneTab || getDefaultTab();
|
||||
|
||||
const setActiveTab = (tab) => {
|
||||
dispatch(updateScriptPaneTab({ uid: folder.uid, scriptPaneTab: tab }));
|
||||
};
|
||||
const [activeTab, setActiveTab] = useState(getInitialTab);
|
||||
const prevFolderUidRef = useRef(folder.uid);
|
||||
|
||||
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,12 +2,6 @@ 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,8 +1,7 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useDispatch } 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';
|
||||
@@ -14,16 +13,6 @@ 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));
|
||||
|
||||
@@ -85,14 +74,11 @@ 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" data-testid="folder-settings-tab-headers" onClick={() => setTab('headers')}>
|
||||
<div className={getTabClassname('headers')} role="tab" onClick={() => setTab('headers')}>
|
||||
Headers
|
||||
{activeHeadersCount > 0 && <sup className="ml-1 font-medium">{activeHeadersCount}</sup>}
|
||||
</div>
|
||||
<div className={getTabClassname('script')} role="tab" data-testid="folder-settings-tab-script" onClick={() => setTab('script')}>
|
||||
<div className={getTabClassname('script')} role="tab" onClick={() => setTab('script')}>
|
||||
Script
|
||||
{hasScripts && <StatusDot />}
|
||||
</div>
|
||||
<div className={getTabClassname('test')} role="tab" data-testid="folder-settings-tab-test" onClick={() => setTab('test')}>
|
||||
<div className={getTabClassname('test')} role="tab" onClick={() => setTab('test')}>
|
||||
Test
|
||||
{hasTests && <StatusDot />}
|
||||
</div>
|
||||
<div className={getTabClassname('vars')} role="tab" data-testid="folder-settings-tab-vars" onClick={() => setTab('vars')}>
|
||||
<div className={getTabClassname('vars')} role="tab" onClick={() => setTab('vars')}>
|
||||
Vars
|
||||
{activeVarsCount > 0 && <sup className="ml-1 font-medium">{activeVarsCount}</sup>}
|
||||
</div>
|
||||
<div className={getTabClassname('auth')} role="tab" data-testid="folder-settings-tab-auth" onClick={() => setTab('auth')}>
|
||||
<div className={getTabClassname('auth')} role="tab" onClick={() => setTab('auth')}>
|
||||
Auth
|
||||
{hasAuth && <StatusDot />}
|
||||
</div>
|
||||
<div className={getTabClassname('docs')} role="tab" data-testid="folder-settings-tab-docs" onClick={() => setTab('docs')}>
|
||||
<div className={getTabClassname('docs')} role="tab" onClick={() => setTab('docs')}>
|
||||
Docs
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import {
|
||||
IconSearch,
|
||||
@@ -13,7 +13,6 @@ 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';
|
||||
@@ -27,21 +26,9 @@ const GlobalSearchModal = ({ isOpen, onClose }) => {
|
||||
const debounceTimeoutRef = useRef(null);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const allCollections = useSelector((state) => state.collections.collections);
|
||||
const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces);
|
||||
const collections = useSelector((state) => state.collections.collections);
|
||||
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,
|
||||
@@ -402,7 +389,6 @@ 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
|
||||
|
||||
@@ -3,8 +3,6 @@ import * as MarkdownItReplaceLink from 'markdown-it-replace-link';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import React from 'react';
|
||||
import { isValidUrl } from 'utils/url/index';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
const Markdown = ({ collectionPath, onDoubleClick, content }) => {
|
||||
const markdownItOptions = {
|
||||
@@ -35,14 +33,14 @@ const Markdown = ({ collectionPath, onDoubleClick, content }) => {
|
||||
};
|
||||
|
||||
const md = new MarkdownIt(markdownItOptions).use(MarkdownItReplaceLink);
|
||||
const htmlFromMarkdown = useMemo(() => md.render(content || ''), [content, collectionPath]);
|
||||
const cleanHTML = useMemo(() => DOMPurify.sanitize(htmlFromMarkdown), [htmlFromMarkdown]);
|
||||
|
||||
const htmlFromMarkdown = md.render(content || '');
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div
|
||||
className="markdown-body"
|
||||
dangerouslySetInnerHTML={{ __html: cleanHTML }}
|
||||
dangerouslySetInnerHTML={{ __html: htmlFromMarkdown }}
|
||||
onClick={handleOnClick}
|
||||
onDoubleClick={handleOnDoubleClick}
|
||||
/>
|
||||
|
||||
@@ -45,6 +45,26 @@ 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
|
||||
@@ -70,15 +90,8 @@ 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
|
||||
@@ -86,12 +99,6 @@ 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();
|
||||
@@ -147,13 +154,16 @@ class MultiLineEditor extends Component {
|
||||
this.editor.setOption('readOnly', this.props.readOnly);
|
||||
}
|
||||
if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue && this.editor) {
|
||||
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();
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
if (!isEqual(this.props.isSecret, prevProps.isSecret)) {
|
||||
@@ -179,11 +189,7 @@ class MultiLineEditor extends Component {
|
||||
this.maskedEditor.destroy();
|
||||
this.maskedEditor = null;
|
||||
}
|
||||
if (this.editor) {
|
||||
this.editor.off('change', this._onEdit);
|
||||
this.editor.off('blur', this._onBlur);
|
||||
this.editor.getWrapperElement().remove();
|
||||
}
|
||||
this.editor.getWrapperElement().remove();
|
||||
}
|
||||
|
||||
addOverlay = (variables) => {
|
||||
|
||||
@@ -80,10 +80,6 @@ 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,328 +1,53 @@
|
||||
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;
|
||||
|
||||
&::-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};
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.tables-container {
|
||||
overflow-y: auto;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
thead,
|
||||
td {
|
||||
border: 1px solid ${(props) => props.theme.table.border};
|
||||
}
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
|
||||
&.tables-disabled {
|
||||
opacity: 0.45;
|
||||
pointer-events: none;
|
||||
thead {
|
||||
color: ${(props) => props.theme.table.thead.color};
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
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 {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border-radius: ${(props) => props.theme.border.radius.base};
|
||||
border: solid 1px ${(props) => props.theme.border.border0};
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
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.sidebar.bg};
|
||||
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: 0 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.sidebar.bg};
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
tr.row-editing td {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
tr.section-heading-row td {
|
||||
font-weight: 600;
|
||||
padding: 6px 10px !important;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
tr.section-heading-row:hover td {
|
||||
background: transparent;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
tr.section-last-row td {
|
||||
border-bottom: solid 1px ${(props) => props.theme.border.border0} !important;
|
||||
}
|
||||
}
|
||||
|
||||
.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 #E4AE49;
|
||||
border-radius: 4px;
|
||||
min-width: 100%;
|
||||
max-width: 100%;
|
||||
padding: 0 8px;
|
||||
caret-color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
.shortcut-input--error.shortcut-input--editing {
|
||||
outline: 1px solid #CE4F3B;
|
||||
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: 22px;
|
||||
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: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
tbody tr.row-success td {
|
||||
background: #2E8A540F;
|
||||
}
|
||||
|
||||
tbody tr.row-error td {
|
||||
background: #D32F2F0F;
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
color: #2E8A54;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
color: #CE4F3B;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.input-error-icon {
|
||||
color: #CE4F3B;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@keyframes blink-caret {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
.editing-caret {
|
||||
.key-button {
|
||||
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;
|
||||
}
|
||||
|
||||
.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;
|
||||
color: ${(props) => props.theme.table.input.color};
|
||||
opacity: 0.7;
|
||||
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};
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,959 +1,43 @@
|
||||
import React, { useMemo, useRef, useState, useEffect } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { IconReload, IconPencil, IconLock, IconCircleCheck, IconAlertCircle } from '@tabler/icons';
|
||||
import React from 'react';
|
||||
import { getKeyBindingsForOS } from 'providers/Hotkeys/keyMappings';
|
||||
import { isMacOS } from 'utils/common/platform';
|
||||
|
||||
import { savePreferences } from 'providers/ReduxStore/slices/app';
|
||||
import { KEY_BINDING_SECTIONS } from 'providers/Hotkeys/keyMappings.js';
|
||||
import { Tooltip } from 'react-tooltip';
|
||||
import ToggleSwitch from 'components/ToggleSwitch/index';
|
||||
|
||||
const SEP = '+bind+';
|
||||
const getOS = () => (isMacOS() ? 'mac' : 'windows');
|
||||
|
||||
// Modifier tokens used in stored preferences.
|
||||
// These are lowercase on purpose so they match persisted values.
|
||||
const MODIFIERS = new Set(['ctrl', 'command', 'alt', 'shift']);
|
||||
|
||||
const MODIFIER_SYMBOLS = {
|
||||
mac: {
|
||||
command: '⌘',
|
||||
ctrl: '⌃',
|
||||
alt: '⌥',
|
||||
shift: '⇧'
|
||||
},
|
||||
windows: {
|
||||
ctrl: 'Ctrl',
|
||||
alt: 'Alt',
|
||||
shift: 'Shift',
|
||||
command: 'Win'
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to parse displayValue string into arrays of key arrays for rendering as keycaps
|
||||
// Takes a raw string like "command+bind+1 - command+bind+8" and returns [["command", "1"], ["command", "8"]]
|
||||
// This allows rendering in the same pills style as regular keybindings
|
||||
const parseDisplayValue = (displayValue, os) => {
|
||||
if (!displayValue || typeof displayValue !== 'string') return null;
|
||||
|
||||
const symbols = MODIFIER_SYMBOLS[os] || MODIFIER_SYMBOLS.windows;
|
||||
|
||||
// Reverse mapping from symbol to key name
|
||||
const symbolToKey = {};
|
||||
Object.entries(symbols).forEach(([key, symbol]) => {
|
||||
symbolToKey[symbol.toLowerCase()] = key;
|
||||
});
|
||||
|
||||
// Split by " - " to get range parts (e.g., ["command+bind+1", "command+bind+8"])
|
||||
const rangeParts = displayValue.split(/\s*-\s*/);
|
||||
|
||||
const result = rangeParts.map((part) => {
|
||||
// Split by "+bind+" to get individual keys (consistent with storage format)
|
||||
// Filter out empty strings that may result from the split
|
||||
const keys = part.split(SEP).filter(Boolean).map((key) => {
|
||||
const lowerKey = key.toLowerCase().trim();
|
||||
// Check if it's a symbol and convert back to key name
|
||||
if (symbolToKey[lowerKey]) {
|
||||
return symbolToKey[lowerKey];
|
||||
}
|
||||
// For non-modifier keys, return as-is but lowercase
|
||||
return lowerKey;
|
||||
});
|
||||
return keys;
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
// Render displayValue using the same pills style as regular keybindings
|
||||
const renderDisplayValue = (displayValue, os) => {
|
||||
const parsed = parseDisplayValue(displayValue, os);
|
||||
if (!parsed || !parsed.length) return null;
|
||||
|
||||
// If there's only one shortcut, render it normally
|
||||
if (parsed.length === 1) {
|
||||
return <span className="shortcut-pills">{renderKeycaps(parsed[0], os)}</span>;
|
||||
}
|
||||
|
||||
// If there are multiple shortcuts (range), render each as a group with separator
|
||||
return (
|
||||
<span className="shortcut-pills">
|
||||
{parsed.map((keysArr, index) => (
|
||||
<React.Fragment key={index}>
|
||||
{index > 0 && <span className="shortcut-separator"> - </span>}
|
||||
{renderKeycaps(keysArr, os)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// Required modifier policy by OS.
|
||||
// On macOS, command/ctrl/alt/shift are allowed as the required modifier.
|
||||
// On Windows, command should not count as a valid modifier for app shortcuts.
|
||||
const REQUIRED_MODIFIERS_BY_OS = {
|
||||
mac: new Set(['command', 'alt', 'shift', 'ctrl']),
|
||||
windows: new Set(['ctrl', 'alt', 'shift'])
|
||||
};
|
||||
|
||||
const FUNCTION_KEY_PATTERN = /^f([1-9]|1[0-2])$/;
|
||||
const isFunctionKey = (k) => FUNCTION_KEY_PATTERN.test(k);
|
||||
const hasRequiredModifier = (os, arr) => {
|
||||
// Function keys (F1-F12) are allowed without a modifier
|
||||
if (arr.some(isFunctionKey)) return true;
|
||||
return arr.some((k) => REQUIRED_MODIFIERS_BY_OS[os]?.has(k));
|
||||
};
|
||||
const isOnlyModifiers = (arr) => arr.length > 0 && arr.every((k) => MODIFIERS.has(k));
|
||||
|
||||
// Keep a stable modifier order for display, storage, and duplicate detection.
|
||||
// Non-modifier keys keep their original order.
|
||||
const MODIFIER_ORDER = ['ctrl', 'command', 'alt', 'shift'];
|
||||
|
||||
const sortCombo = (arr) => {
|
||||
const modifiers = [];
|
||||
const nonModifiers = [];
|
||||
|
||||
arr.forEach((key) => {
|
||||
if (MODIFIERS.has(key)) {
|
||||
modifiers.push(key);
|
||||
} else {
|
||||
nonModifiers.push(key);
|
||||
}
|
||||
});
|
||||
|
||||
modifiers.sort((a, b) => MODIFIER_ORDER.indexOf(a) - MODIFIER_ORDER.indexOf(b));
|
||||
|
||||
return [...modifiers, ...nonModifiers];
|
||||
};
|
||||
|
||||
// Remove duplicates while preserving insertion order, then apply stable sorting.
|
||||
const uniqSorted = (arr) => {
|
||||
const seen = new Set();
|
||||
const unique = [];
|
||||
|
||||
arr.forEach((key) => {
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
unique.push(key);
|
||||
}
|
||||
});
|
||||
|
||||
return sortCombo(unique);
|
||||
};
|
||||
|
||||
const fromKeysString = (keysStr) => (keysStr ? keysStr.split(SEP).filter(Boolean) : []);
|
||||
const toKeysString = (keysArr) => uniqSorted(keysArr).join(SEP);
|
||||
|
||||
const formatSingleKeyForDisplay = (key, os) => {
|
||||
if (MODIFIER_SYMBOLS[os]?.[key]) return MODIFIER_SYMBOLS[os][key];
|
||||
if (key.length === 1) return key.toUpperCase();
|
||||
|
||||
const SPECIAL_LABELS = {
|
||||
enter: os === 'mac' ? '↩' : 'Enter',
|
||||
backspace: os === 'mac' ? '⌫' : 'Backspace',
|
||||
tab: os === 'mac' ? '⇥' : 'Tab',
|
||||
delete: os === 'mac' ? '⌦' : 'Delete',
|
||||
esc: os === 'mac' ? '⎋' : 'Esc',
|
||||
space: os === 'mac' ? '␣' : 'Space',
|
||||
arrowup: '↑',
|
||||
arrowdown: '↓',
|
||||
arrowleft: '←',
|
||||
arrowright: '→',
|
||||
pageup: 'PageUp',
|
||||
pagedown: 'PageDown',
|
||||
home: 'Home',
|
||||
end: 'End'
|
||||
};
|
||||
|
||||
return SPECIAL_LABELS[key] || key.charAt(0).toUpperCase() + key.slice(1);
|
||||
};
|
||||
|
||||
const renderKeycaps = (keysArr, os) => {
|
||||
if (!keysArr?.length) return null;
|
||||
|
||||
return keysArr.map((key, index) => (
|
||||
<span key={`${key}-${index}`} className="keycap">
|
||||
{formatSingleKeyForDisplay(key, os)}
|
||||
</span>
|
||||
));
|
||||
};
|
||||
|
||||
// Signature is intentionally exact.
|
||||
// This means:
|
||||
// - command + f
|
||||
// - command + shift + f
|
||||
// are treated as different shortcuts and can coexist.
|
||||
// Only an exact same normalized combo is considered duplicate.
|
||||
const comboSignature = (arr) => toKeysString(arr);
|
||||
|
||||
// OS reserved shortcuts in stored-token format.
|
||||
// These are blocked because they are usually intercepted by the OS/window manager.
|
||||
// Also includes common editing shortcuts that should not be overridden.
|
||||
const RESERVED_BY_OS = {
|
||||
mac: new Set([
|
||||
comboSignature(['command', 'h']),
|
||||
comboSignature(['command', 'alt', 'h']),
|
||||
comboSignature(['ctrl', 'command', 'f']),
|
||||
comboSignature(['command', 'shift', 'q']),
|
||||
comboSignature(['command', 'alt', 'd']),
|
||||
comboSignature(['command', 'm']),
|
||||
comboSignature(['command', 'tab']),
|
||||
comboSignature(['command', 'space']),
|
||||
comboSignature(['ctrl', 'command', 'q']),
|
||||
comboSignature(['command', 'shift', '3']),
|
||||
comboSignature(['command', 'shift', '4']),
|
||||
comboSignature(['command', 'shift', '5']),
|
||||
comboSignature(['command', 'alt', 'esc']),
|
||||
// Undo/Redo - standard text editing shortcuts that browsers handle natively
|
||||
comboSignature(['command', 'z']),
|
||||
comboSignature(['command', 'shift', 'z']),
|
||||
comboSignature(['command', 'alt', 'z']),
|
||||
// Toggle Developer Tools
|
||||
comboSignature(['command', 'alt', 'i']),
|
||||
// Function keys reserved by macOS
|
||||
comboSignature(['f11']), // Show Desktop
|
||||
comboSignature(['f12']) // Dashboard (older macOS)
|
||||
]),
|
||||
windows: new Set([
|
||||
comboSignature(['alt', 'tab']),
|
||||
comboSignature(['alt', 'f4']),
|
||||
comboSignature(['f1']), // Windows Help
|
||||
comboSignature(['ctrl', 'alt', 'delete']),
|
||||
comboSignature(['command', 'l']),
|
||||
comboSignature(['command', 'd']),
|
||||
comboSignature(['command', 'e']),
|
||||
comboSignature(['command', 'r']),
|
||||
comboSignature(['command', 'i']),
|
||||
comboSignature(['command', 's']),
|
||||
comboSignature(['command', 'a']),
|
||||
comboSignature(['command', 'x']),
|
||||
comboSignature(['command', 'm']),
|
||||
comboSignature(['command', 'tab']),
|
||||
comboSignature(['ctrl', 'shift', 'esc']),
|
||||
// Undo/Redo - standard text editing shortcuts that browsers handle natively
|
||||
comboSignature(['ctrl', 'z']),
|
||||
comboSignature(['ctrl', 'shift', 'z']),
|
||||
// Toggle Developer Tools
|
||||
comboSignature(['ctrl', 'shift', 'i'])
|
||||
])
|
||||
};
|
||||
|
||||
// Normalize keyboard event to stored token format.
|
||||
// The output must stay aligned with default preference values.
|
||||
const normalizeKey = (e) => {
|
||||
const k = e.key;
|
||||
|
||||
// Handle dead keys on macOS - Option+letter produces dead key characters
|
||||
// Convert dead key back to the base character for consistent normalization
|
||||
if (k === 'Dead') {
|
||||
// Use code to determine the base key (e.g., 'KeyI' for 'i')
|
||||
const code = e.code;
|
||||
if (code) {
|
||||
const baseKey = code.replace('Key', '').toLowerCase();
|
||||
return baseKey;
|
||||
}
|
||||
return 'dead';
|
||||
}
|
||||
|
||||
// Ignore lock keys. They should not be recordable shortcuts.
|
||||
if (k === 'CapsLock' || k === 'NumLock' || k === 'ScrollLock') return null;
|
||||
|
||||
if (k === ' ') return 'space';
|
||||
if (k === 'Escape') return 'esc';
|
||||
if (k === 'Control') return 'ctrl';
|
||||
if (k === 'Alt') return 'alt';
|
||||
if (k === 'Shift') return 'shift';
|
||||
if (k === 'Enter') return 'enter';
|
||||
if (k === 'Backspace') return 'backspace';
|
||||
if (k === 'Tab') return 'tab';
|
||||
if (k === 'Delete') return 'delete';
|
||||
|
||||
// Meta maps to command so storage format stays consistent across the app.
|
||||
if (k === 'Meta') return 'command';
|
||||
|
||||
// For letter and digit keys always use e.code (the physical key) instead of e.key.
|
||||
// When Option/Alt is held, e.key produces a composed character (e.g. Option+X → '≈')
|
||||
// which Mousetrap does not recognise — it expects the base key name ('x').
|
||||
// e.code is unaffected by modifier state: 'KeyX' → 'x', 'Digit1' → '1'.
|
||||
const code = e.code || '';
|
||||
if (code.startsWith('Key')) return code.slice(3).toLowerCase();
|
||||
if (code.startsWith('Digit')) return code.slice(5);
|
||||
|
||||
// Single printable chars become lowercase.
|
||||
if (k.length === 1) return k.toLowerCase();
|
||||
|
||||
// ArrowUp -> arrowup, PageUp -> pageup, etc.
|
||||
return k.toLowerCase();
|
||||
};
|
||||
|
||||
const ERROR = {
|
||||
EMPTY: 'EMPTY',
|
||||
ONLY_MODIFIERS: 'ONLY_MODIFIERS',
|
||||
MISSING_REQUIRED_MOD: 'MISSING_REQUIRED_MOD',
|
||||
MULTIPLE_NON_MODIFIERS: 'MULTIPLE_NON_MODIFIERS',
|
||||
RESERVED: 'RESERVED',
|
||||
DUPLICATE: 'DUPLICATE'
|
||||
};
|
||||
|
||||
const Keybindings = () => {
|
||||
const dispatch = useDispatch();
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const { theme } = useTheme();
|
||||
|
||||
const os = getOS();
|
||||
const keybindingsEnabled = preferences?.keybindingsEnabled !== false;
|
||||
|
||||
const handleToggleKeybindings = () => {
|
||||
const updatedPreferences = {
|
||||
...preferences,
|
||||
keybindingsEnabled: !keybindingsEnabled
|
||||
};
|
||||
dispatch(savePreferences(updatedPreferences));
|
||||
};
|
||||
|
||||
// Flatten KEY_BINDING_SECTIONS into a single lookup map for internal logic.
|
||||
const sectionDefaults = useMemo(() => {
|
||||
const merged = {};
|
||||
|
||||
for (const section of KEY_BINDING_SECTIONS) {
|
||||
for (const [action, binding] of Object.entries(section.bindings || {})) {
|
||||
merged[action] = { ...binding };
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}, []);
|
||||
|
||||
// Source of truth:
|
||||
// Start from grouped defaults, then merge user-specific overrides on top.
|
||||
const keyBindings = useMemo(() => {
|
||||
const merged = {};
|
||||
|
||||
for (const [action, binding] of Object.entries(sectionDefaults)) {
|
||||
merged[action] = { ...binding };
|
||||
}
|
||||
|
||||
const userBindings = preferences?.keyBindings || {};
|
||||
for (const [action, binding] of Object.entries(userBindings)) {
|
||||
if (merged[action]) {
|
||||
merged[action] = {
|
||||
...merged[action],
|
||||
...binding
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}, [preferences?.keyBindings, sectionDefaults]);
|
||||
|
||||
// Build grouped rows for current OS only and skip hidden bindings.
|
||||
const groupedKeyMappings = useMemo(() => {
|
||||
return KEY_BINDING_SECTIONS.map((section) => {
|
||||
const rows = Object.entries(section.bindings || {})
|
||||
.map(([action]) => {
|
||||
const binding = keyBindings[action];
|
||||
if (!binding?.[os] || binding.hidden) return null;
|
||||
|
||||
return {
|
||||
action,
|
||||
name: binding.name,
|
||||
keys: binding[os],
|
||||
readOnly: binding.readOnly,
|
||||
displayValue: binding.displayValue
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
return {
|
||||
heading: section.heading,
|
||||
rows
|
||||
};
|
||||
}).filter((section) => section.rows.length > 0);
|
||||
}, [keyBindings, os]);
|
||||
|
||||
// editingAction:
|
||||
// The row currently in edit mode.
|
||||
const [editingAction, setEditingAction] = useState(null);
|
||||
|
||||
// hoveredAction:
|
||||
// Tracks row hover state to show pencil/reset/lock controls.
|
||||
const [hoveredAction, setHoveredAction] = useState(null);
|
||||
|
||||
// recordingAction:
|
||||
// The row actively listening for key presses.
|
||||
const [recordingAction, setRecordingAction] = useState(null);
|
||||
|
||||
// Tracks currently held keys while recording.
|
||||
// A Set allows more than 2 keys and avoids duplicates naturally.
|
||||
const pressedKeysRef = useRef(new Set());
|
||||
|
||||
// Refs for row inputs, used to focus the selected row when editing starts.
|
||||
const inputRefs = useRef({});
|
||||
|
||||
// draftByAction:
|
||||
// Temporary in-progress shortcut for a row while editing.
|
||||
const [draftByAction, setDraftByAction] = useState({});
|
||||
|
||||
// errorByAction:
|
||||
// Validation result per row while editing.
|
||||
const [errorByAction, setErrorByAction] = useState({});
|
||||
|
||||
// successAction:
|
||||
// Tracks which row just saved successfully for a 1-second flash.
|
||||
const [successAction, setSuccessAction] = useState(null);
|
||||
const successTimerRef = useRef(null);
|
||||
|
||||
const getCurrentRowKeysString = (action) => keyBindings?.[action]?.[os] || '';
|
||||
const getDefaultRowKeysString = (action) => sectionDefaults?.[action]?.[os] || '';
|
||||
|
||||
const isRowDirty = (action) => {
|
||||
const current = getCurrentRowKeysString(action);
|
||||
const def = getDefaultRowKeysString(action);
|
||||
|
||||
if (!sectionDefaults[action]) return false;
|
||||
return current !== def;
|
||||
};
|
||||
|
||||
// Whether any row differs from the default binding.
|
||||
const hasDirtyRows = useMemo(() => {
|
||||
for (const action of Object.keys(sectionDefaults)) {
|
||||
if (isRowDirty(action)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}, [keyBindings, os, sectionDefaults]);
|
||||
|
||||
// Build a set of exact normalized signatures for all shortcuts except the row being edited.
|
||||
// This allows:
|
||||
// - command + f
|
||||
// - command + shift + f
|
||||
// to coexist, because signatures differ.
|
||||
const buildUsedSignatures = (excludeAction) => {
|
||||
const used = new Set();
|
||||
|
||||
for (const [action, binding] of Object.entries(keyBindings)) {
|
||||
if (action === excludeAction) continue;
|
||||
|
||||
const keysStr = binding?.[os];
|
||||
if (!keysStr) continue;
|
||||
|
||||
const normalized = comboSignature(fromKeysString(keysStr));
|
||||
if (normalized) used.add(normalized);
|
||||
}
|
||||
|
||||
return used;
|
||||
};
|
||||
|
||||
// Validate only the exact current combo.
|
||||
// No subset/superset conflict detection is done here.
|
||||
const validateCombo = (action, arrRaw) => {
|
||||
const arr = uniqSorted(arrRaw);
|
||||
const sig = comboSignature(arr);
|
||||
|
||||
if (!sig) {
|
||||
return { code: ERROR.EMPTY, message: `Shortcut can’t be empty.` };
|
||||
}
|
||||
|
||||
if (isOnlyModifiers(arr)) {
|
||||
return {
|
||||
code: ERROR.ONLY_MODIFIERS,
|
||||
message: 'Add a non-modifier key (e.g. Ctrl + K).'
|
||||
};
|
||||
}
|
||||
|
||||
if (!hasRequiredModifier(os, arr)) {
|
||||
return {
|
||||
code: ERROR.MISSING_REQUIRED_MOD,
|
||||
message:
|
||||
os === 'mac'
|
||||
? 'macOS shortcuts must include at least one modifier (command/alt/shift/ctrl).'
|
||||
: 'Windows shortcuts must include at least one modifier (ctrl/alt/shift).'
|
||||
};
|
||||
}
|
||||
|
||||
const nonModifierCount = arr.filter((k) => !MODIFIERS.has(k)).length;
|
||||
if (nonModifierCount > 1) {
|
||||
return {
|
||||
code: ERROR.MULTIPLE_NON_MODIFIERS,
|
||||
message: 'Only one non-modifier key allowed (e.g. Cmd + Shift + K).'
|
||||
};
|
||||
}
|
||||
|
||||
if (RESERVED_BY_OS[os]?.has(sig)) {
|
||||
return {
|
||||
code: ERROR.RESERVED,
|
||||
message: 'This shortcut is reserved by the OS.'
|
||||
};
|
||||
}
|
||||
|
||||
if (buildUsedSignatures(action).has(sig)) {
|
||||
return {
|
||||
code: ERROR.DUPLICATE,
|
||||
message: 'That shortcut is already in use.'
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const persistToPreferences = (action, nextKeys) => {
|
||||
const updatedPreferences = {
|
||||
...preferences,
|
||||
keyBindings: {
|
||||
...(preferences?.keyBindings || {}),
|
||||
[action]: {
|
||||
...(preferences?.keyBindings?.[action] || {}),
|
||||
name: preferences?.keyBindings?.[action]?.name || sectionDefaults?.[action]?.name || action,
|
||||
[os]: nextKeys
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
dispatch(savePreferences(updatedPreferences));
|
||||
};
|
||||
|
||||
// Commit the draft only if it is valid.
|
||||
// Returns true if saved or unchanged, false if invalid.
|
||||
const commitCombo = (action) => {
|
||||
const draftArr = draftByAction[action] || [];
|
||||
if (!draftArr.length) return;
|
||||
|
||||
const arr = uniqSorted(draftArr);
|
||||
const err = validateCombo(action, arr);
|
||||
|
||||
if (err) {
|
||||
setErrorByAction((prev) => ({ ...prev, [action]: err }));
|
||||
return false;
|
||||
}
|
||||
|
||||
setErrorByAction((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[action];
|
||||
return next;
|
||||
});
|
||||
|
||||
const nextKeys = toKeysString(arr);
|
||||
const currentKeys = getCurrentRowKeysString(action);
|
||||
|
||||
if (nextKeys === currentKeys) return true;
|
||||
|
||||
persistToPreferences(action, nextKeys);
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const resetRowToDefault = (action) => {
|
||||
const def = sectionDefaults?.[action]?.[os];
|
||||
if (!def) return;
|
||||
|
||||
setErrorByAction((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[action];
|
||||
return next;
|
||||
});
|
||||
|
||||
setDraftByAction((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[action];
|
||||
return next;
|
||||
});
|
||||
|
||||
persistToPreferences(action, def);
|
||||
};
|
||||
|
||||
const resetAllKeybindings = () => {
|
||||
const updatedPreferences = {
|
||||
...preferences,
|
||||
keyBindings: {}
|
||||
};
|
||||
|
||||
dispatch(savePreferences(updatedPreferences));
|
||||
};
|
||||
|
||||
const startEditing = (action) => {
|
||||
if (!keybindingsEnabled) return;
|
||||
// If another row is already editing, try to commit it first.
|
||||
// If invalid, keep the previous row active.
|
||||
if (editingAction && editingAction !== action) {
|
||||
const ok = commitCombo(editingAction);
|
||||
if (ok) {
|
||||
setRecordingAction(null);
|
||||
setEditingAction(null);
|
||||
pressedKeysRef.current = new Set();
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setEditingAction(action);
|
||||
setRecordingAction(action);
|
||||
pressedKeysRef.current = new Set();
|
||||
|
||||
// Seed the draft with the current saved value so the row reflects existing state.
|
||||
setDraftByAction((prev) => ({
|
||||
...prev,
|
||||
[action]: fromKeysString(getCurrentRowKeysString(action))
|
||||
}));
|
||||
|
||||
// Clear any previous validation error for this row.
|
||||
setErrorByAction((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[action];
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Focus the input div after React has committed the editingAction state change.
|
||||
// Runs only when editingAction changes — no extra renders beyond what already happens.
|
||||
useEffect(() => {
|
||||
if (editingAction) {
|
||||
inputRefs.current[editingAction]?.focus?.();
|
||||
}
|
||||
}, [editingAction]);
|
||||
|
||||
const showSuccessFlash = (action) => {
|
||||
if (successTimerRef.current) clearTimeout(successTimerRef.current);
|
||||
setSuccessAction(action);
|
||||
successTimerRef.current = setTimeout(() => {
|
||||
setSuccessAction(null);
|
||||
successTimerRef.current = null;
|
||||
}, 800);
|
||||
};
|
||||
|
||||
const stopEditing = (action) => {
|
||||
const draftArr = draftByAction[action] || [];
|
||||
const currentKeys = getCurrentRowKeysString(action);
|
||||
const nextKeys = draftArr.length ? toKeysString(draftArr) : currentKeys;
|
||||
const willChange = nextKeys !== currentKeys;
|
||||
|
||||
const ok = commitCombo(action);
|
||||
|
||||
if (!ok) {
|
||||
// On invalid commit, discard the invalid draft and restore saved value.
|
||||
cancelEditing(action);
|
||||
return;
|
||||
}
|
||||
|
||||
setRecordingAction(null);
|
||||
setEditingAction(null);
|
||||
pressedKeysRef.current = new Set();
|
||||
|
||||
if (willChange) {
|
||||
showSuccessFlash(action);
|
||||
}
|
||||
};
|
||||
|
||||
// Cancel editing and restore the persisted value.
|
||||
const cancelEditing = (action) => {
|
||||
setErrorByAction((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[action];
|
||||
return next;
|
||||
});
|
||||
|
||||
setDraftByAction((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[action];
|
||||
return next;
|
||||
});
|
||||
|
||||
setRecordingAction(null);
|
||||
setEditingAction(null);
|
||||
pressedKeysRef.current = new Set();
|
||||
};
|
||||
|
||||
const handleKeyDown = (action, e) => {
|
||||
if (recordingAction !== action || editingAction !== action) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Allow clearing current draft while staying in edit mode.
|
||||
if (e.key === 'Backspace' || e.key === 'Delete') {
|
||||
pressedKeysRef.current = new Set();
|
||||
setDraftByAction((prev) => ({ ...prev, [action]: [] }));
|
||||
setErrorByAction((prev) => ({
|
||||
...prev,
|
||||
[action]: { code: ERROR.EMPTY, message: `Shortcut can't be empty.` }
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// Ignore key repeat so holding a key does not cause noise.
|
||||
if (e.repeat) return;
|
||||
|
||||
const keyName = normalizeKey(e);
|
||||
if (!keyName) return;
|
||||
|
||||
// Starting a new combo after a failed one — clear stale draft
|
||||
if (pressedKeysRef.current.size === 0 && errorByAction[action]?.message) {
|
||||
setDraftByAction((prev) => ({ ...prev, [action]: [] }));
|
||||
setErrorByAction((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[action];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
// Max 3 keys allowed per keybinding
|
||||
if (pressedKeysRef.current.size >= 3 && !pressedKeysRef.current.has(keyName)) return;
|
||||
|
||||
pressedKeysRef.current.add(keyName);
|
||||
|
||||
const nextDraft = uniqSorted(Array.from(pressedKeysRef.current));
|
||||
|
||||
setDraftByAction((prev) => ({
|
||||
...prev,
|
||||
[action]: nextDraft
|
||||
}));
|
||||
|
||||
const err = validateCombo(action, nextDraft);
|
||||
setErrorByAction((prev) => {
|
||||
const next = { ...prev };
|
||||
|
||||
if (err) {
|
||||
next[action] = err;
|
||||
} else {
|
||||
delete next[action];
|
||||
}
|
||||
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleKeyUp = (action, e) => {
|
||||
if (recordingAction !== action || editingAction !== action) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const keyName = normalizeKey(e);
|
||||
if (!keyName) return;
|
||||
|
||||
pressedKeysRef.current.delete(keyName);
|
||||
|
||||
const currentDraft = draftByAction[action] || [];
|
||||
|
||||
// If empty, keep editing.
|
||||
if (currentDraft.length === 0) return;
|
||||
|
||||
// If invalid, keep the draft visible but mark for reset on next keypress.
|
||||
if (errorByAction[action]?.message) return;
|
||||
|
||||
// Commit as soon as the draft is valid, regardless of how many keys are still held.
|
||||
// On macOS, keyup events for non-Meta keys are swallowed when Cmd is held, so
|
||||
// pressedKeysRef.size may never reach 0 — committing on any keyup fixes this.
|
||||
stopEditing(action);
|
||||
};
|
||||
|
||||
const renderValue = (action) => {
|
||||
const binding = keyBindings[action];
|
||||
|
||||
if (binding?.displayValue) {
|
||||
// Use the same pills style rendering as regular keybindings
|
||||
if (typeof binding.displayValue === 'string') {
|
||||
return <span className="shortcut-text">{renderDisplayValue(binding.displayValue, os)}</span>;
|
||||
}
|
||||
|
||||
// displayValue can be an object with OS-specific values
|
||||
const rawDisplayText = binding.displayValue[os] || binding.displayValue.mac || binding.displayValue.windows;
|
||||
return <span className="shortcut-text">{renderDisplayValue(rawDisplayText, os)}</span>;
|
||||
}
|
||||
|
||||
const isRecording = recordingAction === action;
|
||||
const arr = isRecording
|
||||
? draftByAction[action]
|
||||
: fromKeysString(getCurrentRowKeysString(action));
|
||||
|
||||
if (isRecording) {
|
||||
const textParts = (arr || []).map((key) => formatSingleKeyForDisplay(key, os));
|
||||
return (
|
||||
<span className="shortcut-text">
|
||||
{textParts.join(' ')}
|
||||
<span className="editing-caret" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return renderKeycaps(arr || [], os);
|
||||
};
|
||||
const Keybindings = ({ close }) => {
|
||||
const keyMapping = getKeyBindingsForOS(isMacOS() ? 'mac' : 'windows');
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full">
|
||||
<div className="section-header">
|
||||
<span>Keybindings</span>
|
||||
|
||||
<div className="section-actions">
|
||||
<ToggleSwitch
|
||||
isOn={keybindingsEnabled}
|
||||
handleToggle={handleToggleKeybindings}
|
||||
size="2xs"
|
||||
activeColor={theme.primary.solid}
|
||||
/>
|
||||
<div className="section-actions-divider" />
|
||||
<button
|
||||
onClick={resetAllKeybindings}
|
||||
className="reset-btn"
|
||||
data-testid="reset-all-keybindings-btn"
|
||||
>
|
||||
Reset Default
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`tables-container ${!keybindingsEnabled ? 'tables-disabled' : ''}`}>
|
||||
{groupedKeyMappings.length > 0 ? (
|
||||
<div className="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<td>Command</td>
|
||||
<td>Keybinding</td>
|
||||
<div className="section-header">Keybindings</div>
|
||||
<div className="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Command</th>
|
||||
<th>Keybinding</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{keyMapping ? (
|
||||
Object.entries(keyMapping).map(([action, { name, keys }], index) => (
|
||||
<tr key={index}>
|
||||
<td>{name}</td>
|
||||
<td>
|
||||
{keys.split('+').map((key, i) => (
|
||||
<div className="key-button" key={i}>
|
||||
{key}
|
||||
</div>
|
||||
))}
|
||||
</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{groupedKeyMappings.map((section, sectionIndex) => (
|
||||
<React.Fragment key={section.heading}>
|
||||
<tr className="section-heading-row">
|
||||
<td colSpan={2}>{section.heading}</td>
|
||||
</tr>
|
||||
{section.rows.map((row, rowIndex) => {
|
||||
const { action } = row;
|
||||
const isEditing = editingAction === action;
|
||||
const isHovered = hoveredAction === action;
|
||||
const isDirty = isRowDirty(action);
|
||||
const isReadOnly = row?.readOnly === true;
|
||||
|
||||
const isSuccess = successAction === action;
|
||||
const hasError = Boolean(errorByAction[action]?.message);
|
||||
const errorMessage = errorByAction[action]?.message;
|
||||
|
||||
const showPencil = isHovered && !isDirty && !isEditing && !isReadOnly && !isSuccess && !hasError;
|
||||
const showRefresh = isDirty && !isEditing && !isSuccess && !hasError;
|
||||
const showLock = isHovered && isReadOnly && !isEditing && !isSuccess;
|
||||
const inputId = `kb-input-${action}`;
|
||||
|
||||
const isLastInSection = rowIndex === section.rows.length - 1
|
||||
&& sectionIndex < groupedKeyMappings.length - 1;
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={action}
|
||||
className={`${isSuccess ? 'row-success' : ''} ${isEditing ? 'row-editing' : ''} ${isLastInSection ? 'section-last-row' : ''}`}
|
||||
data-testid={`keybinding-row-${action}`}
|
||||
onMouseEnter={() => setHoveredAction(action)}
|
||||
onMouseLeave={() =>
|
||||
setHoveredAction((prev) => (prev === action ? null : prev))}
|
||||
onClick={() => !isReadOnly && !isEditing && startEditing(action)}
|
||||
>
|
||||
<td data-testid={`keybinding-name-${action}`}>{row.name}</td>
|
||||
|
||||
<td>
|
||||
<div className="keybinding-row">
|
||||
<div className="shortcut-wrap">
|
||||
<div
|
||||
id={inputId}
|
||||
ref={(el) => {
|
||||
if (el) inputRefs.current[action] = el;
|
||||
}}
|
||||
data-testid={`keybinding-input-${action}`}
|
||||
className={`shortcut-input ${hasError && errorByAction[action]?.code !== ERROR.EMPTY ? 'shortcut-input--error' : ''} ${isEditing ? 'shortcut-input--editing' : ''
|
||||
} ${isReadOnly ? 'shortcut-input--readonly' : ''}`}
|
||||
tabIndex={isReadOnly ? -1 : 0}
|
||||
role="textbox"
|
||||
aria-readonly={!isEditing || isReadOnly}
|
||||
aria-disabled={isReadOnly}
|
||||
onKeyDown={(e) => (isReadOnly ? null : handleKeyDown(action, e))}
|
||||
onKeyUp={(e) => (isReadOnly ? null : handleKeyUp(action, e))}
|
||||
onBlur={() => {
|
||||
if (isEditing && hasError) {
|
||||
cancelEditing(action);
|
||||
} else if (isEditing) {
|
||||
stopEditing(action);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{renderValue(action)}
|
||||
{hasError && errorByAction[action]?.code !== ERROR.EMPTY && (
|
||||
<span className="input-error-icon">
|
||||
<IconAlertCircle size={14} stroke={1.5} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isEditing && hasError && errorByAction[action]?.code !== ERROR.EMPTY && (
|
||||
<Tooltip
|
||||
id={`kb-editing-error-tooltip-${action}`}
|
||||
anchorSelect={`#${inputId}`}
|
||||
place="bottom-start"
|
||||
opacity={1}
|
||||
isOpen={true}
|
||||
content={errorMessage}
|
||||
className="tooltip-mod tooltip-mod--error"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isEditing && (
|
||||
<div className="button-placeholder">
|
||||
{isSuccess && !hasError && (
|
||||
<span className="success-icon">
|
||||
<IconCircleCheck size={14} stroke={1.5} />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{showRefresh && !hasError && (
|
||||
<button
|
||||
className="action-btn"
|
||||
data-testid={`keybinding-reset-${action}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation(); resetRowToDefault(action);
|
||||
}}
|
||||
title="Reset to default"
|
||||
>
|
||||
<IconReload size={14} stroke={1.5} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{showPencil && (
|
||||
<span
|
||||
className="pencil-icon"
|
||||
data-testid={`keybinding-edit-${action}`}
|
||||
title="Customize keys"
|
||||
>
|
||||
<IconPencil size={14} stroke={1.5} />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{showLock && (
|
||||
<button
|
||||
type="button"
|
||||
className="edit-btn"
|
||||
data-testid={`keybinding-locked-${action}`}
|
||||
title="Reserved shortcut"
|
||||
>
|
||||
<IconLock size={14} stroke={1.5} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="empty-state">No key bindings available</div>
|
||||
)}
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan="2">No key bindings available</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -5,7 +5,7 @@ const StyledWrapper = styled.div`
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
|
||||
|
||||
.settings-label {
|
||||
width: 100px;
|
||||
}
|
||||
@@ -26,57 +26,6 @@ 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,22 +17,7 @@ const ProxySettings = ({ close }) => {
|
||||
|
||||
const proxySchema = Yup.object({
|
||||
disabled: Yup.boolean().optional(),
|
||||
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(),
|
||||
inherit: Yup.boolean().required(),
|
||||
config: Yup.object({
|
||||
protocol: Yup.string().required().oneOf(['http', 'https', 'socks4', 'socks5']),
|
||||
hostname: Yup.string().max(1024),
|
||||
@@ -54,10 +39,7 @@ const ProxySettings = ({ close }) => {
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
disabled: preferences.proxy.disabled || false,
|
||||
source: preferences.proxy.source || 'manual',
|
||||
pac: {
|
||||
source: preferences.proxy.pac?.source || ''
|
||||
},
|
||||
inherit: preferences.proxy.inherit || false,
|
||||
config: {
|
||||
protocol: preferences.proxy.config?.protocol || 'http',
|
||||
hostname: preferences.proxy.config?.hostname || '',
|
||||
@@ -104,26 +86,15 @@ 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, proxyMode]);
|
||||
}, [formik.values, formik.dirty, formik.isValid, debouncedSave]);
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
@@ -139,10 +110,10 @@ const ProxySettings = ({ close }) => {
|
||||
type="radio"
|
||||
name="mode"
|
||||
value="off"
|
||||
checked={proxyMode === 'off'}
|
||||
checked={formik.values.disabled === true}
|
||||
onChange={(e) => {
|
||||
setProxyMode('off');
|
||||
formik.setFieldValue('disabled', true);
|
||||
formik.setFieldValue('inherit', false);
|
||||
}}
|
||||
className="mr-1 cursor-pointer"
|
||||
/>
|
||||
@@ -152,12 +123,11 @@ const ProxySettings = ({ close }) => {
|
||||
<input
|
||||
type="radio"
|
||||
name="mode"
|
||||
value="manual"
|
||||
checked={proxyMode === 'manual'}
|
||||
value="on"
|
||||
checked={formik.values.disabled === false && formik.values.inherit === false}
|
||||
onChange={(e) => {
|
||||
setProxyMode('manual');
|
||||
formik.setFieldValue('disabled', false);
|
||||
formik.setFieldValue('source', 'manual');
|
||||
formik.setFieldValue('inherit', false);
|
||||
}}
|
||||
className="mr-1 cursor-pointer"
|
||||
/>
|
||||
@@ -167,40 +137,24 @@ const ProxySettings = ({ close }) => {
|
||||
<input
|
||||
type="radio"
|
||||
name="mode"
|
||||
value="inherit"
|
||||
checked={proxyMode === 'inherit'}
|
||||
value="system"
|
||||
checked={formik.values.disabled === false && formik.values.inherit === true}
|
||||
onChange={(e) => {
|
||||
setProxyMode('inherit');
|
||||
formik.setFieldValue('disabled', false);
|
||||
formik.setFieldValue('source', 'inherit');
|
||||
formik.setFieldValue('inherit', true);
|
||||
}}
|
||||
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>
|
||||
{proxyMode === 'inherit' ? (
|
||||
{formik.values.disabled === false && formik.values.inherit === true ? (
|
||||
<div className="mb-3 pt-1 text-muted system-proxy-settings">
|
||||
<SystemProxy />
|
||||
</div>
|
||||
) : null}
|
||||
{proxyMode === 'manual' ? (
|
||||
{formik.values.disabled === false && formik.values.inherit === false ? (
|
||||
<>
|
||||
<div className="mb-3 flex items-center">
|
||||
<label className="settings-label" htmlFor="protocol">
|
||||
@@ -381,79 +335,6 @@ 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: 180px;
|
||||
min-width: 160px;
|
||||
|
||||
div.tab {
|
||||
display: flex;
|
||||
@@ -38,7 +38,7 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
|
||||
section.tab-panel {
|
||||
max-height: calc(100% - 55px);
|
||||
min-height: 70vh;
|
||||
overflow-y: auto;
|
||||
flex-grow: 1;
|
||||
padding: 12px;
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useDispatch } 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';
|
||||
@@ -55,18 +54,8 @@ 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));
|
||||
|
||||
@@ -168,7 +157,6 @@ const Assertions = ({ item, collection }) => {
|
||||
return (
|
||||
<StyledWrapper className="w-full">
|
||||
<EditableTable
|
||||
tableId="assertions"
|
||||
columns={columns}
|
||||
rows={assertions || []}
|
||||
onChange={handleAssertionsChange}
|
||||
@@ -176,8 +164,6 @@ const Assertions = ({ item, collection }) => {
|
||||
reorderable={true}
|
||||
onReorder={handleAssertionDrag}
|
||||
testId="assertions-table"
|
||||
columnWidths={assertionsWidths}
|
||||
onColumnWidthsChange={(widths) => handleColumnWidthsChange('assertions', widths)}
|
||||
/>
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -47,11 +47,6 @@ 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',
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
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;
|
||||
@@ -1,439 +0,0 @@
|
||||
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,7 +6,6 @@ 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';
|
||||
@@ -91,9 +90,6 @@ 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, useSelector } from 'react-redux';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import {
|
||||
moveFormUrlEncodedParam,
|
||||
@@ -8,25 +8,14 @@ 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));
|
||||
|
||||
@@ -83,15 +72,12 @@ 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>
|
||||
);
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
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,15 +1,10 @@
|
||||
import React, { useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import React, { useEffect, useState, 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, updateQueryBuilderOpen, updateQueryBuilderWidth, updateVariablesPaneOpen, updateVariablesPaneHeight } from 'providers/ReduxStore/slices/tabs';
|
||||
import { updateRequestPaneTab } 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';
|
||||
@@ -18,12 +13,10 @@ import Assertions from 'components/RequestPane/Assertions';
|
||||
import Script from 'components/RequestPane/Script';
|
||||
import Tests from 'components/RequestPane/Tests';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { updateRequestGraphqlQuery, updateRequestGraphqlVariables } from 'providers/ReduxStore/slices/collections';
|
||||
import { updateRequestGraphqlQuery } from 'providers/ReduxStore/slices/collections';
|
||||
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import Documentation from 'components/Documentation/index';
|
||||
import useGraphqlSchema from '../GraphQLSchemaActions/useGraphqlSchema';
|
||||
import { findEnvironmentInCollection } from 'utils/collections';
|
||||
import GraphQLSchemaActions from '../GraphQLSchemaActions/index';
|
||||
import HeightBoundContainer from 'ui/HeightBoundContainer';
|
||||
import Settings from 'components/RequestPane/Settings';
|
||||
import ResponsiveTabs from 'ui/ResponsiveTabs';
|
||||
@@ -31,6 +24,7 @@ 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' },
|
||||
@@ -46,16 +40,6 @@ 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', '')
|
||||
@@ -65,70 +49,16 @@ const GraphQLRequestPane = ({ item, collection, onSchemaLoad, toggleDocs, handle
|
||||
: get(item, 'request.body.graphql.variables', '');
|
||||
|
||||
const { displayedTheme } = useTheme();
|
||||
|
||||
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 [schema, setSchema] = useState(null);
|
||||
const schemaActionsRef = useRef(null);
|
||||
|
||||
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
|
||||
const requestPaneTab = focusedTab?.requestPaneTab;
|
||||
|
||||
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(
|
||||
@@ -142,19 +72,6 @@ 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]
|
||||
@@ -174,77 +91,25 @@ 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 (
|
||||
<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>
|
||||
<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')}
|
||||
/>
|
||||
);
|
||||
case 'variables':
|
||||
return <GraphQLVariables item={item} variables={variables} collection={collection} />;
|
||||
case 'headers':
|
||||
return <RequestHeaders item={item} collection={collection} />;
|
||||
case 'auth':
|
||||
@@ -264,30 +129,7 @@ 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, 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]);
|
||||
}, [requestPaneTab, item, collection, displayedTheme, schema, onSave, query, onRun, onQueryChange, handleGqlClickReference, preferences, variables]);
|
||||
|
||||
if (!activeTabUid || !focusedTab?.uid || !requestPaneTab) {
|
||||
return <div className="pb-4 px-4">An error occurred!</div>;
|
||||
@@ -298,29 +140,13 @@ const GraphQLRequestPane = ({ item, collection, onSchemaLoad, toggleDocs, handle
|
||||
<AuthMode item={item} collection={collection} />
|
||||
</div>
|
||||
) : requestPaneTab === 'query' ? (
|
||||
<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 ref={schemaActionsRef}>
|
||||
<GraphQLSchemaActions item={item} collection={collection} onSchemaLoad={setSchema} toggleDocs={toggleDocs} />
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<StyledWrapper className="flex flex-col h-full relative">
|
||||
<div className="flex flex-col h-full relative">
|
||||
<ResponsiveTabs
|
||||
tabs={allTabs}
|
||||
activeTab={requestPaneTab}
|
||||
@@ -329,33 +155,10 @@ const GraphQLRequestPane = ({ item, collection, onSchemaLoad, toggleDocs, handle
|
||||
rightContentRef={rightContent ? schemaActionsRef : null}
|
||||
/>
|
||||
|
||||
<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 className={classnames('flex w-full flex-1 mt-4')}>
|
||||
<HeightBoundContainer>{tabPanel}</HeightBoundContainer>
|
||||
</section>
|
||||
</StyledWrapper>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -5,6 +5,10 @@ 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();
|
||||
@@ -12,6 +16,24 @@ 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({
|
||||
@@ -26,19 +48,28 @@ const GraphQLVariables = ({ variables, item, collection }) => {
|
||||
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
|
||||
|
||||
return (
|
||||
<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']}
|
||||
/>
|
||||
<>
|
||||
<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']}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ const Wrapper = styled.div`
|
||||
transition: color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => props.theme.text};
|
||||
color: ${(props) => props.theme.colors.text.link};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ const Wrapper = styled.div`
|
||||
}
|
||||
|
||||
.file-value-cell {
|
||||
width: 100%;
|
||||
padding: 4px 0;
|
||||
|
||||
.file-name {
|
||||
font-size: 12px;
|
||||
@@ -33,8 +33,6 @@ 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, useSelector } from 'react-redux';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { IconUpload, IconX, IconFile } from '@tabler/icons';
|
||||
import {
|
||||
@@ -11,7 +11,6 @@ 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';
|
||||
@@ -20,18 +19,8 @@ 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));
|
||||
|
||||
@@ -65,21 +54,12 @@ const MultipartFormParams = ({ item, collection }) => {
|
||||
const currentParams = item.draft
|
||||
? get(item, 'draft.request.body.multipartForm')
|
||||
: get(item, 'request.body.multipartForm');
|
||||
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: '' }
|
||||
];
|
||||
}
|
||||
const updatedParams = (currentParams || []).map((p) => {
|
||||
if (p.uid === row.uid) {
|
||||
return { ...p, type: 'file', value: processedPaths };
|
||||
}
|
||||
return p;
|
||||
});
|
||||
handleParamsChange(updatedParams);
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -142,22 +122,18 @@ const MultipartFormParams = ({ item, collection }) => {
|
||||
name: 'Value',
|
||||
placeholder: 'Value',
|
||||
width: '35%',
|
||||
render: ({ row, value, onChange }) => {
|
||||
render: ({ row, value, onChange, isLastEmptyRow }) => {
|
||||
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" />
|
||||
<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>
|
||||
<span className="file-name flex-1 truncate" title={Array.isArray(value) ? value.join(', ') : value}>
|
||||
{fileName}
|
||||
</span>
|
||||
<button
|
||||
className="clear-file-btn ml-1"
|
||||
onClick={() => handleClearFile(row)}
|
||||
@@ -184,13 +160,15 @@ const MultipartFormParams = ({ item, collection }) => {
|
||||
placeholder={!value ? 'Value' : ''}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="upload-btn ml-1"
|
||||
onClick={() => handleBrowseFiles(row, onChange)}
|
||||
title="Select file"
|
||||
>
|
||||
<IconUpload size={16} />
|
||||
</button>
|
||||
{!hasTextValue && !isLastEmptyRow && (
|
||||
<button
|
||||
className="upload-btn ml-1"
|
||||
onClick={() => handleBrowseFiles(row, onChange)}
|
||||
title="Select file"
|
||||
>
|
||||
<IconUpload size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -224,15 +202,12 @@ 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>
|
||||
);
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
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;
|
||||
@@ -1,529 +0,0 @@
|
||||
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"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
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"
|
||||
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}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleExpand(e);
|
||||
}
|
||||
}}
|
||||
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"
|
||||
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}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleExpand(e);
|
||||
}
|
||||
}}
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
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}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
toggleExpand(e);
|
||||
}
|
||||
}}
|
||||
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"
|
||||
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}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
toggleExpand(e);
|
||||
}
|
||||
}}
|
||||
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"
|
||||
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);
|
||||
@@ -1,56 +0,0 @@
|
||||
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;
|
||||
@@ -1,383 +0,0 @@
|
||||
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: default;
|
||||
|
||||
.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;
|
||||
@@ -1,238 +0,0 @@
|
||||
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}
|
||||
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,17 +67,6 @@ 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,9 +11,11 @@ import MD from 'markdown-it';
|
||||
import { format } from 'prettier/standalone';
|
||||
import prettierPluginGraphql from 'prettier/parser-graphql';
|
||||
import { getAllVariables } from 'utils/collections';
|
||||
import { PLACEHOLDER } from 'utils/graphql/queryBuilder';
|
||||
import { defineCodeMirrorBrunoVariablesMode } from 'utils/common/codemirror';
|
||||
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';
|
||||
|
||||
@@ -103,6 +105,16 @@ 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();
|
||||
@@ -124,6 +136,18 @@ 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'
|
||||
}
|
||||
@@ -152,10 +176,15 @@ 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) {
|
||||
const cursor = this.editor.getCursor();
|
||||
this.cachedValue = String(this.props.value);
|
||||
this.editor.setValue(String(this.props.value) || '');
|
||||
this.editor.setCursor(cursor);
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.props.theme !== prevProps.theme && this.editor) {
|
||||
@@ -177,33 +206,16 @@ 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 {
|
||||
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, {
|
||||
const prettyQuery = format(this.props.value, {
|
||||
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');
|
||||
@@ -223,15 +235,25 @@ 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;
|
||||
}}
|
||||
/>
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import InfoTip from 'components/InfoTip';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import {
|
||||
moveQueryParam,
|
||||
updatePathParam,
|
||||
setQueryParams
|
||||
} from 'providers/ReduxStore/slices/collections';
|
||||
import { saveRequest, sendRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
|
||||
import MultiLineEditor from 'components/MultiLineEditor';
|
||||
import { saveRequest, sendRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import EditableTable from 'components/EditableTable';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import BulkEditor from '../../BulkEditor';
|
||||
@@ -18,23 +17,12 @@ 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));
|
||||
|
||||
@@ -150,15 +138,12 @@ 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}>
|
||||
@@ -168,7 +153,7 @@ const QueryParams = ({ item, collection }) => {
|
||||
|
||||
<div className="mb-3 title text-xs flex items-stretch">
|
||||
<span>Path</span>
|
||||
<InfoTip className="tooltip-mod" infotipId="path-param-InfoTip">
|
||||
<InfoTip infotipId="path-param-InfoTip">
|
||||
<div>
|
||||
Path variables are automatically added whenever the
|
||||
<code className="font-mono mx-2">:name</code>
|
||||
@@ -181,7 +166,6 @@ const QueryParams = ({ item, collection }) => {
|
||||
</div>
|
||||
{pathParams && pathParams.length > 0 ? (
|
||||
<EditableTable
|
||||
tableId="path-params"
|
||||
columns={pathColumns}
|
||||
rows={pathParams}
|
||||
onChange={() => {}}
|
||||
@@ -189,8 +173,6 @@ 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,13 +2,9 @@ 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;
|
||||
@@ -53,7 +49,6 @@ const Wrapper = styled.div`
|
||||
.shortcut {
|
||||
font-size: 0.625rem;
|
||||
}
|
||||
|
||||
`;
|
||||
|
||||
export default Wrapper;
|
||||
|
||||
@@ -16,9 +16,8 @@ import { saveRequest, cancelRequest } from 'providers/ReduxStore/slices/collecti
|
||||
import { getRequestFromCurlCommand } from 'utils/curl';
|
||||
import HttpMethodSelector from './HttpMethodSelector';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { IconDeviceFloppy, IconCode } from '@tabler/icons';
|
||||
import { IconDeviceFloppy, IconArrowRight, IconCode, IconSquareRoundedX } 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';
|
||||
@@ -113,13 +112,6 @@ 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
|
||||
@@ -202,13 +194,6 @@ 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(
|
||||
@@ -385,67 +370,76 @@ const QueryUrl = ({ item, collection, handleRun }) => {
|
||||
};
|
||||
return (
|
||||
<StyledWrapper className="flex items-center w-full">
|
||||
<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 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>
|
||||
<div
|
||||
id="request-url"
|
||||
className="h-full w-full flex flex-row items-center input-container overflow-hidden"
|
||||
title="Save Request"
|
||||
className="infotip"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!hasChanges) return;
|
||||
onSave();
|
||||
}}
|
||||
>
|
||||
<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}
|
||||
<IconDeviceFloppy
|
||||
color={hasChanges ? theme.draftColor : theme.requestTabs.icon.color}
|
||||
strokeWidth={1.5}
|
||||
size={20}
|
||||
className={`${hasChanges ? 'cursor-pointer' : 'cursor-default'}`}
|
||||
/>
|
||||
<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>
|
||||
<span className="infotiptext text-xs">
|
||||
Save <span className="shortcut">({saveShortcut})</span>
|
||||
</span>
|
||||
</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,6 +1,5 @@
|
||||
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';
|
||||
@@ -8,7 +7,6 @@ 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';
|
||||
|
||||
@@ -18,9 +16,6 @@ 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(
|
||||
@@ -35,15 +30,6 @@ 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',
|
||||
@@ -71,8 +57,6 @@ const RequestBody = ({ item, collection }) => {
|
||||
onEdit={onEdit}
|
||||
onRun={onRun}
|
||||
onSave={onSave}
|
||||
onScroll={onScroll}
|
||||
initialScroll={focusedTab?.requestBodyScrollPosition || 0}
|
||||
mode={codeMirrorMode[bodyMode]}
|
||||
enableVariableHighlighting={true}
|
||||
showHintsFor={['variables']}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useDispatch } 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';
|
||||
@@ -18,19 +17,9 @@ 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));
|
||||
|
||||
@@ -134,7 +123,6 @@ const RequestHeaders = ({ item, collection, addHeaderText }) => {
|
||||
return (
|
||||
<StyledWrapper className="w-full">
|
||||
<EditableTable
|
||||
tableId="request-headers"
|
||||
columns={columns}
|
||||
rows={headers || []}
|
||||
onChange={handleHeadersChange}
|
||||
@@ -142,8 +130,6 @@ 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}>
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
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;
|
||||
@@ -1,22 +0,0 @@
|
||||
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,20 +27,18 @@ const Tests = ({ item, collection }) => {
|
||||
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
|
||||
|
||||
return (
|
||||
<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>
|
||||
<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']}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useDispatch } 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';
|
||||
@@ -14,16 +13,6 @@ 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));
|
||||
@@ -68,7 +57,7 @@ const VarsTable = ({ item, collection, vars, varType }) => {
|
||||
name: varType === 'request' ? 'Value' : (
|
||||
<div className="flex items-center">
|
||||
<span>Expr</span>
|
||||
<InfoTip className="tooltip-mod" content="You can write any valid JS expression here" infotipId={`request-${varType}-var`} />
|
||||
<InfoTip content="You can write any valid JS expression here" infotipId={`request-${varType}-var`} />
|
||||
</div>
|
||||
),
|
||||
placeholder: varType === 'request' ? 'Value' : 'Expr',
|
||||
@@ -96,7 +85,6 @@ const VarsTable = ({ item, collection, vars, varType }) => {
|
||||
return (
|
||||
<StyledWrapper className="w-full">
|
||||
<EditableTable
|
||||
tableId="request-vars"
|
||||
columns={columns}
|
||||
rows={vars || []}
|
||||
onChange={handleVarsChange}
|
||||
@@ -104,8 +92,6 @@ 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 OAuth1/OAuth2 - not supported for WebSockets
|
||||
if (source?.auth?.mode === 'oauth1' || source?.auth?.mode === 'oauth2') {
|
||||
// Check if inherited auth is OAuth2 - not supported for WebSockets
|
||||
if (source?.auth?.mode === 'oauth2') {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-row w-full mt-2 gap-2">
|
||||
{source.auth.mode === 'oauth1' ? 'OAuth 1.0' : 'OAuth 2'} not <strong>yet</strong> supported by WebSockets. Using no auth instead.
|
||||
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,7 +99,6 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { IconDeviceFloppy, IconPlugConnected, IconPlugConnectedX } from '@tabler/icons';
|
||||
import SendButton from 'components/RequestPane/SendButton';
|
||||
import { IconArrowRight, IconDeviceFloppy, IconPlugConnected, IconPlugConnectedX } from '@tabler/icons';
|
||||
import classnames from 'classnames';
|
||||
import SingleLineEditor from 'components/SingleLineEditor/index';
|
||||
import { requestUrlChanged } from 'providers/ReduxStore/slices/collections';
|
||||
@@ -124,7 +123,7 @@ const WsQueryUrl = ({ item, collection, handleRun }) => {
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="flex items-center h-full">
|
||||
<div className="flex items-center input-container flex-1 min-w-0 h-full relative">
|
||||
<div className="flex items-center input-container flex-1 w-full h-full relative">
|
||||
<div className="flex items-center justify-center px-[10px]">
|
||||
<span className="text-xs font-medium method-ws">WS</span>
|
||||
</div>
|
||||
@@ -188,14 +187,15 @@ 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,8 +8,7 @@ import GrpcRequestPane from 'components/RequestPane/GrpcRequestPane/index';
|
||||
import ResponsePane from 'components/ResponsePane';
|
||||
import GrpcResponsePane from 'components/ResponsePane/GrpcResponsePane';
|
||||
import { findItemInCollection } from 'utils/collections';
|
||||
import { sendRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { updateGqlDocsOpen } from 'providers/ReduxStore/slices/tabs';
|
||||
import { cancelRequest, sendRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import RequestNotFound from './RequestNotFound';
|
||||
import QueryUrl from 'components/RequestPane/QueryUrl/index';
|
||||
import GrpcQueryUrl from 'components/RequestPane/GrpcQueryUrl/index';
|
||||
@@ -32,8 +31,6 @@ 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';
|
||||
@@ -60,12 +57,6 @@ 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(() => {
|
||||
@@ -101,24 +92,18 @@ const RequestTabPanel = () => {
|
||||
const mainSectionRef = useRef(null);
|
||||
|
||||
const [schema, setSchema] = useState(null);
|
||||
|
||||
// Get gqlDocsOpen from Redux for persistence across tab switches
|
||||
const showGqlDocs = focusedTab?.gqlDocsOpen || false;
|
||||
|
||||
const [showGqlDocs, setShowGqlDocs] = useState(false);
|
||||
const onSchemaLoad = useCallback((schema) => setSchema(schema), []);
|
||||
const toggleDocs = useCallback((value = null) => {
|
||||
const newValue = value !== null ? !!value : !showGqlDocs;
|
||||
dispatch(updateGqlDocsOpen({ uid: activeTabUid, gqlDocsOpen: newValue }));
|
||||
}, [dispatch, activeTabUid, showGqlDocs]);
|
||||
const toggleDocs = useCallback(() => setShowGqlDocs((prev) => !prev), []);
|
||||
|
||||
const handleGqlClickReference = useCallback((reference) => {
|
||||
if (docExplorerRef.current) {
|
||||
docExplorerRef.current.showDocForReference(reference);
|
||||
}
|
||||
if (!showGqlDocs) {
|
||||
dispatch(updateGqlDocsOpen({ uid: activeTabUid, gqlDocsOpen: true }));
|
||||
setShowGqlDocs(true);
|
||||
}
|
||||
}, [dispatch, activeTabUid, showGqlDocs]);
|
||||
}, []);
|
||||
|
||||
const handleMouseMove = useCallback((e) => {
|
||||
if (!draggingRef.current || !mainSectionRef.current) return;
|
||||
@@ -299,13 +284,20 @@ const RequestTabPanel = () => {
|
||||
toast.error('Please enter a valid WebSocket URL');
|
||||
return;
|
||||
}
|
||||
if (item.requestState !== 'sending' && item.requestState !== 'queued') {
|
||||
|
||||
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') {
|
||||
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} />;
|
||||
@@ -361,52 +353,50 @@ const RequestTabPanel = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<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>
|
||||
|
||||
<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">
|
||||
<div
|
||||
className="dragbar-wrapper"
|
||||
onDoubleClick={(e) => {
|
||||
e.preventDefault();
|
||||
resetPaneBoundaries();
|
||||
}}
|
||||
onMouseDown={handleDragbarMouseDown}
|
||||
className="px-4 h-full"
|
||||
style={requestPaneStyle}
|
||||
>
|
||||
<div className="dragbar-handle" />
|
||||
{renderRequestPane()}
|
||||
</div>
|
||||
|
||||
<section className="response-pane flex-grow overflow-x-auto" data-testid="response-pane">
|
||||
{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" data-testid="graphql-docs-close-button" onClick={() => toggleDocs(false)} aria-label="Close Documentation Explorer">
|
||||
{'\u2715'}
|
||||
</button>
|
||||
</DocExplorer>
|
||||
</div>
|
||||
) : null}
|
||||
</StyledWrapper>
|
||||
</ScopedPersistenceProvider>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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" data-testid="runner">
|
||||
<ActionIcon onClick={handleRun} aria-label="Runner" size="sm">
|
||||
<IconRun size={16} strokeWidth={1.5} />
|
||||
</ActionIcon>
|
||||
</ToolHint>
|
||||
{/* JS Sandbox Mode - always visible */}
|
||||
<JsSandboxMode collection={collection} />
|
||||
{/* Overflow menu */}
|
||||
<MenuDropdown items={overflowMenuItems} placement="bottom-end" data-testid="more-actions">
|
||||
<MenuDropdown items={overflowMenuItems} placement="bottom-end">
|
||||
<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>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user