mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-04 09:58:35 +00:00
Compare commits
81 Commits
ci/self-ho
...
feat/fix-e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd8bc459ce | ||
|
|
a93e1dc8bf | ||
|
|
4fffef51ba | ||
|
|
6136d3ac62 | ||
|
|
942f995717 | ||
|
|
82ee8e1331 | ||
|
|
6711ccdda2 | ||
|
|
a7efed674e | ||
|
|
5345cb7b5f | ||
|
|
36e59e992c | ||
|
|
fab18d9e3e | ||
|
|
7b94e069e9 | ||
|
|
ba063f6d82 | ||
|
|
c857d27415 | ||
|
|
3c576487c9 | ||
|
|
277845b6d8 | ||
|
|
1907b2b3f0 | ||
|
|
05ab2661fa | ||
|
|
07c7348666 | ||
|
|
b73bf9d898 | ||
|
|
9d8c0fd2a0 | ||
|
|
2bc735ee00 | ||
|
|
1472f6b158 | ||
|
|
d8d468f1e0 | ||
|
|
0d73e38515 | ||
|
|
cff1f25528 | ||
|
|
db195fe302 | ||
|
|
e7e6cdfa51 | ||
|
|
7a24b1924d | ||
|
|
13363d7931 | ||
|
|
1d3a412539 | ||
|
|
59b4a16b79 | ||
|
|
377cdb488c | ||
|
|
79504ed729 | ||
|
|
6791e0a674 | ||
|
|
ed5f5c21cf | ||
|
|
280b856869 | ||
|
|
216d8e7151 | ||
|
|
13a48a256f | ||
|
|
240826ebc1 | ||
|
|
6f47218a81 | ||
|
|
95c75c90c1 | ||
|
|
366d85b141 | ||
|
|
b9ee1ee523 | ||
|
|
2d4d4e4037 | ||
|
|
b9d8bdf2ec | ||
|
|
913214e96b | ||
|
|
f629c3dd20 | ||
|
|
b70bfb26d4 | ||
|
|
a8b938fe4c | ||
|
|
dadd69b02d | ||
|
|
8f80230708 | ||
|
|
026dbfb108 | ||
|
|
462a39308d | ||
|
|
f23e406ef8 | ||
|
|
db91dbf192 | ||
|
|
7413465bb4 | ||
|
|
18761ee156 | ||
|
|
d8b6701bb5 | ||
|
|
472241b51c | ||
|
|
244f528277 | ||
|
|
49088e98c8 | ||
|
|
b43a5e6e0a | ||
|
|
4ee9a75465 | ||
|
|
413697cbe7 | ||
|
|
6b7e5f3813 | ||
|
|
d9c13e74ac | ||
|
|
809f951a47 | ||
|
|
2e0094fc46 | ||
|
|
39308bc03c | ||
|
|
a3e3199490 | ||
|
|
87d97ba0ef | ||
|
|
b20893eee1 | ||
|
|
9b0911926c | ||
|
|
8cd7c26648 | ||
|
|
611724a744 | ||
|
|
71b53ee0bc | ||
|
|
113e28dc3c | ||
|
|
2d25b2cfb0 | ||
|
|
f916b19a6f | ||
|
|
c5528a75a6 |
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 @sid-bruno @vijayh-bruno
|
||||
|
||||
@@ -18,7 +18,7 @@ runs:
|
||||
- name: Run Playwright Tests (Ubuntu)
|
||||
if: inputs.os == 'ubuntu'
|
||||
shell: bash
|
||||
run: xvfb-run npm run test:e2e
|
||||
run: xvfb-run dbus-run-session -- npm run test:e2e
|
||||
|
||||
- name: Run Playwright Tests
|
||||
if: inputs.os != 'ubuntu'
|
||||
|
||||
7
.github/workflows/tests-linux.yml
vendored
7
.github/workflows/tests-linux.yml
vendored
@@ -49,7 +49,7 @@ jobs:
|
||||
|
||||
e2e-test:
|
||||
name: Playwright E2E Tests (Linux)
|
||||
timeout-minutes: 120
|
||||
timeout-minutes: 240
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
@@ -59,7 +59,8 @@ jobs:
|
||||
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
|
||||
xvfb \
|
||||
gsettings-desktop-schemas dbus-x11
|
||||
|
||||
- name: Setup Node Dependencies
|
||||
uses: ./.github/actions/common/setup-node-deps
|
||||
@@ -69,7 +70,7 @@ jobs:
|
||||
sudo chown root node_modules/electron/dist/chrome-sandbox
|
||||
sudo chmod 4755 node_modules/electron/dist/chrome-sandbox
|
||||
|
||||
- name: Run playwright Tests
|
||||
- name: Run E2E Tests
|
||||
uses: ./.github/actions/tests/run-e2e-tests
|
||||
with:
|
||||
os: ubuntu
|
||||
|
||||
2
.github/workflows/tests-macos.yml
vendored
2
.github/workflows/tests-macos.yml
vendored
@@ -49,7 +49,7 @@ jobs:
|
||||
|
||||
e2e-test:
|
||||
name: Playwright E2E Tests (macOS)
|
||||
timeout-minutes: 150
|
||||
timeout-minutes: 240
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
2
.github/workflows/tests-windows.yml
vendored
2
.github/workflows/tests-windows.yml
vendored
@@ -58,7 +58,7 @@ jobs:
|
||||
|
||||
e2e-test:
|
||||
name: Playwright E2E Tests (Windows)
|
||||
timeout-minutes: 120
|
||||
timeout-minutes: 240
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
264
package-lock.json
generated
264
package-lock.json
generated
@@ -30,7 +30,6 @@
|
||||
"@eslint/compat": "^1.3.2",
|
||||
"@faker-js/faker": "^7.6.0",
|
||||
"@jest/globals": "^29.2.0",
|
||||
"@opencollection/types": "0.9.1",
|
||||
"@playwright/test": "^1.51.1",
|
||||
"@rollup/plugin-json": "^6.1.0",
|
||||
"@storybook/addon-webpack5-compiler-babel": "^4.0.0",
|
||||
@@ -39,6 +38,7 @@
|
||||
"@storybook/react-webpack5": "^10.1.10",
|
||||
"@stylistic/eslint-plugin": "^5.3.1",
|
||||
"@types/jest": "^29.5.11",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "^22.14.1",
|
||||
"@typescript-eslint/parser": "^8.39.0",
|
||||
@@ -50,7 +50,7 @@
|
||||
"globals": "^16.1.0",
|
||||
"husky": "^9.1.7",
|
||||
"jest": "^29.2.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"lodash-es": "^4.17.23",
|
||||
"nano-staged": "^0.8.0",
|
||||
"playwright": "^1.51.1",
|
||||
"pretty-quick": "^3.1.3",
|
||||
@@ -67,6 +67,84 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@ai-sdk/anthropic": {
|
||||
"version": "3.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@ai-sdk/anthropic/-/anthropic-3.0.15.tgz",
|
||||
"integrity": "sha512-FCNy6pABPe5Qb1VPbdLLIi/XkQN2g/fKUcl1GcXxIU3Ofr+vOND8cyZfH20cMODR523FSGfwswJoJic8skr8qg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.4",
|
||||
"@ai-sdk/provider-utils": "4.0.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.76 || ^4.1.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@ai-sdk/gateway": {
|
||||
"version": "3.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.16.tgz",
|
||||
"integrity": "sha512-OOY5CfRJiHvh/8np2vs1RQaCZ5hWv2qOeEmmeiABXK3gLQHUVnCO+1hhoLsZdHM5iElu6M407dAOfyvTsKJqcQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.4",
|
||||
"@ai-sdk/provider-utils": "4.0.8",
|
||||
"@vercel/oidc": "3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.76 || ^4.1.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@ai-sdk/openai": {
|
||||
"version": "3.0.12",
|
||||
"resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-3.0.12.tgz",
|
||||
"integrity": "sha512-zqLWEKuaKnjXhu7xCw1jgz/+yTbd3F7EtgU4T2Q8BAo8OJC5wZv14l+kwM7Jai7M1/2Y2T/zBkrfiIu+7NsvfQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.4",
|
||||
"@ai-sdk/provider-utils": "4.0.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.76 || ^4.1.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@ai-sdk/provider": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.4.tgz",
|
||||
"integrity": "sha512-5KXyBOSEX+l67elrEa+wqo/LSsSTtrPj9Uoh3zMbe/ceQX4ucHI3b9nUEfNkGF3Ry1svv90widAt+aiKdIJasQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"json-schema": "^0.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@ai-sdk/provider-utils": {
|
||||
"version": "4.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.8.tgz",
|
||||
"integrity": "sha512-ns9gN7MmpI8vTRandzgz+KK/zNMLzhrriiKECMt4euLtQFSBgNfydtagPOX4j4pS1/3KvHF6RivhT3gNQgBZsg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.4",
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"eventsource-parser": "^3.0.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.76 || ^4.1.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@alloc/quick-lru": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
|
||||
@@ -6865,6 +6943,16 @@
|
||||
"dev": true,
|
||||
"license": "(Apache-2.0 AND BSD-3-Clause)"
|
||||
},
|
||||
"node_modules/@colors/colors": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz",
|
||||
"integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=0.1.90"
|
||||
}
|
||||
},
|
||||
"node_modules/@develar/schema-utils": {
|
||||
"version": "2.6.5",
|
||||
"resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz",
|
||||
@@ -9606,6 +9694,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@opentelemetry/api": {
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
|
||||
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.0.tgz",
|
||||
@@ -10048,9 +10145,9 @@
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/utf8": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz",
|
||||
"integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==",
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz",
|
||||
"integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@radix-ui/primitive": {
|
||||
@@ -11271,6 +11368,12 @@
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@storybook/addon-webpack5-compiler-babel": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/addon-webpack5-compiler-babel/-/addon-webpack5-compiler-babel-4.0.0.tgz",
|
||||
@@ -12290,7 +12393,7 @@
|
||||
"@swagger-api/apidom-core": "^1.4.0",
|
||||
"@swagger-api/apidom-error": "^1.4.0",
|
||||
"@types/ramda": "~0.30.0",
|
||||
"axios": "1.13.6",
|
||||
"axios": "1.16.0",
|
||||
"minimatch": "^7.4.3",
|
||||
"process": "^0.11.10",
|
||||
"ramda": "~0.30.0",
|
||||
@@ -12812,6 +12915,13 @@
|
||||
"pretty-format": "^29.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/js-yaml": {
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz",
|
||||
"integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/jsdom": {
|
||||
"version": "20.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-20.0.1.tgz",
|
||||
@@ -13401,6 +13511,15 @@
|
||||
"resolved": "packages/bruno-toml",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@vercel/oidc": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.1.0.tgz",
|
||||
"integrity": "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
|
||||
@@ -14074,6 +14193,24 @@
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/ai": {
|
||||
"version": "6.0.39",
|
||||
"resolved": "https://registry.npmjs.org/ai/-/ai-6.0.39.tgz",
|
||||
"integrity": "sha512-hF05gF4H+IxuilA8kNANVVHQXduTJsJaH74jmlmy8mcQt3NZgPYe2zZNyGBV4DPDYTUDt1h31hbLgQqJTn5LGA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@ai-sdk/gateway": "3.0.16",
|
||||
"@ai-sdk/provider": "3.0.4",
|
||||
"@ai-sdk/provider-utils": "4.0.8",
|
||||
"@opentelemetry/api": "1.9.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.76 || ^4.1.8"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "8.17.1",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
|
||||
@@ -14601,9 +14738,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.13.6",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
|
||||
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz",
|
||||
"integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.11",
|
||||
@@ -14617,7 +14754,7 @@
|
||||
"integrity": "sha512-CS6WE8chZpEDKxv4IFwr5zcG7InMC6Ek0aj2n2tHauBh+8KiYVC4qMn3N2arjR5tnyILQuTGlI0mc83hgWxS4Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "1.13.6",
|
||||
"axios": "1.16.0",
|
||||
"des.js": "^1.1.0",
|
||||
"dev-null": "^0.1.1",
|
||||
"js-md4": "^0.3.2"
|
||||
@@ -16105,6 +16242,21 @@
|
||||
"node": ">=0.2.5"
|
||||
}
|
||||
},
|
||||
"node_modules/cli-table3": {
|
||||
"version": "0.6.5",
|
||||
"resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.5.tgz",
|
||||
"integrity": "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "10.* || >= 12.*"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@colors/colors": "1.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cli-truncate": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz",
|
||||
@@ -16469,7 +16621,7 @@
|
||||
"date-fns": "^2.30.0",
|
||||
"lodash": "^4.17.21",
|
||||
"rxjs": "^7.8.1",
|
||||
"shell-quote": "^1.8.1",
|
||||
"shell-quote": "^1.8.4",
|
||||
"spawn-command": "0.0.2",
|
||||
"supports-color": "^8.1.1",
|
||||
"tree-kill": "^1.2.2",
|
||||
@@ -19009,6 +19161,15 @@
|
||||
"bare-events": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eventsource-parser": {
|
||||
"version": "3.0.8",
|
||||
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.8.tgz",
|
||||
"integrity": "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/evp_bytestokey": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz",
|
||||
@@ -23397,6 +23558,12 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/json-schema": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
|
||||
"integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==",
|
||||
"license": "(AFL-2.1 OR BSD-3-Clause)"
|
||||
},
|
||||
"node_modules/json-schema-traverse": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
@@ -23700,15 +23867,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||
"version": "4.18.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.0.tgz",
|
||||
"integrity": "sha512-l1mfj2atMqndAHI3ls7XqPxEjV2J9ZkcNyHpoZA3r2T1LLwDB69jgkMWh71YKwhBbK0G2f4WSn05ahmQXVxupA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash-es": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
|
||||
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
|
||||
"version": "4.18.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.0.tgz",
|
||||
"integrity": "sha512-koAgswPPA+UTaPN64Etp+PGP+WT6oqOS2NMi5yDkMaiGw9qY4VxQbQF0mtKMyr4BlTznWyzePV5UpECTJQmSUA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.camelcase": {
|
||||
@@ -26584,7 +26751,7 @@
|
||||
"integrity": "sha512-l+fsjYEkTik3m/G0pE7gMr4qBJP84LhK779oQm6MBzhBGpd4By4qieTW+4FUAlNCyzQTynn3Nhsa50c0IELSxQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "1.13.6",
|
||||
"axios": "1.16.0",
|
||||
"rusha": "^0.8.14"
|
||||
},
|
||||
"engines": {
|
||||
@@ -26841,9 +27008,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/protobufjs": {
|
||||
"version": "7.4.0",
|
||||
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.4.0.tgz",
|
||||
"integrity": "sha512-mRUWCc3KUU4w1jU8sGxICXH/gNS94DvI1gxqDvBzhj1JpcsimQkYiOJfwsPUykUI5ZaspFbSgmBLER8IrQ3tqw==",
|
||||
"version": "7.5.8",
|
||||
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.8.tgz",
|
||||
"integrity": "sha512-dvpCIeLPbXZS/Ete7yLaO7RenOdken2NHKykBXbsaGxZT0UTltcarBciw+A78SRQs9iMAAVpsYA+l8b1hTePIA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
@@ -29420,10 +29587,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/shell-quote": {
|
||||
"version": "1.8.2",
|
||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.2.tgz",
|
||||
"integrity": "sha512-AzqKpGKjrj7EM6rKVQEPpB288oCfnrEIuyoT9cyF4nmGa7V8Zk6f7RRqYisX8X9m+Q7bd632aZW4ky7EhbQztA==",
|
||||
"dev": true,
|
||||
"version": "1.8.4",
|
||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz",
|
||||
"integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -32742,6 +32908,15 @@
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
||||
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"packages/bruno-app": {
|
||||
"name": "@usebruno/app",
|
||||
"version": "2.0.0",
|
||||
@@ -32821,7 +32996,7 @@
|
||||
"react-virtuoso": "^4.18.1",
|
||||
"sass": "^1.46.0",
|
||||
"semver": "^7.7.1",
|
||||
"shell-quote": "^1.8.3",
|
||||
"shell-quote": "^1.8.4",
|
||||
"strip-json-comments": "^5.0.1",
|
||||
"styled-components": "^5.3.3",
|
||||
"swagger-ui-react": "^5.31.0",
|
||||
@@ -34312,18 +34487,6 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"packages/bruno-app/node_modules/shell-quote": {
|
||||
"version": "1.8.3",
|
||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
|
||||
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"packages/bruno-app/node_modules/uc.micro": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
|
||||
@@ -34374,10 +34537,11 @@
|
||||
"@usebruno/lang": "0.12.0",
|
||||
"@usebruno/requests": "^0.1.0",
|
||||
"aws4-axios": "^3.3.15",
|
||||
"axios": "1.13.6",
|
||||
"axios": "1.16.0",
|
||||
"axios-ntlm": "^1.4.2",
|
||||
"chai": "^4.3.7",
|
||||
"chalk": "^3.0.0",
|
||||
"cli-table3": "^0.6.5",
|
||||
"decomment": "^0.9.5",
|
||||
"form-data": "4.0.4",
|
||||
"fs-extra": "^10.1.0",
|
||||
@@ -34918,6 +35082,7 @@
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@usebruno/common": "^0.1.0",
|
||||
"@usebruno/schema": "^0.7.0",
|
||||
"js-yaml": "^4.1.1",
|
||||
"jscodeshift": "^17.3.0",
|
||||
@@ -34928,6 +35093,7 @@
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.25.2",
|
||||
"@babel/preset-env": "^7.25.4",
|
||||
"@babel/preset-typescript": "^7.25.0",
|
||||
"@opencollection/types": "0.9.1",
|
||||
"@rollup/plugin-alias": "^5.1.0",
|
||||
"@rollup/plugin-commonjs": "^23.0.2",
|
||||
@@ -35011,6 +35177,8 @@
|
||||
"name": "bruno",
|
||||
"version": "2.0.0",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "3.0.15",
|
||||
"@ai-sdk/openai": "3.0.12",
|
||||
"@aws-sdk/credential-providers": "3.1019.0",
|
||||
"@grpc/grpc-js": "^1.13.2",
|
||||
"@grpc/proto-loader": "^0.7.13",
|
||||
@@ -35025,9 +35193,10 @@
|
||||
"@usebruno/schema": "0.7.0",
|
||||
"about-window": "^1.15.2",
|
||||
"adm-zip": "^0.5.16",
|
||||
"ai": "6.0.39",
|
||||
"archiver": "^7.0.1",
|
||||
"aws4-axios": "^3.3.15",
|
||||
"axios": "1.13.6",
|
||||
"axios": "1.16.0",
|
||||
"axios-ntlm": "^1.4.2",
|
||||
"chai": "^4.3.7",
|
||||
"chokidar": "^3.5.3",
|
||||
@@ -35057,7 +35226,8 @@
|
||||
"socks-proxy-agent": "^8.0.2",
|
||||
"tough-cookie": "^6.0.0",
|
||||
"uuid": "^10.0.0",
|
||||
"yup": "^0.32.11"
|
||||
"yup": "^0.32.11",
|
||||
"zod": "^4.1.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "~37.6.1",
|
||||
@@ -35595,6 +35765,7 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/nanoid": "^2.1.0",
|
||||
"@usebruno/common": "0.1.0",
|
||||
"@usebruno/lang": "0.12.0",
|
||||
"ajv": "^8.17.1",
|
||||
"lodash": "^4.17.21",
|
||||
@@ -35603,6 +35774,7 @@
|
||||
"devDependencies": {
|
||||
"@babel/preset-env": "^7.22.0",
|
||||
"@babel/preset-typescript": "^7.22.0",
|
||||
"@opencollection/types": "0.9.1",
|
||||
"@rollup/plugin-commonjs": "^23.0.2",
|
||||
"@rollup/plugin-json": "^6.1.0",
|
||||
"@rollup/plugin-node-resolve": "^15.0.1",
|
||||
@@ -35802,7 +35974,7 @@
|
||||
"ajv": "^8.12.0",
|
||||
"ajv-formats": "^2.1.1",
|
||||
"atob": "^2.1.2",
|
||||
"axios": "1.13.6",
|
||||
"axios": "1.16.0",
|
||||
"btoa": "^1.2.1",
|
||||
"chai": "^4.3.7",
|
||||
"chai-string": "^1.5.0",
|
||||
@@ -35978,6 +36150,7 @@
|
||||
"version": "0.12.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@usebruno/common": "0.1.0",
|
||||
"arcsecond": "^5.0.0",
|
||||
"dotenv": "^16.3.1",
|
||||
"lodash": "^4.17.21",
|
||||
@@ -36008,7 +36181,7 @@
|
||||
"@grpc/grpc-js": "^1.13.3",
|
||||
"@grpc/proto-loader": "^0.7.15",
|
||||
"@types/qs": "^6.9.18",
|
||||
"axios": "1.13.6",
|
||||
"axios": "1.16.0",
|
||||
"debug": "^4.4.3",
|
||||
"google-protobuf": "^4.0.0",
|
||||
"grpc-js-reflection-client": "^1.3.0",
|
||||
@@ -36185,6 +36358,7 @@
|
||||
"version": "0.7.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@usebruno/common": "0.1.0",
|
||||
"nanoid": "3.3.8",
|
||||
"yup": "^0.32.11"
|
||||
}
|
||||
@@ -36216,7 +36390,7 @@
|
||||
"version": "0.0.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "1.13.6",
|
||||
"axios": "1.16.0",
|
||||
"body-parser": "2.2.0",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"cors": "^2.8.5",
|
||||
@@ -36484,4 +36658,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
10
package.json
10
package.json
@@ -23,7 +23,6 @@
|
||||
"@eslint/compat": "^1.3.2",
|
||||
"@faker-js/faker": "^7.6.0",
|
||||
"@jest/globals": "^29.2.0",
|
||||
"@opencollection/types": "0.9.1",
|
||||
"@playwright/test": "^1.51.1",
|
||||
"@rollup/plugin-json": "^6.1.0",
|
||||
"@storybook/addon-webpack5-compiler-babel": "^4.0.0",
|
||||
@@ -32,6 +31,7 @@
|
||||
"@storybook/react-webpack5": "^10.1.10",
|
||||
"@stylistic/eslint-plugin": "^5.3.1",
|
||||
"@types/jest": "^29.5.11",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "^22.14.1",
|
||||
"@typescript-eslint/parser": "^8.39.0",
|
||||
@@ -43,7 +43,7 @@
|
||||
"globals": "^16.1.0",
|
||||
"husky": "^9.1.7",
|
||||
"jest": "^29.2.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"lodash-es": "^4.17.23",
|
||||
"nano-staged": "^0.8.0",
|
||||
"playwright": "^1.51.1",
|
||||
"pretty-quick": "^3.1.3",
|
||||
@@ -80,7 +80,7 @@
|
||||
"watch:common": "npm run watch --workspace=packages/bruno-common",
|
||||
"watch:requests": "npm run watch --workspace=packages/bruno-requests",
|
||||
"test:codegen": "node playwright/codegen.ts",
|
||||
"test:e2e": "playwright test --project=default",
|
||||
"test:e2e": "playwright test --project=default --project=system-pac",
|
||||
"test:e2e:ssl": "playwright test --project=ssl",
|
||||
"test:e2e:auth": "playwright test --project=auth",
|
||||
"test:benchmark": "playwright test --config=playwright.benchmark.config.ts",
|
||||
@@ -94,9 +94,9 @@
|
||||
]
|
||||
},
|
||||
"overrides": {
|
||||
"axios":"1.13.6",
|
||||
"axios": "1.16.0",
|
||||
"rollup": "3.30.0",
|
||||
"pbkdf2":"3.1.5",
|
||||
"pbkdf2": "3.1.5",
|
||||
"electron-store": {
|
||||
"conf": {
|
||||
"json-schema-typed": "8.0.1"
|
||||
|
||||
@@ -1,3 +1,19 @@
|
||||
global.ResizeObserver = class ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
};
|
||||
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: jest.fn().mockImplementation((query) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn()
|
||||
}))
|
||||
});
|
||||
|
||||
jest.mock('nanoid', () => {
|
||||
return {
|
||||
nanoid: () => {}
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
"react-virtuoso": "^4.18.1",
|
||||
"sass": "^1.46.0",
|
||||
"semver": "^7.7.1",
|
||||
"shell-quote": "^1.8.3",
|
||||
"shell-quote": "^1.8.4",
|
||||
"strip-json-comments": "^5.0.1",
|
||||
"styled-components": "^5.3.3",
|
||||
"swagger-ui-react": "^5.31.0",
|
||||
|
||||
@@ -38,6 +38,9 @@ export default defineConfig({
|
||||
dynamicImportMode: "eager",
|
||||
},
|
||||
},
|
||||
rules: [
|
||||
{ test: /\.md$/, type: 'asset/source' }
|
||||
]
|
||||
},
|
||||
ignoreWarnings: [
|
||||
(warning) => warning.message.includes('Critical dependency: the request of a dependency is an expression') && warning?.moduleDescriptor?.name?.includes('flow-parser')
|
||||
|
||||
298
packages/bruno-app/src/components/AIAssist/StyledWrapper.js
Normal file
298
packages/bruno-app/src/components/AIAssist/StyledWrapper.js
Normal file
@@ -0,0 +1,298 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
z-index: 10;
|
||||
|
||||
.ai-assist-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease, background-color 0.15s ease, border-color 0.15s ease;
|
||||
opacity: 0.7;
|
||||
|
||||
&:hover,
|
||||
&.open {
|
||||
opacity: 1;
|
||||
color: ${(props) => props.theme.colors.accent};
|
||||
background: ${(props) => props.theme.colors.accent}10;
|
||||
border-color: ${(props) => props.theme.input.border};
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid ${(props) => props.theme.colors.accent}55;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.ai-assist-popup {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
right: 0;
|
||||
width: 360px;
|
||||
background: ${(props) => props.theme.bg};
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
border-radius: ${(props) => props.theme.border.radius.md};
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.popup-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid ${(props) => props.theme.input.border};
|
||||
}
|
||||
|
||||
.popup-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: ${(props) => props.theme.text};
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
|
||||
svg {
|
||||
color: ${(props) => props.theme.colors.accent};
|
||||
}
|
||||
}
|
||||
|
||||
.popup-close {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.input.bg};
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
}
|
||||
|
||||
.popup-body {
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.popup-input {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
line-height: 1.4;
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
background: ${(props) => props.theme.input.bg};
|
||||
color: ${(props) => props.theme.text};
|
||||
resize: vertical;
|
||||
outline: none;
|
||||
transition: border-color 0.15s ease;
|
||||
|
||||
&::placeholder {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: ${(props) => props.theme.input.focusBorder};
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.popup-suggestions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.suggestion-chip {
|
||||
padding: 3px 8px;
|
||||
font-size: 11px;
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
border-radius: 999px;
|
||||
background: ${(props) => props.theme.input.bg};
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease, border-color 0.15s ease, background-color 0.15s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: ${(props) => props.theme.text};
|
||||
border-color: ${(props) => props.theme.colors.accent}80;
|
||||
background: ${(props) => props.theme.colors.accent}10;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.popup-error {
|
||||
padding: 6px 8px;
|
||||
font-size: 11px;
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
color: ${(props) => props.theme.colors.text.danger};
|
||||
background: ${(props) => props.theme.colors.bg.danger}15;
|
||||
}
|
||||
|
||||
.popup-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-top: 1px solid ${(props) => props.theme.input.border};
|
||||
}
|
||||
|
||||
.popup-hint {
|
||||
font-size: 11px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
.popup-loading {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid ${(props) => props.theme.input.border};
|
||||
border-top-color: ${(props) => props.theme.colors.accent};
|
||||
border-radius: 50%;
|
||||
animation: ai-assist-spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes ai-assist-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.btn-generate {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
border: 1px solid ${(props) => props.theme.colors.accent};
|
||||
background: ${(props) => props.theme.colors.accent};
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 5px 12px;
|
||||
font-size: 12px;
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
background: transparent;
|
||||
color: ${(props) => props.theme.text};
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: ${(props) => props.theme.input.bg};
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.preview-label {
|
||||
font-size: 11px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.preview-code {
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
padding: 8px 10px;
|
||||
font-family: ${(props) => props.theme.font.monospace || 'monospace'};
|
||||
font-size: 11.5px;
|
||||
line-height: 1.5;
|
||||
color: ${(props) => props.theme.text};
|
||||
background: ${(props) => props.theme.input.bg};
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.preview-modes {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
.preview-mode-btn {
|
||||
padding: 2px 6px;
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
|
||||
&.active {
|
||||
color: ${(props) => props.theme.text};
|
||||
border-color: ${(props) => props.theme.input.border};
|
||||
background: ${(props) => props.theme.input.bg};
|
||||
}
|
||||
|
||||
&:hover:not(.active) {
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
232
packages/bruno-app/src/components/AIAssist/index.js
Normal file
232
packages/bruno-app/src/components/AIAssist/index.js
Normal file
@@ -0,0 +1,232 @@
|
||||
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import get from 'lodash/get';
|
||||
import { IconStars, IconX, IconArrowBackUp } from '@tabler/icons';
|
||||
import { aiGenerateScript } from 'utils/ai';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const SUGGESTIONS = {
|
||||
'tests': [
|
||||
{ label: 'Status 200', prompt: 'Add a test asserting the response status code is 200' },
|
||||
{ label: 'JSON body', prompt: 'Add tests validating the JSON response body structure and key fields' },
|
||||
{ label: 'Headers', prompt: 'Add a test checking the content-type response header' },
|
||||
{ label: 'Response time', prompt: 'Add a test asserting the response time is below 1000ms' }
|
||||
],
|
||||
'pre-request': [
|
||||
{ label: 'Auth header', prompt: 'Set an Authorization header from an environment token variable' },
|
||||
{ label: 'Timestamp', prompt: 'Set a variable named "timestamp" containing the current epoch ms' },
|
||||
{ label: 'Random ID', prompt: 'Set a variable named "requestId" containing a random UUID-style id' }
|
||||
],
|
||||
'post-response': [
|
||||
{ label: 'Save token', prompt: 'Extract a token from the response body and save it to an environment variable' },
|
||||
{ label: 'Save id', prompt: 'Extract the primary id from the response body and save it to a variable' },
|
||||
{ label: 'Log response', prompt: 'Log the response status and a short summary of the body' }
|
||||
]
|
||||
};
|
||||
|
||||
const TITLES = {
|
||||
'tests': 'Generate Tests',
|
||||
'pre-request': 'Generate Pre-Request Script',
|
||||
'post-response': 'Generate Post-Response Script'
|
||||
};
|
||||
|
||||
const isValidType = (t) => SUGGESTIONS[t] !== undefined;
|
||||
|
||||
const AIAssist = ({ scriptType, currentScript, requestContext, onApply }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [generated, setGenerated] = useState(null);
|
||||
const buttonRef = useRef(null);
|
||||
|
||||
const focusOnMount = useCallback((el) => {
|
||||
el?.focus();
|
||||
}, []);
|
||||
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const isAiEnabled = get(preferences, 'ai.enabled', false);
|
||||
|
||||
const suggestions = useMemo(() => SUGGESTIONS[scriptType] || [], [scriptType]);
|
||||
const title = TITLES[scriptType] || 'Generate with AI';
|
||||
|
||||
const close = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
const attachPopup = useCallback((el) => {
|
||||
if (!el) return undefined;
|
||||
const onDocMouseDown = (e) => {
|
||||
if (!el.contains(e.target) && !buttonRef.current?.contains(e.target)) {
|
||||
close();
|
||||
}
|
||||
};
|
||||
const onKey = (e) => {
|
||||
if (e.key === 'Escape') close();
|
||||
};
|
||||
document.addEventListener('mousedown', onDocMouseDown);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDocMouseDown);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [close]);
|
||||
|
||||
const handleGenerate = useCallback(
|
||||
async (overridePrompt) => {
|
||||
const text = (overridePrompt ?? prompt).trim();
|
||||
if (!text || isLoading) return;
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await aiGenerateScript({
|
||||
scriptType,
|
||||
prompt: text,
|
||||
currentScript: currentScript || '',
|
||||
requestContext
|
||||
});
|
||||
if (result?.error) {
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
if (result?.content) {
|
||||
setGenerated(result.content);
|
||||
} else {
|
||||
setError('No content was generated. Try rephrasing your prompt.');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err?.message || 'Failed to generate script');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[prompt, isLoading, scriptType, currentScript, requestContext]
|
||||
);
|
||||
|
||||
const handleApply = useCallback(() => {
|
||||
if (generated == null) return;
|
||||
onApply(generated);
|
||||
setGenerated(null);
|
||||
setPrompt('');
|
||||
close();
|
||||
}, [generated, onApply, close]);
|
||||
|
||||
const handleBackToPrompt = useCallback(() => {
|
||||
setGenerated(null);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
if (!isAiEnabled || !isValidType(scriptType)) return null;
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
className={`ai-assist-trigger ${isOpen ? 'open' : ''}`}
|
||||
onClick={() => setIsOpen((v) => !v)}
|
||||
title={title}
|
||||
type="button"
|
||||
aria-label={title}
|
||||
>
|
||||
<IconStars size={14} strokeWidth={1.75} />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div ref={attachPopup} className="ai-assist-popup" role="dialog" aria-label={title}>
|
||||
<div className="popup-header">
|
||||
<span className="popup-title">
|
||||
<IconStars size={12} strokeWidth={1.75} />
|
||||
{title}
|
||||
</span>
|
||||
<button className="popup-close" onClick={close} type="button" aria-label="Close">
|
||||
<IconX size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{generated == null ? (
|
||||
<>
|
||||
<div className="popup-body">
|
||||
<textarea
|
||||
ref={focusOnMount}
|
||||
className="popup-input"
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
handleGenerate();
|
||||
}
|
||||
}}
|
||||
placeholder="Describe what you want to generate..."
|
||||
rows={3}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
|
||||
{!isLoading && !prompt && suggestions.length > 0 && (
|
||||
<div className="popup-suggestions">
|
||||
{suggestions.map((s) => (
|
||||
<button
|
||||
key={s.label}
|
||||
className="suggestion-chip"
|
||||
type="button"
|
||||
onClick={() => handleGenerate(s.prompt)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div className="popup-error">{error}</div>}
|
||||
</div>
|
||||
|
||||
<div className="popup-footer">
|
||||
{isLoading ? (
|
||||
<span className="popup-loading">
|
||||
<span className="loading-spinner" />
|
||||
Generating...
|
||||
</span>
|
||||
) : (
|
||||
<span className="popup-hint">⌘ + Enter to generate</span>
|
||||
)}
|
||||
<button
|
||||
className="btn-generate"
|
||||
type="button"
|
||||
onClick={() => handleGenerate()}
|
||||
disabled={!prompt.trim() || isLoading}
|
||||
>
|
||||
Generate
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="popup-body">
|
||||
<div className="preview-section">
|
||||
<span className="preview-label">Preview · replaces current script</span>
|
||||
<pre className="preview-code">{generated}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="popup-footer">
|
||||
<button className="btn-secondary" type="button" onClick={handleBackToPrompt}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||
<IconArrowBackUp size={12} /> Back
|
||||
</span>
|
||||
</button>
|
||||
<button className="btn-generate" type="button" onClick={handleApply}>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default AIAssist;
|
||||
@@ -1,12 +1,72 @@
|
||||
import { memo } from 'react';
|
||||
import SwaggerUI from 'swagger-ui-react';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { serializeBody } from './serializeBody';
|
||||
|
||||
const serializeHeaders = (headers) => {
|
||||
if (!headers) return {};
|
||||
if (typeof headers.entries === 'function') {
|
||||
const out = {};
|
||||
for (const [k, v] of headers.entries()) out[k] = v;
|
||||
return out;
|
||||
}
|
||||
return { ...headers };
|
||||
};
|
||||
|
||||
const proxiedFetch = async (url, options = {}) => {
|
||||
const result = await window.ipcRenderer.invoke('renderer:swagger-fetch', {
|
||||
url,
|
||||
method: options.method || 'GET',
|
||||
headers: serializeHeaders(options.headers),
|
||||
body: serializeBody(options.body)
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
const err = new TypeError(result.message);
|
||||
err.code = result.code;
|
||||
throw err;
|
||||
}
|
||||
|
||||
// The Response constructor throws if a null-body status carries a body.
|
||||
const nullBodyStatus = [101, 204, 205, 304].includes(result.status);
|
||||
const bodyBytes = !nullBodyStatus && result.bodyBase64
|
||||
? Uint8Array.from(atob(result.bodyBase64), (c) => c.charCodeAt(0))
|
||||
: null;
|
||||
|
||||
// Build Headers manually so multi-value response headers (e.g. Set-Cookie,
|
||||
// which axios returns as string[]) end up as repeated entries rather than
|
||||
// joined via toString(). new Headers({ 'set-cookie': ['a','b'] }) coerces
|
||||
// the array to "a,b", which is invalid Set-Cookie syntax.
|
||||
const responseHeaders = new Headers();
|
||||
for (const [name, value] of Object.entries(result.headers || {})) {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((v) => responseHeaders.append(name, String(v)));
|
||||
} else if (value != null) {
|
||||
responseHeaders.append(name, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(bodyBytes, {
|
||||
status: result.status,
|
||||
statusText: result.statusText,
|
||||
headers: responseHeaders
|
||||
});
|
||||
};
|
||||
|
||||
const requestInterceptor = (req) => {
|
||||
req.userFetch = proxiedFetch;
|
||||
return req;
|
||||
};
|
||||
|
||||
const Swagger = ({ spec, onComplete }) => {
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="swagger-root w-full">
|
||||
<SwaggerUI spec={spec} onComplete={onComplete} />
|
||||
<SwaggerUI
|
||||
spec={spec}
|
||||
onComplete={onComplete}
|
||||
requestInterceptor={requestInterceptor}
|
||||
/>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
// Serializes a SwaggerUI fetch body for transport across the renderer ↔ main
|
||||
// IPC bridge in `renderer:swagger-fetch`. Only types that survive Electron's
|
||||
// structured-clone serialization (and that our axios bridge knows how to send
|
||||
// as an HTTP body) are supported. Multipart / binary types throw so the user
|
||||
// gets a clear message in the SwaggerUI response panel instead of a silent
|
||||
// failure.
|
||||
|
||||
const detectBodyType = (body) => {
|
||||
if (body == null) return 'null';
|
||||
if (typeof body === 'string') return 'string';
|
||||
if (typeof FormData !== 'undefined' && body instanceof FormData) return 'FormData';
|
||||
if (typeof File !== 'undefined' && body instanceof File) return 'File';
|
||||
if (typeof Blob !== 'undefined' && body instanceof Blob) return 'Blob';
|
||||
if (typeof URLSearchParams !== 'undefined' && body instanceof URLSearchParams) return 'URLSearchParams';
|
||||
if (typeof ArrayBuffer !== 'undefined' && body instanceof ArrayBuffer) return 'ArrayBuffer';
|
||||
if (ArrayBuffer.isView && ArrayBuffer.isView(body)) return body.constructor?.name || 'TypedArray';
|
||||
if (typeof ReadableStream !== 'undefined' && body instanceof ReadableStream) return 'ReadableStream';
|
||||
return typeof body;
|
||||
};
|
||||
|
||||
export const UNSUPPORTED_BODY_TYPE_CODE = 'UNSUPPORTED_BODY_TYPE';
|
||||
|
||||
// Mapping from Web API class name (the raw detected type) to the user-facing
|
||||
// subject used in the error message. SwaggerUI itself supports these body
|
||||
// types fine; the limitation is Bruno's renderer↔main IPC bridge, not Swagger.
|
||||
const BODY_TYPE_LABEL_MAP = {
|
||||
File: 'File upload',
|
||||
Blob: 'Binary file upload',
|
||||
FormData: 'Multipart form data',
|
||||
ArrayBuffer: 'Binary data',
|
||||
ReadableStream: 'Streaming upload'
|
||||
};
|
||||
|
||||
const mapBodyTypeToLabel = (typeName) => {
|
||||
if (BODY_TYPE_LABEL_MAP[typeName]) return BODY_TYPE_LABEL_MAP[typeName];
|
||||
// TypedArrays (Uint8Array, Float32Array, etc.) share a label.
|
||||
if (typeof typeName === 'string' && typeName.endsWith('Array')) return 'Binary data';
|
||||
return 'This request body type';
|
||||
};
|
||||
|
||||
export const UNSUPPORTED_BODY_MESSAGE = (typeName) =>
|
||||
`${mapBodyTypeToLabel(typeName)} via the Swagger Try-it-out panel isn't supported in Bruno yet. `
|
||||
+ `Supported body types: JSON, URL-encoded forms, plain text. `
|
||||
+ `Create a Bruno request to test this endpoint.`;
|
||||
|
||||
// Build a TypeError that carries the detected type as a property so downstream
|
||||
// catchers can branch on `err.code` / `err.bodyType` instead of regex-parsing
|
||||
// the message. `err.bodyType` keeps the raw Web API class name for diagnostics;
|
||||
// the user-visible message uses the friendly subject above.
|
||||
const unsupportedBodyError = (typeName) => {
|
||||
const err = new TypeError(UNSUPPORTED_BODY_MESSAGE(typeName));
|
||||
err.code = UNSUPPORTED_BODY_TYPE_CODE;
|
||||
err.bodyType = typeName;
|
||||
return err;
|
||||
};
|
||||
|
||||
export const serializeBody = (body) => {
|
||||
const typeName = detectBodyType(body);
|
||||
|
||||
switch (typeName) {
|
||||
case 'null':
|
||||
return undefined;
|
||||
case 'string':
|
||||
return body;
|
||||
case 'URLSearchParams':
|
||||
return body.toString();
|
||||
case 'FormData':
|
||||
case 'File':
|
||||
case 'Blob':
|
||||
case 'ArrayBuffer':
|
||||
case 'ReadableStream':
|
||||
throw unsupportedBodyError(typeName);
|
||||
default:
|
||||
// TypedArrays land here (Uint8Array, etc.) — also unsupported by the bridge.
|
||||
if (ArrayBuffer.isView && ArrayBuffer.isView(body)) {
|
||||
throw unsupportedBodyError(typeName);
|
||||
}
|
||||
// Plain objects, numbers, booleans — pass through. SwaggerUI rarely sends
|
||||
// these as body directly (it stringifies JSON before fetch), but keep the
|
||||
// path open rather than rejecting unexpectedly.
|
||||
return body;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
import { serializeBody, UNSUPPORTED_BODY_MESSAGE, UNSUPPORTED_BODY_TYPE_CODE } from './serializeBody';
|
||||
|
||||
// Helper: invoke serializeBody and return the thrown error (or fail).
|
||||
const catchSerializeError = (body) => {
|
||||
try {
|
||||
serializeBody(body);
|
||||
} catch (err) {
|
||||
return err;
|
||||
}
|
||||
throw new Error('expected serializeBody to throw');
|
||||
};
|
||||
|
||||
describe('serializeBody', () => {
|
||||
describe('supported body types', () => {
|
||||
it('returns undefined for null', () => {
|
||||
expect(serializeBody(null)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for undefined', () => {
|
||||
expect(serializeBody(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns string bodies as-is', () => {
|
||||
expect(serializeBody('{"name":"doggie"}')).toBe('{"name":"doggie"}');
|
||||
expect(serializeBody('plain text')).toBe('plain text');
|
||||
});
|
||||
|
||||
it('stringifies URLSearchParams', () => {
|
||||
const params = new URLSearchParams({ a: '1', b: '2' });
|
||||
expect(serializeBody(params)).toBe('a=1&b=2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('unsupported body types (BRU-3300)', () => {
|
||||
it('throws TypeError for FormData using "Multipart form data" subject', () => {
|
||||
const fd = new FormData();
|
||||
fd.append('file', new Blob(['x']));
|
||||
expect(() => serializeBody(fd)).toThrow(TypeError);
|
||||
expect(() => serializeBody(fd)).toThrow(/Multipart form data/);
|
||||
expect(() => serializeBody(fd)).toThrow(/Create a Bruno request/);
|
||||
});
|
||||
|
||||
it('throws TypeError for Blob using "Binary file upload" subject', () => {
|
||||
const blob = new Blob(['payload']);
|
||||
expect(() => serializeBody(blob)).toThrow(TypeError);
|
||||
expect(() => serializeBody(blob)).toThrow(/Binary file upload/);
|
||||
});
|
||||
|
||||
it('throws TypeError for File using "File upload" subject', () => {
|
||||
const file = new File(['payload'], 'test.txt', { type: 'text/plain' });
|
||||
expect(() => serializeBody(file)).toThrow(TypeError);
|
||||
expect(() => serializeBody(file)).toThrow(/File upload/);
|
||||
});
|
||||
|
||||
it('throws TypeError for ArrayBuffer using "Binary data" subject', () => {
|
||||
const buf = new ArrayBuffer(8);
|
||||
expect(() => serializeBody(buf)).toThrow(TypeError);
|
||||
expect(() => serializeBody(buf)).toThrow(/Binary data/);
|
||||
});
|
||||
|
||||
it('throws TypeError for TypedArray using "Binary data" subject', () => {
|
||||
const u8 = new Uint8Array([1, 2, 3]);
|
||||
expect(() => serializeBody(u8)).toThrow(TypeError);
|
||||
expect(() => serializeBody(u8)).toThrow(/Binary data/);
|
||||
});
|
||||
|
||||
it('message attributes the limitation to Bruno, not Swagger', () => {
|
||||
expect(UNSUPPORTED_BODY_MESSAGE('FormData')).toMatch(/isn't supported in Bruno yet/);
|
||||
});
|
||||
|
||||
it('message lists supported alternatives', () => {
|
||||
expect(UNSUPPORTED_BODY_MESSAGE('FormData')).toMatch(/JSON, URL-encoded forms, plain text/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error metadata preservation (Bijin review feedback)', () => {
|
||||
it('attaches err.code = UNSUPPORTED_BODY_TYPE so callers can branch programmatically', () => {
|
||||
const err = catchSerializeError(new FormData());
|
||||
expect(err.code).toBe(UNSUPPORTED_BODY_TYPE_CODE);
|
||||
expect(UNSUPPORTED_BODY_TYPE_CODE).toBe('UNSUPPORTED_BODY_TYPE');
|
||||
});
|
||||
|
||||
it('attaches err.bodyType naming the specific unsupported type', () => {
|
||||
expect(catchSerializeError(new FormData()).bodyType).toBe('FormData');
|
||||
expect(catchSerializeError(new Blob(['x'])).bodyType).toBe('Blob');
|
||||
expect(catchSerializeError(new File(['x'], 'a.txt')).bodyType).toBe('File');
|
||||
expect(catchSerializeError(new ArrayBuffer(4)).bodyType).toBe('ArrayBuffer');
|
||||
expect(catchSerializeError(new Uint8Array([1, 2])).bodyType).toBe('Uint8Array');
|
||||
});
|
||||
|
||||
it('thrown error is still a TypeError instance', () => {
|
||||
expect(catchSerializeError(new FormData())).toBeInstanceOf(TypeError);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -152,8 +152,10 @@ const AppTitleBar = () => {
|
||||
|
||||
const handleOpenWorkspace = async () => {
|
||||
try {
|
||||
await dispatch(openWorkspaceDialog());
|
||||
toast.success('Workspace opened successfully');
|
||||
const result = await dispatch(openWorkspaceDialog());
|
||||
if (result) {
|
||||
toast.success('Workspace opened successfully');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error.message || 'Failed to open workspace');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# What's New in Bruno
|
||||
|
||||
- Various stability and performance improvements.
|
||||
|
||||
---
|
||||
|
||||
For the full release history, see the [Bruno releases page](https://github.com/usebruno/bruno/releases).
|
||||
@@ -0,0 +1,31 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
.changelog-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid ${(props) => props.theme.requestTabs?.border || props.theme.sidebar?.border || 'transparent'};
|
||||
color: ${(props) => props.theme.text};
|
||||
|
||||
.header-version {
|
||||
font-size: ${(props) => props.theme.font?.size?.sm || '0.85em'};
|
||||
color: ${(props) => props.theme.colors?.text?.muted || props.theme.text};
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.changelog-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem 1.5rem 2rem 1.5rem;
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
23
packages/bruno-app/src/components/ChangelogTab/index.js
Normal file
23
packages/bruno-app/src/components/ChangelogTab/index.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
import { IconConfetti } from '@tabler/icons';
|
||||
import Markdown from 'components/MarkDown';
|
||||
import { version } from '../../../package.json';
|
||||
import changelogContent from './CHANGELOG.md';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const ChangelogTab = () => {
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="changelog-header">
|
||||
<IconConfetti size={18} strokeWidth={1.5} />
|
||||
<span>What's New</span>
|
||||
<span className="header-version">v{version}</span>
|
||||
</div>
|
||||
<div className="changelog-body">
|
||||
<Markdown content={changelogContent} onDoubleClick={() => {}} />
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChangelogTab;
|
||||
@@ -165,6 +165,32 @@ const StyledWrapper = styled.div`
|
||||
background: ${(props) => props.theme.codemirror.searchLineHighlightCurrent};
|
||||
}
|
||||
|
||||
@keyframes cm-error-line-flash {
|
||||
0%, 60% {
|
||||
background-color: ${(props) => props.theme.status.danger.background};
|
||||
}
|
||||
100% {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.CodeMirror .cm-error-line-flash {
|
||||
background-color: transparent;
|
||||
animation: cm-error-line-flash 3s ease-in-out;
|
||||
}
|
||||
|
||||
.CodeMirror .cm-error-line-flash-gutter {
|
||||
color: ${(props) => props.theme.colors.text.danger} !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.CodeMirror .cm-error-line-flash {
|
||||
animation: none;
|
||||
background-color: ${(props) => props.theme.status.danger.background};
|
||||
}
|
||||
}
|
||||
|
||||
.cm-search-match {
|
||||
background: rgba(255, 193, 7, 0.25);
|
||||
}
|
||||
|
||||
@@ -75,13 +75,13 @@ const AuthMode = ({ collection }) => {
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="inline-flex items-center cursor-pointer auth-mode-selector">
|
||||
<div className="inline-flex items-center cursor-pointer auth-mode-selector" data-testid="auth-mode-selector">
|
||||
<MenuDropdown
|
||||
items={menuItems}
|
||||
placement="bottom-end"
|
||||
selectedItemId={authMode}
|
||||
>
|
||||
<div className="flex items-center justify-center auth-mode-label select-none">
|
||||
<div className="flex items-center justify-center auth-mode-label select-none" data-testid="auth-mode-label">
|
||||
{humanizeRequestAuthMode(authMode)} <IconCaretDown className="caret ml-1" size={14} strokeWidth={2} />
|
||||
</div>
|
||||
</MenuDropdown>
|
||||
|
||||
@@ -5,10 +5,11 @@ import { updateCollectionPresets } from 'providers/ReduxStore/slices/collections
|
||||
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { get } from 'lodash';
|
||||
import Button from 'ui/Button';
|
||||
import { DEFAULT_PRESET_REQUEST_TYPE, PRESET_REQUEST_TYPES } from 'utils/common/constants';
|
||||
|
||||
const PresetsSettings = ({ collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const initialPresets = { requestType: 'http', requestUrl: '' };
|
||||
const initialPresets = { requestType: DEFAULT_PRESET_REQUEST_TYPE, requestUrl: '' };
|
||||
|
||||
// Get presets from draft.brunoConfig if it exists, otherwise from brunoConfig
|
||||
const currentPresets = collection.draft?.brunoConfig
|
||||
@@ -47,12 +48,13 @@ const PresetsSettings = ({ collection }) => {
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
id="http"
|
||||
data-testid="presets-request-type-http"
|
||||
className="cursor-pointer"
|
||||
type="radio"
|
||||
name="requestType"
|
||||
onChange={handleRequestTypeChange}
|
||||
value="http"
|
||||
checked={(currentPresets.requestType || 'http') === 'http'}
|
||||
value={PRESET_REQUEST_TYPES.HTTP}
|
||||
checked={(currentPresets.requestType || DEFAULT_PRESET_REQUEST_TYPE) === PRESET_REQUEST_TYPES.HTTP}
|
||||
/>
|
||||
<label htmlFor="http" className="ml-1 cursor-pointer select-none">
|
||||
HTTP
|
||||
@@ -60,12 +62,13 @@ const PresetsSettings = ({ collection }) => {
|
||||
|
||||
<input
|
||||
id="graphql"
|
||||
data-testid="presets-request-type-graphql"
|
||||
className="ml-4 cursor-pointer"
|
||||
type="radio"
|
||||
name="requestType"
|
||||
onChange={handleRequestTypeChange}
|
||||
value="graphql"
|
||||
checked={(currentPresets.requestType || 'http') === 'graphql'}
|
||||
value={PRESET_REQUEST_TYPES.GRAPHQL}
|
||||
checked={(currentPresets.requestType || DEFAULT_PRESET_REQUEST_TYPE) === PRESET_REQUEST_TYPES.GRAPHQL}
|
||||
/>
|
||||
<label htmlFor="graphql" className="ml-1 cursor-pointer select-none">
|
||||
GraphQL
|
||||
@@ -73,12 +76,13 @@ const PresetsSettings = ({ collection }) => {
|
||||
|
||||
<input
|
||||
id="grpc"
|
||||
data-testid="presets-request-type-grpc"
|
||||
className="ml-4 cursor-pointer"
|
||||
type="radio"
|
||||
name="requestType"
|
||||
onChange={handleRequestTypeChange}
|
||||
value="grpc"
|
||||
checked={(currentPresets.requestType || 'http') === 'grpc'}
|
||||
value={PRESET_REQUEST_TYPES.GRPC}
|
||||
checked={(currentPresets.requestType || DEFAULT_PRESET_REQUEST_TYPE) === PRESET_REQUEST_TYPES.GRPC}
|
||||
/>
|
||||
<label htmlFor="grpc" className="ml-1 cursor-pointer select-none">
|
||||
gRPC
|
||||
@@ -86,12 +90,13 @@ const PresetsSettings = ({ collection }) => {
|
||||
|
||||
<input
|
||||
id="ws"
|
||||
data-testid="presets-request-type-ws"
|
||||
className="ml-4 cursor-pointer"
|
||||
type="radio"
|
||||
name="requestType"
|
||||
onChange={handleRequestTypeChange}
|
||||
value="ws"
|
||||
checked={(currentPresets.requestType || 'http') === 'ws'}
|
||||
value={PRESET_REQUEST_TYPES.WS}
|
||||
checked={(currentPresets.requestType || DEFAULT_PRESET_REQUEST_TYPE) === PRESET_REQUEST_TYPES.WS}
|
||||
/>
|
||||
<label htmlFor="ws" className="ml-1 cursor-pointer select-none">
|
||||
WebSocket
|
||||
@@ -106,6 +111,7 @@ const PresetsSettings = ({ collection }) => {
|
||||
<div className="flex items-center flex-grow input-container h-full">
|
||||
<input
|
||||
id="request-url"
|
||||
data-testid="presets-request-url"
|
||||
type="text"
|
||||
name="requestUrl"
|
||||
placeholder="Request URL"
|
||||
@@ -123,7 +129,7 @@ const PresetsSettings = ({ collection }) => {
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<Button type="button" size="sm" onClick={handleSave}>
|
||||
<Button type="button" size="sm" data-testid="presets-save-btn" onClick={handleSave}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import get from 'lodash/get';
|
||||
import find from 'lodash/find';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import CodeEditor from 'components/CodeEditor';
|
||||
import AIAssist from 'components/AIAssist';
|
||||
import { updateCollectionRequestScript, updateCollectionResponseScript } from 'providers/ReduxStore/slices/collections';
|
||||
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { updateScriptPaneTab } from 'providers/ReduxStore/slices/tabs';
|
||||
@@ -13,6 +14,7 @@ import { flattenItems, isItemARequest } from 'utils/collections';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import Button from 'ui/Button';
|
||||
import { usePersistedState } from 'hooks/usePersistedState';
|
||||
import { useFocusErrorLine } from 'hooks/useFocusErrorLine';
|
||||
|
||||
const Script = ({ collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -59,6 +61,20 @@ const Script = ({ collection }) => {
|
||||
return () => clearTimeout(timer);
|
||||
}, [activeTab]);
|
||||
|
||||
useFocusErrorLine({
|
||||
uid: collection.uid,
|
||||
editorRef: preRequestEditorRef,
|
||||
scriptPhase: 'pre-request',
|
||||
isVisible: activeTab === 'pre-request'
|
||||
});
|
||||
|
||||
useFocusErrorLine({
|
||||
uid: collection.uid,
|
||||
editorRef: postResponseEditorRef,
|
||||
scriptPhase: 'post-response',
|
||||
isVisible: activeTab === 'post-response'
|
||||
});
|
||||
|
||||
const onRequestScriptEdit = (value) => {
|
||||
dispatch(
|
||||
updateCollectionRequestScript({
|
||||
@@ -108,39 +124,53 @@ const Script = ({ collection }) => {
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="pre-request" className="mt-2" dataTestId="collection-pre-request-script-editor">
|
||||
<CodeEditor
|
||||
ref={preRequestEditorRef}
|
||||
collection={collection}
|
||||
docKey="collection-script:pre-request"
|
||||
value={requestScript || ''}
|
||||
theme={displayedTheme}
|
||||
onEdit={onRequestScriptEdit}
|
||||
mode="javascript"
|
||||
onSave={handleSave}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
showHintsFor={['req', 'bru']}
|
||||
initialScroll={preReqScroll}
|
||||
onScroll={setPreReqScroll}
|
||||
/>
|
||||
<div className="relative h-full">
|
||||
<CodeEditor
|
||||
ref={preRequestEditorRef}
|
||||
collection={collection}
|
||||
docKey="collection-script:pre-request"
|
||||
value={requestScript || ''}
|
||||
theme={displayedTheme}
|
||||
onEdit={onRequestScriptEdit}
|
||||
mode="javascript"
|
||||
onSave={handleSave}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
showHintsFor={['req', 'bru']}
|
||||
initialScroll={preReqScroll}
|
||||
onScroll={setPreReqScroll}
|
||||
/>
|
||||
<AIAssist
|
||||
scriptType="pre-request"
|
||||
currentScript={requestScript || ''}
|
||||
onApply={onRequestScriptEdit}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="post-response" className="mt-2" dataTestId="collection-post-response-script-editor">
|
||||
<CodeEditor
|
||||
ref={postResponseEditorRef}
|
||||
collection={collection}
|
||||
docKey="collection-script:post-response"
|
||||
value={responseScript || ''}
|
||||
theme={displayedTheme}
|
||||
onEdit={onResponseScriptEdit}
|
||||
mode="javascript"
|
||||
onSave={handleSave}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
showHintsFor={['req', 'res', 'bru']}
|
||||
initialScroll={postResScroll}
|
||||
onScroll={setPostResScroll}
|
||||
/>
|
||||
<div className="relative h-full">
|
||||
<CodeEditor
|
||||
ref={postResponseEditorRef}
|
||||
collection={collection}
|
||||
docKey="collection-script:post-response"
|
||||
value={responseScript || ''}
|
||||
theme={displayedTheme}
|
||||
onEdit={onResponseScriptEdit}
|
||||
mode="javascript"
|
||||
onSave={handleSave}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
showHintsFor={['req', 'res', 'bru']}
|
||||
initialScroll={postResScroll}
|
||||
onScroll={setPostResScroll}
|
||||
/>
|
||||
<AIAssist
|
||||
scriptType="post-response"
|
||||
currentScript={responseScript || ''}
|
||||
onApply={onResponseScriptEdit}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
|
||||
@@ -2,12 +2,14 @@ import React, { useRef } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import CodeEditor from 'components/CodeEditor';
|
||||
import AIAssist from 'components/AIAssist';
|
||||
import { updateCollectionTests } from 'providers/ReduxStore/slices/collections';
|
||||
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import Button from 'ui/Button';
|
||||
import { usePersistedState } from 'hooks/usePersistedState';
|
||||
import { useFocusErrorLine } from 'hooks/useFocusErrorLine';
|
||||
|
||||
const Tests = ({ collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -29,24 +31,33 @@ const Tests = ({ collection }) => {
|
||||
|
||||
const handleSave = () => dispatch(saveCollectionSettings(collection.uid));
|
||||
|
||||
useFocusErrorLine({
|
||||
uid: collection.uid,
|
||||
editorRef: testsEditorRef,
|
||||
scriptPhase: 'test'
|
||||
});
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full flex flex-col h-full">
|
||||
<div className="text-xs mb-4 text-muted">These tests will run any time a request in this collection is sent.</div>
|
||||
<CodeEditor
|
||||
ref={testsEditorRef}
|
||||
collection={collection}
|
||||
docKey="collection-tests"
|
||||
value={tests || ''}
|
||||
theme={displayedTheme}
|
||||
onEdit={onEdit}
|
||||
mode="javascript"
|
||||
onSave={handleSave}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
showHintsFor={['req', 'res', 'bru']}
|
||||
initialScroll={testsScroll}
|
||||
onScroll={setTestsScroll}
|
||||
/>
|
||||
<div className="relative h-full">
|
||||
<CodeEditor
|
||||
ref={testsEditorRef}
|
||||
collection={collection}
|
||||
docKey="collection-tests"
|
||||
value={tests || ''}
|
||||
theme={displayedTheme}
|
||||
onEdit={onEdit}
|
||||
mode="javascript"
|
||||
onSave={handleSave}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
showHintsFor={['req', 'res', 'bru']}
|
||||
initialScroll={testsScroll}
|
||||
onScroll={setTestsScroll}
|
||||
/>
|
||||
<AIAssist scriptType="tests" currentScript={tests || ''} onApply={onEdit} />
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<Button type="submit" size="sm" onClick={handleSave}>
|
||||
|
||||
@@ -5,6 +5,8 @@ import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/
|
||||
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
|
||||
import MultiLineEditor from 'components/MultiLineEditor';
|
||||
import InfoTip from 'components/InfoTip';
|
||||
import DataTypeSelector from 'components/DataTypeSelector';
|
||||
import { valueToString } from '@usebruno/common/utils';
|
||||
import EditableTable from 'components/EditableTable';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import toast from 'react-hot-toast';
|
||||
@@ -57,15 +59,31 @@ const VarsTable = ({ collection, vars, varType, initialScroll = 0 }) => {
|
||||
</div>
|
||||
),
|
||||
placeholder: varType === 'request' ? 'Value' : 'Expr',
|
||||
render: ({ value, onChange }) => (
|
||||
<MultiLineEditor
|
||||
value={value || ''}
|
||||
theme={storedTheme}
|
||||
onSave={onSave}
|
||||
onChange={onChange}
|
||||
collection={collection}
|
||||
placeholder={!value ? (varType === 'request' ? 'Value' : 'Expr') : ''}
|
||||
/>
|
||||
render: ({ row, value, onChange, isLastEmptyRow }) => (
|
||||
<div className="flex items-center w-full gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<MultiLineEditor
|
||||
value={valueToString(value)}
|
||||
theme={storedTheme}
|
||||
onSave={onSave}
|
||||
onChange={onChange}
|
||||
collection={collection}
|
||||
placeholder={value == null || (typeof value === 'string' && value.trim() === '') ? (varType === 'request' ? 'Value' : 'Expr') : ''}
|
||||
/>
|
||||
</div>
|
||||
{/* DataTypes apply to literal values, not to the JS expression that produces a post-response value. */}
|
||||
{!isLastEmptyRow && varType === 'request' && (
|
||||
<DataTypeSelector
|
||||
variable={row}
|
||||
theme={storedTheme}
|
||||
collection={collection}
|
||||
onChange={(fields) => {
|
||||
const updated = (vars || []).map((v) => v.uid === row.uid ? { ...v, ...fields } : v);
|
||||
handleVarsChange(updated);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
];
|
||||
@@ -80,6 +98,7 @@ const VarsTable = ({ collection, vars, varType, initialScroll = 0 }) => {
|
||||
<StyledWrapper className="w-full">
|
||||
<EditableTable
|
||||
tableId="collection-vars"
|
||||
testId={`collection-vars-${varType === 'response' ? 'res' : 'req'}`}
|
||||
columns={columns}
|
||||
rows={vars}
|
||||
onChange={handleVarsChange}
|
||||
|
||||
@@ -15,6 +15,7 @@ import StyledWrapper from './StyledWrapper';
|
||||
import Vars from './Vars/index';
|
||||
import StatusDot from 'components/StatusDot';
|
||||
import Overview from './Overview/index';
|
||||
import { DEFAULT_PRESET_REQUEST_TYPE } from 'utils/common/constants';
|
||||
|
||||
const CollectionSettings = ({ collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -60,7 +61,7 @@ const CollectionSettings = ({ collection }) => {
|
||||
? get(collection, 'draft.brunoConfig.protobuf', {})
|
||||
: get(collection, 'brunoConfig.protobuf', {});
|
||||
const presets = collection.draft?.brunoConfig ? get(collection, 'draft.brunoConfig.presets', {}) : get(collection, 'brunoConfig.presets', {});
|
||||
const hasPresets = presets && presets.requestUrl !== '';
|
||||
const hasPresets = presets && ((presets.requestType && presets.requestType !== DEFAULT_PRESET_REQUEST_TYPE) || (presets.requestUrl && presets.requestUrl !== ''));
|
||||
|
||||
const getTabPanel = (tab) => {
|
||||
switch (tab) {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.type-label {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 150px;
|
||||
display: inline-block;
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.caret-icon {
|
||||
opacity: 0.7;
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
58
packages/bruno-app/src/components/DataTypeSelector/index.js
Normal file
58
packages/bruno-app/src/components/DataTypeSelector/index.js
Normal file
@@ -0,0 +1,58 @@
|
||||
import React from 'react';
|
||||
import { IconAlertCircle, IconCaretDown } from '@tabler/icons';
|
||||
import { Tooltip } from 'react-tooltip';
|
||||
import { BRUNO_VARIABLE_DATATYPES, parseValueByDataType, validateDataTypeValue } from '@usebruno/common/utils';
|
||||
import MenuDropdown from 'ui/MenuDropdown';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const DataTypeSelector = ({ variable, onChange }) => {
|
||||
const selectedType = variable.dataType || 'string';
|
||||
const coercedValue = parseValueByDataType(variable.value, selectedType);
|
||||
const typeError = validateDataTypeValue(coercedValue, selectedType);
|
||||
|
||||
const handleTypeChange = (type) => {
|
||||
onChange({ dataType: type === 'string' ? undefined : type });
|
||||
};
|
||||
|
||||
const items = BRUNO_VARIABLE_DATATYPES.map((type) => ({
|
||||
id: type,
|
||||
label: type,
|
||||
onClick: () => handleTypeChange(type)
|
||||
}));
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="flex items-center relative">
|
||||
<MenuDropdown
|
||||
items={items}
|
||||
selectedItemId={selectedType}
|
||||
placement="bottom-end"
|
||||
showTickMark={true}
|
||||
appendTo={() => document.body}
|
||||
>
|
||||
<div className="flex items-center cursor-pointer select-none">
|
||||
<span className="type-label">{selectedType}</span>
|
||||
<IconCaretDown className="caret-icon ml-1" size={14} strokeWidth={2} />
|
||||
</div>
|
||||
</MenuDropdown>
|
||||
{typeError && (
|
||||
<span className="ml-1">
|
||||
<IconAlertCircle
|
||||
data-tooltip-id={`type-error-${variable.uid}`}
|
||||
className="text-yellow-600 cursor-pointer"
|
||||
size={16}
|
||||
/>
|
||||
<Tooltip
|
||||
className="tooltip-mod"
|
||||
id={`type-error-${variable.uid}`}
|
||||
content={typeError}
|
||||
place="top"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(DataTypeSelector);
|
||||
@@ -69,13 +69,22 @@ const StyledWrapper = styled.div`
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
min-height: 0; /* Important for proper flex behavior */
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.col-separator {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: ${(props) => props.theme.console.border};
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.requests-header {
|
||||
display: grid;
|
||||
grid-template-columns: 80px 80px 150px 1fr 100px 80px 80px;
|
||||
gap: 12px;
|
||||
padding: 4px 16px;
|
||||
padding: 0;
|
||||
background: ${(props) => props.theme.console.headerBg};
|
||||
border-bottom: 1px solid ${(props) => props.theme.console.border};
|
||||
font-size: 10px;
|
||||
@@ -83,6 +92,39 @@ const StyledWrapper = styled.div`
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.header-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
&:first-child {
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
padding-right: 16px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => props.theme.console.messageColor};
|
||||
}
|
||||
|
||||
span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
.requests-list {
|
||||
@@ -94,9 +136,7 @@ const StyledWrapper = styled.div`
|
||||
|
||||
.request-row {
|
||||
display: grid;
|
||||
grid-template-columns: 80px 80px 150px 1fr 100px 80px 80px;
|
||||
gap: 12px;
|
||||
padding: 2px 16px;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.1s ease;
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
@@ -107,12 +147,19 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
|
||||
&.selected {
|
||||
padding-left: 13px;
|
||||
background: ${(props) => props.theme.console.logHoverBg};
|
||||
border-left: 3px solid ${(props) => props.theme.console.checkboxColor};
|
||||
box-shadow: inset 3px 0 0 ${(props) => props.theme.console.checkboxColor};
|
||||
}
|
||||
}
|
||||
|
||||
.request-method {
|
||||
padding: 2px 8px 2px 16px;
|
||||
}
|
||||
|
||||
.request-status {
|
||||
padding: 2px 8px;
|
||||
}
|
||||
|
||||
.method-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -128,6 +175,7 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
|
||||
.request-domain {
|
||||
padding: 2px 8px;
|
||||
color: ${(props) => props.theme.console.messageColor};
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -135,6 +183,7 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
|
||||
.request-path {
|
||||
padding: 2px 8px;
|
||||
color: ${(props) => props.theme.console.messageColor};
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -143,19 +192,26 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
|
||||
.request-time {
|
||||
padding: 2px 8px;
|
||||
color: ${(props) => props.theme.console.timestampColor};
|
||||
font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||
font-size: ${(props) => props.theme.font.size.xs};
|
||||
}
|
||||
|
||||
.request-duration {
|
||||
padding: 2px 8px;
|
||||
color: ${(props) => props.theme.console.messageColor};
|
||||
font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||
font-size: ${(props) => props.theme.font.size.xs};
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.text-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.request-size {
|
||||
padding: 2px 8px;
|
||||
color: ${(props) => props.theme.console.messageColor};
|
||||
font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||
font-size: ${(props) => props.theme.font.size.xs};
|
||||
|
||||
@@ -1,12 +1,26 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import {
|
||||
IconNetwork
|
||||
IconNetwork,
|
||||
IconArrowUp,
|
||||
IconArrowDown
|
||||
} from '@tabler/icons';
|
||||
import {
|
||||
setSelectedRequest
|
||||
} from 'providers/ReduxStore/slices/logs';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { getGridTemplate, getSeparatorPositions, sortRequests } from './utils';
|
||||
|
||||
// TODO: Columns will be resizable in the future, so width can be null (for auto) or a number (for fixed width)
|
||||
const COLUMNS = [
|
||||
{ key: 'method', label: 'Method', width: 90, align: 'left' },
|
||||
{ key: 'status', label: 'Status', width: 80, align: 'left' },
|
||||
{ key: 'domain', label: 'Domain', width: 200, align: 'left' },
|
||||
{ key: 'path', label: 'Path', width: null, align: 'left' },
|
||||
{ key: 'time', label: 'Time', width: 100, align: 'left' },
|
||||
{ key: 'duration', label: 'Duration', width: 120, align: 'right' },
|
||||
{ key: 'size', label: 'Size', width: 80, align: 'right' }
|
||||
];
|
||||
|
||||
const MethodBadge = ({ method }) => {
|
||||
const methodLower = method?.toLowerCase() || 'get';
|
||||
@@ -28,7 +42,7 @@ const StatusBadge = ({ status, statusCode }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const RequestRow = ({ request, isSelected, onClick }) => {
|
||||
const RequestRow = ({ request, isSelected, onClick, gridTemplateColumns }) => {
|
||||
const { data } = request;
|
||||
const { request: req, response: res, timestamp } = data;
|
||||
|
||||
@@ -82,6 +96,9 @@ const RequestRow = ({ request, isSelected, onClick }) => {
|
||||
<div
|
||||
className={`request-row ${isSelected ? 'selected' : ''}`}
|
||||
onClick={onClick}
|
||||
style={{ gridTemplateColumns }}
|
||||
data-testid="network-request-row"
|
||||
|
||||
>
|
||||
<div className="request-method">
|
||||
<MethodBadge method={req?.method} />
|
||||
@@ -116,6 +133,9 @@ const RequestRow = ({ request, isSelected, onClick }) => {
|
||||
|
||||
const NetworkTab = () => {
|
||||
const dispatch = useDispatch();
|
||||
const [sortConfig, setSortConfig] = useState({ key: null, direction: null });
|
||||
const gridTemplateColumns = useMemo(() => getGridTemplate(COLUMNS), []);
|
||||
const separatorPositions = useMemo(() => getSeparatorPositions(COLUMNS), []);
|
||||
const { networkFilters, selectedRequest } = useSelector((state) => state.logs);
|
||||
const collections = useSelector((state) => state.collections.collections);
|
||||
|
||||
@@ -150,6 +170,21 @@ const NetworkTab = () => {
|
||||
dispatch(setSelectedRequest(request));
|
||||
};
|
||||
|
||||
const handleHeaderClick = (key) => {
|
||||
setSortConfig((prev) => {
|
||||
// If clicking a different column, start with ascending sort
|
||||
if (prev.key !== key) return { key, direction: 'asc' };
|
||||
|
||||
if (prev.direction === 'asc') return { key, direction: 'desc' };
|
||||
return { key: null, direction: null };
|
||||
});
|
||||
};
|
||||
|
||||
const sortedRequests = useMemo(
|
||||
() => sortRequests(filteredRequests, sortConfig.key, sortConfig.direction),
|
||||
[filteredRequests, sortConfig]
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="network-content">
|
||||
@@ -161,26 +196,45 @@ const NetworkTab = () => {
|
||||
</div>
|
||||
) : (
|
||||
<div className="requests-container">
|
||||
<div className="requests-header">
|
||||
<div>Method</div>
|
||||
<div>Status</div>
|
||||
<div>Domain</div>
|
||||
<div>Path</div>
|
||||
<div>Time</div>
|
||||
<div className="text-right">Duration</div>
|
||||
<div className="text-right">Size</div>
|
||||
<div className="requests-header" style={{ gridTemplateColumns }}>
|
||||
{COLUMNS.map((col) => (
|
||||
<div
|
||||
key={col.key}
|
||||
className={`header-cell${col.align === 'right' ? ' text-right' : ''}`}
|
||||
onClick={() => handleHeaderClick(col.key)}
|
||||
data-testid={`network-header-${col.key}`}
|
||||
>
|
||||
<span title={col.label}>{col.label}</span>
|
||||
{sortConfig.key === col.key && (
|
||||
sortConfig.direction === 'asc'
|
||||
? <IconArrowUp size={14} strokeWidth={2} data-testid="sort-icon-asc" />
|
||||
: <IconArrowDown size={14} strokeWidth={2} data-testid="sort-icon-desc" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="requests-list">
|
||||
{filteredRequests.map((request, index) => (
|
||||
{sortedRequests.map((request, index) => (
|
||||
<RequestRow
|
||||
key={`${request.collectionUid}-${request.itemUid}-${request.timestamp}-${index}`}
|
||||
request={request}
|
||||
isSelected={selectedRequest?.timestamp === request.timestamp && selectedRequest?.itemUid === request.itemUid}
|
||||
onClick={() => handleRequestClick(request)}
|
||||
gridTemplateColumns={gridTemplateColumns}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{separatorPositions.map((pos, i) =>
|
||||
pos ? (
|
||||
<div
|
||||
key={i}
|
||||
className="col-separator"
|
||||
style={'left' in pos ? { left: `${pos.left}px` } : { right: `${pos.right}px` }}
|
||||
/>
|
||||
) : null
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { Provider } from 'react-redux';
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { ThemeProvider } from 'providers/Theme';
|
||||
import NetworkTab from './index';
|
||||
|
||||
const makeRequest = (overrides = {}) => ({
|
||||
type: 'request',
|
||||
timestamp: overrides.timestamp ?? 1000,
|
||||
collectionUid: overrides.collectionUid ?? 'col-1',
|
||||
itemUid: overrides.itemUid ?? 'item-1',
|
||||
collectionName: 'Test Collection',
|
||||
data: {
|
||||
request: {
|
||||
method: overrides.method ?? 'GET',
|
||||
url: overrides.url ?? 'https://example.com/api/users'
|
||||
},
|
||||
response: {
|
||||
status: overrides.status ?? 200,
|
||||
statusCode: overrides.statusCode ?? 200,
|
||||
// Use 'in' check so callers can explicitly pass undefined to test missing-value behaviour
|
||||
...('duration' in overrides ? { duration: overrides.duration } : { duration: 100 }),
|
||||
...('size' in overrides ? { size: overrides.size } : { size: 512 })
|
||||
},
|
||||
timestamp: overrides.timestamp ?? 1000
|
||||
}
|
||||
});
|
||||
|
||||
const ALL_FILTERS = { GET: true, POST: true, PUT: true, DELETE: true, PATCH: true, HEAD: true, OPTIONS: true };
|
||||
|
||||
const renderNetworkTab = (requests = []) => {
|
||||
const store = configureStore({
|
||||
reducer: {
|
||||
collections: (state = {
|
||||
collections: [{
|
||||
uid: 'col-1',
|
||||
name: 'Test Collection',
|
||||
timeline: requests
|
||||
}]
|
||||
}) => state,
|
||||
logs: (state = {
|
||||
networkFilters: ALL_FILTERS,
|
||||
selectedRequest: null
|
||||
}) => state
|
||||
}
|
||||
});
|
||||
|
||||
return render(
|
||||
<Provider store={store}>
|
||||
<ThemeProvider>
|
||||
<NetworkTab />
|
||||
</ThemeProvider>
|
||||
</Provider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('sort state cycle', () => {
|
||||
const requests = [
|
||||
makeRequest({ itemUid: 'a', method: 'GET' }),
|
||||
makeRequest({ itemUid: 'b', method: 'POST' })
|
||||
];
|
||||
|
||||
it('shows no sort icon by default', () => {
|
||||
renderNetworkTab(requests);
|
||||
expect(screen.queryByTestId('sort-icon-asc')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('sort-icon-desc')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('first click on a column shows ascending icon', () => {
|
||||
renderNetworkTab(requests);
|
||||
fireEvent.click(screen.getByTestId('network-header-method'));
|
||||
expect(screen.getByTestId('sort-icon-asc')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('sort-icon-desc')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('second click on same column shows descending icon', () => {
|
||||
renderNetworkTab(requests);
|
||||
fireEvent.click(screen.getByTestId('network-header-method'));
|
||||
fireEvent.click(screen.getByTestId('network-header-method'));
|
||||
expect(screen.getByTestId('sort-icon-desc')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('sort-icon-asc')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('third click on same column clears sort', () => {
|
||||
renderNetworkTab(requests);
|
||||
fireEvent.click(screen.getByTestId('network-header-method'));
|
||||
fireEvent.click(screen.getByTestId('network-header-method'));
|
||||
fireEvent.click(screen.getByTestId('network-header-method'));
|
||||
expect(screen.queryByTestId('sort-icon-asc')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('sort-icon-desc')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking a different column resets to ascending on the new column', () => {
|
||||
renderNetworkTab(requests);
|
||||
fireEvent.click(screen.getByTestId('network-header-method'));
|
||||
fireEvent.click(screen.getByTestId('network-header-method')); // now desc
|
||||
fireEvent.click(screen.getByTestId('network-header-status')); // switch column
|
||||
// Should show asc on status, not desc
|
||||
expect(screen.getByTestId('sort-icon-asc')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('sort-icon-desc')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sort icon only appears on the active column', () => {
|
||||
renderNetworkTab(requests);
|
||||
fireEvent.click(screen.getByTestId('network-header-duration'));
|
||||
// Only one icon total
|
||||
expect(screen.getAllByTestId('sort-icon-asc')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sort results', () => {
|
||||
const getRowMethods = () =>
|
||||
screen.getAllByTestId('network-request-row').map((row) =>
|
||||
row.querySelector('.method-badge')?.textContent
|
||||
);
|
||||
|
||||
it('sorts by method ascending (A → Z)', () => {
|
||||
const requests = [
|
||||
makeRequest({ itemUid: '1', method: 'POST' }),
|
||||
makeRequest({ itemUid: '2', method: 'GET' }),
|
||||
makeRequest({ itemUid: '3', method: 'DELETE' })
|
||||
];
|
||||
renderNetworkTab(requests);
|
||||
fireEvent.click(screen.getByTestId('network-header-method'));
|
||||
expect(getRowMethods()).toEqual(['DELETE', 'GET', 'POST']);
|
||||
});
|
||||
|
||||
it('sorts by method descending (Z → A)', () => {
|
||||
const requests = [
|
||||
makeRequest({ itemUid: '1', method: 'POST' }),
|
||||
makeRequest({ itemUid: '2', method: 'GET' }),
|
||||
makeRequest({ itemUid: '3', method: 'DELETE' })
|
||||
];
|
||||
renderNetworkTab(requests);
|
||||
fireEvent.click(screen.getByTestId('network-header-method'));
|
||||
fireEvent.click(screen.getByTestId('network-header-method'));
|
||||
expect(getRowMethods()).toEqual(['POST', 'GET', 'DELETE']);
|
||||
});
|
||||
|
||||
it('sorts by status ascending', () => {
|
||||
const requests = [
|
||||
makeRequest({ itemUid: '1', statusCode: 500 }),
|
||||
makeRequest({ itemUid: '2', statusCode: 200 }),
|
||||
makeRequest({ itemUid: '3', statusCode: 404 })
|
||||
];
|
||||
renderNetworkTab(requests);
|
||||
fireEvent.click(screen.getByTestId('network-header-status'));
|
||||
const rows = screen.getAllByTestId('network-request-row');
|
||||
const statuses = rows.map((r) => r.querySelector('.status-badge')?.textContent);
|
||||
expect(statuses).toEqual(['200', '404', '500']);
|
||||
});
|
||||
|
||||
it('sorts mixed-case methods case-insensitively', () => {
|
||||
const requests = [
|
||||
makeRequest({ itemUid: '1', method: 'post' }),
|
||||
makeRequest({ itemUid: '2', method: 'GET' }),
|
||||
makeRequest({ itemUid: '3', method: 'delete' })
|
||||
];
|
||||
renderNetworkTab(requests);
|
||||
fireEvent.click(screen.getByTestId('network-header-method'));
|
||||
// MethodBadge always renders uppercase; sort order should treat 'post' == 'POST'
|
||||
expect(getRowMethods()).toEqual(['DELETE', 'GET', 'POST']);
|
||||
});
|
||||
|
||||
it('preserves insertion order when sort is cleared', () => {
|
||||
const requests = [
|
||||
makeRequest({ itemUid: '1', method: 'POST' }),
|
||||
makeRequest({ itemUid: '2', method: 'GET' }),
|
||||
makeRequest({ itemUid: '3', method: 'DELETE' })
|
||||
];
|
||||
renderNetworkTab(requests);
|
||||
// Sort then clear
|
||||
fireEvent.click(screen.getByTestId('network-header-method'));
|
||||
fireEvent.click(screen.getByTestId('network-header-method'));
|
||||
fireEvent.click(screen.getByTestId('network-header-method'));
|
||||
expect(getRowMethods()).toEqual(['POST', 'GET', 'DELETE']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
export const getGridTemplate = (columns) =>
|
||||
columns.map((c) => (c.width ? `${c.width}px` : '1fr')).join(' ');
|
||||
|
||||
export const getSeparatorPositions = (columns) => {
|
||||
const n = columns.length;
|
||||
const positions = new Array(n - 1).fill(null);
|
||||
|
||||
let leftOffset = 0;
|
||||
for (let i = 0; i < n - 1; i++) {
|
||||
if (columns[i].width === null) break;
|
||||
leftOffset += columns[i].width;
|
||||
positions[i] = { left: leftOffset };
|
||||
}
|
||||
|
||||
let rightOffset = 0;
|
||||
for (let i = n - 1; i > 0; i--) {
|
||||
if (columns[i].width === null) break;
|
||||
rightOffset += columns[i].width;
|
||||
if (positions[i - 1] === null) {
|
||||
positions[i - 1] = { right: rightOffset };
|
||||
}
|
||||
}
|
||||
|
||||
return positions;
|
||||
};
|
||||
|
||||
export const getSortValue = (request, key) => {
|
||||
const { request: req, response: res, timestamp } = request.data;
|
||||
switch (key) {
|
||||
case 'method': return req?.method?.toUpperCase() ?? '';
|
||||
case 'status': return res?.statusCode || res?.status || 0;
|
||||
case 'domain': {
|
||||
try { return new URL(req?.url || '').hostname; } catch { return req?.url || ''; }
|
||||
}
|
||||
case 'path': {
|
||||
try {
|
||||
const u = new URL(req?.url || '');
|
||||
return u.pathname + u.search;
|
||||
} catch { return req?.url || ''; }
|
||||
}
|
||||
case 'time': return timestamp || 0;
|
||||
case 'duration': return res?.duration || 0;
|
||||
case 'size': return res?.size || 0;
|
||||
default: return '';
|
||||
}
|
||||
};
|
||||
|
||||
export const sortRequests = (requests, key, direction) => {
|
||||
if (!key || !direction) return requests;
|
||||
return [...requests].sort((a, b) => {
|
||||
const valueA = getSortValue(a, key);
|
||||
const valueB = getSortValue(b, key);
|
||||
if (valueA < valueB) return direction === 'asc' ? -1 : 1;
|
||||
if (valueA > valueB) return direction === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
};
|
||||
@@ -4,11 +4,8 @@ const StyledWrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: ${(props) => props.theme.console.contentBg};
|
||||
border-left: 1px solid ${(props) => props.theme.console.border};
|
||||
min-width: 400px;
|
||||
max-width: 600px;
|
||||
width: 40%;
|
||||
overflow: hidden;
|
||||
|
||||
.panel-header {
|
||||
|
||||
@@ -144,6 +144,41 @@ const StyledWrapper = styled.div`
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.details-panel-wrapper {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
div.details-drag-handle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
cursor: col-resize;
|
||||
background-color: transparent;
|
||||
width: 6px;
|
||||
position: absolute;
|
||||
left: -3px;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
transition: opacity 0.2s ease;
|
||||
|
||||
div.drag-request-border {
|
||||
width: 1px;
|
||||
height: 100%;
|
||||
border-left: solid 1px ${(props) => props.theme.sidebar.dragbar.border};
|
||||
}
|
||||
|
||||
&:hover div.drag-request-border {
|
||||
width: 1px;
|
||||
height: 100%;
|
||||
border-left: solid 1px ${(props) => props.theme.sidebar.dragbar.activeBorder};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.action-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -23,7 +23,8 @@ import {
|
||||
setActiveTab,
|
||||
clearDebugErrors,
|
||||
updateNetworkFilter,
|
||||
toggleAllNetworkFilters
|
||||
toggleAllNetworkFilters,
|
||||
updateRequestDetailsPanelWidth
|
||||
} from 'providers/ReduxStore/slices/logs';
|
||||
|
||||
import NetworkTab from './NetworkTab';
|
||||
@@ -33,6 +34,10 @@ import RequestDetailsPanel from './RequestDetailsPanel';
|
||||
import ErrorDetailsPanel from './ErrorDetailsPanel';
|
||||
import Performance from '../Performance';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { useResizablePanel } from 'hooks/useResizablePanel';
|
||||
|
||||
const MIN_DETAILS_PANEL_WIDTH = 280;
|
||||
const MAX_DETAILS_PANEL_WIDTH = 800;
|
||||
|
||||
const LogIcon = ({ type }) => {
|
||||
const iconProps = { size: 16, strokeWidth: 1.5 };
|
||||
@@ -381,8 +386,17 @@ const Console = () => {
|
||||
const dispatch = useDispatch();
|
||||
const { logs, filters, activeTab, selectedRequest, selectedError, networkFilters, debugErrors } = useSelector((state) => state.logs);
|
||||
const collections = useSelector((state) => state.collections.collections);
|
||||
const savedDetailsPanelWidth = useSelector((state) => state.logs.requestDetailsPanelWidth);
|
||||
const consoleRef = useRef(null);
|
||||
|
||||
const { width: detailsPanelWidth, handleDragStart: handleDetailsPanelDragStart } = useResizablePanel({
|
||||
initialWidth: savedDetailsPanelWidth,
|
||||
minWidth: MIN_DETAILS_PANEL_WIDTH,
|
||||
maxWidth: MAX_DETAILS_PANEL_WIDTH,
|
||||
direction: 'right',
|
||||
onResizeEnd: (newWidth) => dispatch(updateRequestDetailsPanelWidth({ requestDetailsPanelWidth: newWidth }))
|
||||
});
|
||||
|
||||
const logCounts = logs.reduce((counts, log) => {
|
||||
counts[log.type] = (counts[log.type] || 0) + 1;
|
||||
return counts;
|
||||
@@ -614,7 +628,16 @@ const Console = () => {
|
||||
<div className="network-main">
|
||||
{renderTabContent()}
|
||||
</div>
|
||||
<RequestDetailsPanel />
|
||||
<div className="details-panel-wrapper" style={{ width: detailsPanelWidth }}>
|
||||
<div
|
||||
className="details-drag-handle"
|
||||
onMouseDown={handleDetailsPanelDragStart}
|
||||
data-testid="details-panel-drag-handle"
|
||||
>
|
||||
<div className="drag-request-border" />
|
||||
</div>
|
||||
<RequestDetailsPanel />
|
||||
</div>
|
||||
</div>
|
||||
) : activeTab === 'debug' && selectedError ? (
|
||||
<div className="debug-with-details">
|
||||
|
||||
@@ -21,17 +21,19 @@ const findScrollParent = (element) => {
|
||||
const TableRow = React.memo(
|
||||
({ children, item, context, ...rest }) => {
|
||||
const rowIndex = Number(rest['data-item-index']);
|
||||
const { reorderable, reorderableRowCount, isLastEmptyRow, dragOverRow, onDragStart, onDragOver, onDrop, onDragEnd, onDragLeave } = context;
|
||||
const { reorderable, reorderableRowCount, isLastEmptyRow, dragOverRow, onDragStart, onDragOver, onDrop, onDragEnd, onDragLeave, keyColumn } = context;
|
||||
const isEmpty = isLastEmptyRow(item, rowIndex);
|
||||
const canDrag = reorderable && !isEmpty && rowIndex < reorderableRowCount;
|
||||
const isDragOver = canDrag && dragOverRow === rowIndex;
|
||||
const existingClass = rest.className || '';
|
||||
const className = isDragOver ? `${existingClass} drag-over`.trim() : existingClass;
|
||||
const rowName = keyColumn ? item?.[keyColumn.key] : undefined;
|
||||
|
||||
return (
|
||||
<tr
|
||||
{...rest}
|
||||
className={className}
|
||||
data-row-name={rowName || undefined}
|
||||
draggable={canDrag}
|
||||
onDragStart={canDrag ? (e) => onDragStart(e, rowIndex) : undefined}
|
||||
onDragOver={canDrag ? (e) => onDragOver(e, rowIndex) : undefined}
|
||||
@@ -168,6 +170,17 @@ const EditableTable = ({
|
||||
};
|
||||
}, [defaultRow, checkboxKey]);
|
||||
|
||||
const hasAnyValue = useCallback((row) => {
|
||||
for (const col of columns) {
|
||||
const val = col.getValue ? col.getValue(row) : row[col.key];
|
||||
const defaultVal = defaultRow[col.key];
|
||||
if (val && val !== defaultVal && (typeof val !== 'string' || val.trim() !== '')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}, [columns, defaultRow]);
|
||||
|
||||
const rowsWithEmpty = useMemo(() => {
|
||||
if (!showAddRow) {
|
||||
return rows;
|
||||
@@ -177,16 +190,11 @@ const EditableTable = ({
|
||||
return [createEmptyRow()];
|
||||
}
|
||||
|
||||
const lastRow = rows[rows.length - 1];
|
||||
const keyColumn = columns.find((col) => col.isKeyField);
|
||||
|
||||
if (keyColumn) {
|
||||
const lastRowKeyValue = keyColumn.getValue ? keyColumn.getValue(lastRow) : lastRow[keyColumn.key];
|
||||
const isLastRowEmpty = !lastRowKeyValue || (typeof lastRowKeyValue === 'string' && lastRowKeyValue.trim() === '');
|
||||
|
||||
if (isLastRowEmpty) {
|
||||
return rows;
|
||||
}
|
||||
// If the last row is already empty (e.g. a stray empty row loaded from a
|
||||
// pre-existing file), don't append another one — otherwise the table would
|
||||
// render two empty rows at the bottom on the initial render.
|
||||
if (!hasAnyValue(rows[rows.length - 1])) {
|
||||
return rows;
|
||||
}
|
||||
|
||||
if (!emptyRowUidRef.current || rows.some((r) => r.uid === emptyRowUidRef.current)) {
|
||||
@@ -198,15 +206,11 @@ const EditableTable = ({
|
||||
[checkboxKey]: true,
|
||||
...defaultRow
|
||||
}];
|
||||
}, [rows, columns, defaultRow, checkboxKey, createEmptyRow, showAddRow]);
|
||||
}, [rows, columns, defaultRow, checkboxKey, createEmptyRow, hasAnyValue, showAddRow]);
|
||||
|
||||
const isEmptyRow = useCallback((row) => {
|
||||
const keyColumn = columns.find((col) => col.isKeyField);
|
||||
if (!keyColumn) return false;
|
||||
|
||||
const value = keyColumn.getValue ? keyColumn.getValue(row) : row[keyColumn.key];
|
||||
return !value || (typeof value === 'string' && value.trim() === '');
|
||||
}, [columns]);
|
||||
// A row is empty when none of its columns hold a value — the single source of
|
||||
// truth used everywhere (memo guard, persistence filter, last-row rendering).
|
||||
const isEmptyRow = useCallback((row) => !hasAnyValue(row), [hasAnyValue]);
|
||||
|
||||
const isLastEmptyRow = useCallback((row, index) => {
|
||||
if (!showAddRow) return false;
|
||||
@@ -227,50 +231,20 @@ const EditableTable = ({
|
||||
const rowIndex = rowsWithEmpty.findIndex((r) => r.uid === rowUid);
|
||||
if (rowIndex === -1) return;
|
||||
|
||||
const currentRow = rowsWithEmpty[rowIndex];
|
||||
const isLast = rowIndex === rowsWithEmpty.length - 1;
|
||||
const wasEmpty = isEmptyRow(currentRow);
|
||||
|
||||
const keyColumn = columns.find((col) => col.isKeyField);
|
||||
const isKeyFieldChange = keyColumn && keyColumn.key === key;
|
||||
|
||||
let updatedRows = rowsWithEmpty.map((row) => {
|
||||
const updatedRows = rowsWithEmpty.map((row) => {
|
||||
if (row.uid === rowUid) {
|
||||
return { ...row, [key]: value };
|
||||
}
|
||||
return row;
|
||||
});
|
||||
|
||||
// Only add a new empty row when the key field is filled
|
||||
if (showAddRow && isLast && wasEmpty && isKeyFieldChange && value && value.trim() !== '') {
|
||||
emptyRowUidRef.current = uuid();
|
||||
updatedRows.push({
|
||||
uid: emptyRowUidRef.current,
|
||||
[checkboxKey]: true,
|
||||
...defaultRow
|
||||
});
|
||||
}
|
||||
|
||||
const hasAnyValue = (row) => {
|
||||
for (const col of columns) {
|
||||
const val = col.getValue ? col.getValue(row) : row[col.key];
|
||||
const defaultVal = defaultRow[col.key];
|
||||
if (val && val !== defaultVal && (typeof val !== 'string' || val.trim() !== '')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const result = updatedRows.filter((row, i) => {
|
||||
if (showAddRow && i === updatedRows.length - 1) {
|
||||
return hasAnyValue(row);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
// Remove any fully-empty rows from the persisted data. The trailing empty
|
||||
// "add row" is re-added by the rowsWithEmpty memo, so there's always
|
||||
// exactly one empty row at the bottom and never a stray empty row above it.
|
||||
const result = showAddRow ? updatedRows.filter(hasAnyValue) : updatedRows;
|
||||
|
||||
onChange(result);
|
||||
}, [rowsWithEmpty, columns, onChange, checkboxKey, defaultRow, isEmptyRow, showAddRow]);
|
||||
}, [rowsWithEmpty, hasAnyValue, onChange, showAddRow]);
|
||||
|
||||
const handleCheckboxChange = useCallback((rowUid, checked) => {
|
||||
handleValueChange(rowUid, checkboxKey, checked);
|
||||
@@ -370,17 +344,20 @@ const EditableTable = ({
|
||||
);
|
||||
}, [isLastEmptyRow, getRowError, handleValueChange]);
|
||||
|
||||
const keyColumn = useMemo(() => columns.find((col) => col.isKeyField), [columns]);
|
||||
|
||||
const virtuosoContext = useMemo(() => ({
|
||||
reorderable,
|
||||
reorderableRowCount,
|
||||
isLastEmptyRow,
|
||||
dragOverRow,
|
||||
keyColumn,
|
||||
onDragStart: handleDragStart,
|
||||
onDragOver: handleDragOver,
|
||||
onDragLeave: handleDragLeave,
|
||||
onDrop: handleDrop,
|
||||
onDragEnd: handleDragEnd
|
||||
}), [reorderable, reorderableRowCount, isLastEmptyRow, dragOverRow, handleDragStart, handleDragOver, handleDragLeave, handleDrop, handleDragEnd]);
|
||||
}), [reorderable, reorderableRowCount, isLastEmptyRow, dragOverRow, keyColumn, handleDragStart, handleDragOver, handleDragLeave, handleDrop, handleDragEnd]);
|
||||
|
||||
const fixedHeaderContent = useCallback(() => (
|
||||
<tr>
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import React, { useCallback, useRef, useState, useEffect, useMemo } from 'react';
|
||||
import { TableVirtuoso } from 'react-virtuoso';
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import { IconTrash, IconAlertCircle, IconInfoCircle } from '@tabler/icons';
|
||||
import { IconTrash, IconAlertCircle } from '@tabler/icons';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
|
||||
import MultiLineEditor from 'components/MultiLineEditor/index';
|
||||
import DataTypeSelector from 'components/DataTypeSelector';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { uuid } from 'utils/common';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import { BRUNO_VARIABLE_DATATYPES, valueToString } from '@usebruno/common/utils';
|
||||
import { variableNameRegex } from 'utils/common/regex';
|
||||
import toast from 'react-hot-toast';
|
||||
import { Tooltip } from 'react-tooltip';
|
||||
@@ -23,14 +25,17 @@ const MIN_COLUMN_WIDTH = 80;
|
||||
const MIN_ROW_HEIGHT = 35;
|
||||
|
||||
const TableRow = React.memo(
|
||||
({ children, item, style, ...rest }) => (
|
||||
<tr key={item.uid} style={style} {...rest} data-testid={`env-var-row-${item?.name}`}>
|
||||
{children}
|
||||
</tr>
|
||||
),
|
||||
({ children, item, style, ...rest }) => {
|
||||
const variable = item?.variable ?? item;
|
||||
return (
|
||||
<tr key={variable?.uid} style={style} {...rest} data-testid={`env-var-row-${variable?.name}`}>
|
||||
{children}
|
||||
</tr>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) => {
|
||||
const prevUid = prevProps?.item?.uid;
|
||||
const nextUid = nextProps?.item?.uid;
|
||||
const prevUid = prevProps?.item?.variable?.uid ?? prevProps?.item?.uid;
|
||||
const nextUid = nextProps?.item?.variable?.uid ?? nextProps?.item?.uid;
|
||||
return prevUid === nextUid && prevProps.children === nextProps.children;
|
||||
}
|
||||
);
|
||||
@@ -203,7 +208,9 @@ const EnvironmentVariablesTable = ({
|
||||
secret: Yup.boolean(),
|
||||
type: Yup.string(),
|
||||
uid: Yup.string(),
|
||||
value: Yup.mixed().nullable()
|
||||
value: Yup.mixed().nullable(),
|
||||
dataType: Yup.string().oneOf(BRUNO_VARIABLE_DATATYPES).nullable(),
|
||||
annotations: Yup.array().nullable()
|
||||
})
|
||||
),
|
||||
validate: (values) => {
|
||||
@@ -391,8 +398,16 @@ const EnvironmentVariablesTable = ({
|
||||
const variablesToSave = formik.values.filter((variable) => variable.name && variable.name.trim() !== '');
|
||||
const savedValues = environment.variables || [];
|
||||
|
||||
// Compare without UIDs since they can be different but the actual data is the same
|
||||
const hasChanges = JSON.stringify(variablesToSave.map(stripEnvVarUid)) !== JSON.stringify(savedValues.map(stripEnvVarUid));
|
||||
// Compare against what's on disk: for an ephemeral overlay, that's
|
||||
// `persistedValue`, not the scripted value Redux is holding.
|
||||
const baselineForCompare = (v) => {
|
||||
const stripped = stripEnvVarUid(v);
|
||||
if (v?.ephemeral && v?.persistedValue !== undefined) {
|
||||
stripped.value = v.persistedValue;
|
||||
}
|
||||
return stripped;
|
||||
};
|
||||
const hasChanges = JSON.stringify(variablesToSave.map(stripEnvVarUid)) !== JSON.stringify(savedValues.map(baselineForCompare));
|
||||
if (!hasChanges) {
|
||||
toast.error('No changes to save');
|
||||
return;
|
||||
@@ -524,6 +539,7 @@ const EnvironmentVariablesTable = ({
|
||||
<td></td>
|
||||
</tr>
|
||||
)}
|
||||
defaultItemHeight={35}
|
||||
computeItemKey={(virtualIndex, item) => `${environment.uid}-${item.index}`}
|
||||
itemContent={(virtualIndex, { variable, index: actualIndex }) => {
|
||||
const isLastRow = actualIndex === formik.values.length - 1;
|
||||
@@ -569,21 +585,20 @@ const EnvironmentVariablesTable = ({
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
className="flex flex-row flex-nowrap items-center"
|
||||
className="flex flex-row flex-nowrap items-center gap-2"
|
||||
style={{ width: columnWidths.value }}
|
||||
>
|
||||
<div
|
||||
className="overflow-hidden grow w-full relative"
|
||||
className="flex-1 min-w-0 relative"
|
||||
onFocus={() => handleRowFocus(variable.uid)}
|
||||
>
|
||||
<MultiLineEditor
|
||||
theme={storedTheme}
|
||||
collection={_collection}
|
||||
name={`${actualIndex}.value`}
|
||||
value={variable.value}
|
||||
value={valueToString(variable.value, 2)}
|
||||
placeholder={variable.value == null || (typeof variable.value === 'string' && variable.value.trim() === '') ? 'Value' : ''}
|
||||
isSecret={variable.secret}
|
||||
readOnly={typeof variable.value !== 'string'}
|
||||
onChange={(newValue) => {
|
||||
formik.setFieldValue(`${actualIndex}.value`, newValue, true);
|
||||
// Clear ephemeral metadata when user manually edits the value
|
||||
@@ -608,13 +623,17 @@ const EnvironmentVariablesTable = ({
|
||||
onSave={handleSave}
|
||||
/>
|
||||
</div>
|
||||
{typeof variable.value !== 'string' && (
|
||||
<span className="ml-2 flex items-center">
|
||||
<IconInfoCircle id={`${variable.uid}-disabled-info-icon`} className="text-muted" size={16} />
|
||||
<Tooltip
|
||||
anchorId={`${variable.uid}-disabled-info-icon`}
|
||||
content="Non-string values set via scripts are read-only and can only be updated through scripts."
|
||||
place="top"
|
||||
{!isLastEmptyRow && (
|
||||
<span>
|
||||
<DataTypeSelector
|
||||
variable={variable}
|
||||
theme={storedTheme}
|
||||
collection={_collection}
|
||||
onChange={(fields) => {
|
||||
Object.entries(fields).forEach(([key, val]) => {
|
||||
formik.setFieldValue(`${actualIndex}.${key}`, val, true);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -25,7 +25,7 @@ const EnvironmentListContent = ({
|
||||
<span>No Environment</span>
|
||||
</div>
|
||||
<ToolHint
|
||||
anchorSelect="[data-tooltip-content]"
|
||||
tooltipId="environment-name-tooltip"
|
||||
place="right"
|
||||
positionStrategy="fixed"
|
||||
tooltipStyle={{
|
||||
@@ -40,6 +40,7 @@ const EnvironmentListContent = ({
|
||||
key={env.uid}
|
||||
className={`dropdown-item ${env.uid === activeEnvironmentUid ? 'dropdown-item-active' : ''}`}
|
||||
onClick={() => onEnvironmentSelect(env)}
|
||||
data-tooltip-id="environment-name-tooltip"
|
||||
data-tooltip-content={env.name}
|
||||
data-tooltip-hidden={env.name?.length < 90}
|
||||
>
|
||||
|
||||
@@ -4,6 +4,8 @@ const StyledWrapper = styled.div`
|
||||
color: ${(props) => props.theme.colors.danger};
|
||||
pre {
|
||||
color: ${(props) => props.theme.colors.danger};
|
||||
max-height: 60vh;
|
||||
overflow: auto;
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
color: ${(props) => props.theme.colors.danger};
|
||||
|
||||
pre {
|
||||
display: block;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import Portal from 'components/Portal';
|
||||
import Modal from 'components/Modal';
|
||||
import { useState } from 'react';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const SaveFileErrorModal = ({ error }) => {
|
||||
const [showModal, setShowModal] = useState(true);
|
||||
return (
|
||||
<>
|
||||
{showModal ? (
|
||||
<Portal>
|
||||
<StyledWrapper>
|
||||
<Modal
|
||||
size="sm"
|
||||
title="Save File Error"
|
||||
hideFooter={true}
|
||||
hideCancel={true}
|
||||
handleCancel={() => {
|
||||
setShowModal(false);
|
||||
}}
|
||||
disableCloseOnOutsideClick={true}
|
||||
disableEscapeKey={true}
|
||||
>
|
||||
<pre className="w-full flex flex-wrap whitespace-pre-wrap">{error}</pre>
|
||||
</Modal>
|
||||
</StyledWrapper>
|
||||
</Portal>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SaveFileErrorModal;
|
||||
@@ -0,0 +1,55 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
div.CodeMirror {
|
||||
height: 100%;
|
||||
background: ${(props) => props.theme.codemirror.bg};
|
||||
border: solid 1px ${(props) => props.theme.codemirror.border};
|
||||
font-family: ${(props) => (props.font ? props.font : 'default')};
|
||||
line-break: anywhere;
|
||||
}
|
||||
|
||||
.CodeMirror-overlayscroll-horizontal div,
|
||||
.CodeMirror-overlayscroll-vertical div {
|
||||
background: #d2d7db;
|
||||
}
|
||||
|
||||
textarea.cm-editor {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
// Todo: dark mode temporary fix
|
||||
// Clean this
|
||||
.CodeMirror.cm-s-monokai {
|
||||
.CodeMirror-overlayscroll-horizontal div,
|
||||
.CodeMirror-overlayscroll-vertical div {
|
||||
background: #444444;
|
||||
}
|
||||
}
|
||||
|
||||
.cm-s-monokai span.cm-property,
|
||||
.cm-s-monokai span.cm-attribute {
|
||||
color: #9cdcfe !important;
|
||||
}
|
||||
|
||||
.cm-s-monokai span.cm-string {
|
||||
color: #ce9178 !important;
|
||||
}
|
||||
|
||||
.cm-s-monokai span.cm-number {
|
||||
color: #b5cea8 !important;
|
||||
}
|
||||
|
||||
.cm-s-monokai span.cm-atom {
|
||||
color: #569cd6 !important;
|
||||
}
|
||||
|
||||
.cm-variable-valid {
|
||||
color: green;
|
||||
}
|
||||
.cm-variable-invalid {
|
||||
color: red;
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
236
packages/bruno-app/src/components/FileEditor/CodeEditor/index.js
Normal file
236
packages/bruno-app/src/components/FileEditor/CodeEditor/index.js
Normal file
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* Copyright (c) 2021 GraphQL Contributors.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
import { getEnvironmentVariables } from 'utils/collections';
|
||||
import { defineCodeMirrorBrunoVariablesMode } from 'utils/common/codemirror';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import * as jsonlint from '@prantlf/jsonlint';
|
||||
import { JSHINT } from 'jshint';
|
||||
import CodeMirrorSearch from 'components/CodeMirrorSearch';
|
||||
let CodeMirror;
|
||||
const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
|
||||
|
||||
if (!SERVER_RENDERED) {
|
||||
CodeMirror = require('codemirror');
|
||||
window.jsonlint = jsonlint;
|
||||
window.JSHINT = JSHINT;
|
||||
}
|
||||
|
||||
export default class CodeEditor extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
// Keep a cached version of the value, this cache will be updated when the
|
||||
// editor is updated, which can later be used to protect the editor from
|
||||
// unnecessary updates during the update lifecycle.
|
||||
this.cachedValue = props.value || '';
|
||||
this.variables = {};
|
||||
|
||||
this.lintOptions = {
|
||||
esversion: 11,
|
||||
expr: true,
|
||||
asi: true
|
||||
};
|
||||
|
||||
this.state = {
|
||||
searchBarVisible: false
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const editor = (this.editor = CodeMirror(this._node, {
|
||||
value: this.props.value || '',
|
||||
lineNumbers: true,
|
||||
lineWrapping: true,
|
||||
tabSize: 2,
|
||||
mode: this.props.mode || 'application/ld+json',
|
||||
keyMap: 'sublime',
|
||||
autoCloseBrackets: true,
|
||||
matchBrackets: true,
|
||||
showCursorWhenSelecting: true,
|
||||
foldGutter: true,
|
||||
gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter', 'CodeMirror-lint-markers'],
|
||||
lint: this.lintOptions,
|
||||
readOnly: this.props.readOnly,
|
||||
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();
|
||||
}
|
||||
},
|
||||
'Shift-Cmd-M': () => {
|
||||
if (this.props.toggleFileMode) {
|
||||
this.props.toggleFileMode();
|
||||
}
|
||||
},
|
||||
'Shift-Ctrl-M': () => {
|
||||
if (this.props.toggleFileMode) {
|
||||
this.props.toggleFileMode();
|
||||
}
|
||||
},
|
||||
'Cmd-F': (cm) => {
|
||||
if (this.state.searchBarVisible) {
|
||||
this._node.querySelector('.bruno-search-bar > input').focus();
|
||||
}
|
||||
if (!this.state.searchBarVisible) {
|
||||
this.setState({ searchBarVisible: true });
|
||||
}
|
||||
},
|
||||
'Ctrl-F': (cm) => {
|
||||
if (this.state.searchBarVisible) {
|
||||
this._node.querySelector('.bruno-search-bar > input').focus();
|
||||
}
|
||||
if (!this.state.searchBarVisible) {
|
||||
this.setState({ searchBarVisible: true });
|
||||
}
|
||||
},
|
||||
'Cmd-H': 'replace',
|
||||
'Ctrl-H': 'replace',
|
||||
'Tab': function (cm) {
|
||||
cm.getSelection().includes('\n') || editor.getLine(cm.getCursor().line) == cm.getSelection()
|
||||
? cm.execCommand('indentMore')
|
||||
: cm.replaceSelection(' ', 'end');
|
||||
},
|
||||
'Shift-Tab': 'indentLess',
|
||||
'Ctrl-Space': 'autocomplete',
|
||||
'Cmd-Space': 'autocomplete',
|
||||
'Ctrl-Y': 'foldAll',
|
||||
'Cmd-Y': 'foldAll',
|
||||
'Ctrl-I': 'unfoldAll',
|
||||
'Cmd-I': 'unfoldAll',
|
||||
'Esc': () => {
|
||||
if (this.state.searchBarVisible) {
|
||||
this.setState({ searchBarVisible: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
}));
|
||||
if (editor) {
|
||||
editor.setOption('lint', this.props.mode && editor.getValue().trim().length > 0 ? this.lintOptions : false);
|
||||
editor.on('change', this._onEdit);
|
||||
editor.scrollTo(null, this.props.initialScroll);
|
||||
this._lastScrollTop = this.props.initialScroll || 0;
|
||||
editor.on('scroll', this._onScroll);
|
||||
this.addOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
// Ensure the changes caused by this update are not interpreted as
|
||||
// user-input changes which could otherwise result in an infinite
|
||||
// event loop.
|
||||
this.ignoreChangeEvent = true;
|
||||
if (this.props.schema !== prevProps.schema && this.editor) {
|
||||
this.editor.options.lint.schema = this.props.schema;
|
||||
this.editor.options.hintOptions.schema = this.props.schema;
|
||||
this.editor.options.info.schema = this.props.schema;
|
||||
this.editor.options.jump.schema = this.props.schema;
|
||||
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 = this.props.value;
|
||||
this.editor.setValue(this.props.value);
|
||||
this.editor.setCursor(cursor);
|
||||
}
|
||||
|
||||
if (this.editor) {
|
||||
let variables = getEnvironmentVariables(this.props.collection);
|
||||
if (!isEqual(variables, this.variables)) {
|
||||
this.addOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
if (this.props.theme !== prevProps.theme && this.editor) {
|
||||
this.editor.setOption('theme', this.props.theme === 'dark' ? 'monokai' : 'default');
|
||||
}
|
||||
|
||||
if (this.props.initialScroll !== prevProps.initialScroll && this.editor) {
|
||||
this.editor.scrollTo(null, this.props.initialScroll);
|
||||
}
|
||||
this.ignoreChangeEvent = false;
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.editor) {
|
||||
this.editor.off('change', this._onEdit);
|
||||
this.editor.off('scroll', this._onScroll);
|
||||
if (typeof this.props.onScroll === 'function') {
|
||||
this.props.onScroll(this._lastScrollTop || 0);
|
||||
}
|
||||
const editorElement = this.editor.getWrapperElement();
|
||||
if (editorElement && editorElement.parentNode) {
|
||||
editorElement.parentNode.removeChild(editorElement);
|
||||
}
|
||||
this.editor = null;
|
||||
this._node = null;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.editor) {
|
||||
this.editor.refresh();
|
||||
}
|
||||
return (
|
||||
<StyledWrapper
|
||||
className="h-full w-full"
|
||||
aria-label="Code Editor"
|
||||
font={this.props.font}
|
||||
>
|
||||
<CodeMirrorSearch
|
||||
visible={this.state.searchBarVisible}
|
||||
editor={this.editor}
|
||||
onClose={() => this.setState({ searchBarVisible: false })}
|
||||
/>
|
||||
<div
|
||||
ref={(node) => {
|
||||
this._node = node;
|
||||
}}
|
||||
style={{ height: '100%' }}
|
||||
/>
|
||||
</StyledWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
addOverlay = () => {
|
||||
const mode = this.props.mode || 'application/ld+json';
|
||||
let variables = getEnvironmentVariables(this.props.collection);
|
||||
this.variables = variables;
|
||||
|
||||
defineCodeMirrorBrunoVariablesMode(variables, mode);
|
||||
this.editor.setOption('mode', 'brunovariables');
|
||||
};
|
||||
|
||||
_onEdit = () => {
|
||||
if (!this.ignoreChangeEvent && this.editor) {
|
||||
this.editor.setOption('lint', this.editor.getValue().trim().length > 0 ? this.lintOptions : false);
|
||||
this.cachedValue = this.editor.getValue();
|
||||
if (this.props.onEdit) {
|
||||
this.props.onEdit(this.cachedValue);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_onScroll = () => {
|
||||
if (!this.editor) return;
|
||||
const wrapper = this.editor.getWrapperElement();
|
||||
if (wrapper && wrapper.offsetParent === null) return;
|
||||
this._lastScrollTop = this.editor.getScrollInfo().top;
|
||||
if (typeof this.props.onScroll === 'function') {
|
||||
this.props.onScroll(this._lastScrollTop);
|
||||
}
|
||||
};
|
||||
}
|
||||
68
packages/bruno-app/src/components/FileEditor/index.js
Normal file
68
packages/bruno-app/src/components/FileEditor/index.js
Normal file
@@ -0,0 +1,68 @@
|
||||
import get from 'lodash/get';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import CodeEditor from './CodeEditor/index';
|
||||
import { saveFile } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { IconDeviceFloppy } from '@tabler/icons';
|
||||
import { toggleCollectionFileMode, updateFileContent } from 'providers/ReduxStore/slices/collections';
|
||||
import { usePersistedState } from 'hooks/usePersistedState';
|
||||
|
||||
const FileEditor = ({ item, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { displayedTheme, theme } = useTheme();
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const [scroll, setScroll] = usePersistedState({ key: `file-mode-scroll-${item.uid}`, default: 0 });
|
||||
|
||||
const content = item.draft ? item.draft.raw : item.raw || '';
|
||||
|
||||
const onEdit = (value) => {
|
||||
dispatch(
|
||||
updateFileContent({
|
||||
content: value,
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const hasChanges = item.draft != null;
|
||||
|
||||
const onSave = () => {
|
||||
if (!hasChanges) return;
|
||||
dispatch(saveFile(content, item?.uid, collection?.uid));
|
||||
};
|
||||
|
||||
const _toggleFileMode = () => {
|
||||
dispatch(toggleCollectionFileMode({ collectionUid: collection.uid }));
|
||||
};
|
||||
|
||||
const editorMode = item?.type == 'js' ? 'javascript' : item?.type == 'json' ? 'javascript' : 'application/text';
|
||||
|
||||
return (
|
||||
<div className="flex flex-grow relative h-full">
|
||||
<CodeEditor
|
||||
collection={collection}
|
||||
theme={displayedTheme}
|
||||
value={content}
|
||||
onEdit={onEdit}
|
||||
onSave={onSave}
|
||||
toggleFileMode={_toggleFileMode}
|
||||
mode={editorMode}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
initialScroll={scroll}
|
||||
onScroll={setScroll}
|
||||
/>
|
||||
<IconDeviceFloppy
|
||||
onClick={onSave}
|
||||
color={hasChanges ? theme.draftColor : theme.requestTabs.icon.color}
|
||||
strokeWidth={1.5}
|
||||
size={22}
|
||||
className={`absolute right-0 top-0 m-4 ${
|
||||
hasChanges ? 'cursor-pointer opacity-100' : 'cursor-default opacity-50'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileEditor;
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
|
||||
@@ -18,8 +18,9 @@ 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';
|
||||
import { humanizeRequestAuthMode, getTreePathFromCollectionToItem } from 'utils/collections/index';
|
||||
import { humanizeRequestAuthMode } from 'utils/collections/index';
|
||||
import Button from 'ui/Button';
|
||||
import { getEffectiveAuthSource } from 'utils/auth';
|
||||
|
||||
const GrantTypeComponentMap = ({ collection, folder, updateFolderAuth }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -52,41 +53,6 @@ const Auth = ({ collection, folder }) => {
|
||||
let request = get(folderRoot, 'request', {});
|
||||
const authMode = get(folderRoot, 'request.auth.mode');
|
||||
|
||||
const getEffectiveAuthSource = () => {
|
||||
if (authMode !== 'inherit') return null;
|
||||
|
||||
const collectionRoot = collection?.draft?.root || collection?.root || {};
|
||||
const collectionAuth = get(collectionRoot, 'request.auth');
|
||||
let effectiveSource = {
|
||||
type: 'collection',
|
||||
name: 'Collection',
|
||||
auth: collectionAuth
|
||||
};
|
||||
|
||||
// Get path from collection to current folder
|
||||
const folderTreePath = getTreePathFromCollectionToItem(collection, folder);
|
||||
|
||||
// Check parent folders to find closest auth configuration
|
||||
// Skip the last item which is the current folder
|
||||
for (let i = 0; i < folderTreePath.length - 1; i++) {
|
||||
const parentFolder = folderTreePath[i];
|
||||
if (parentFolder.type === 'folder') {
|
||||
const parentFolderRoot = parentFolder?.draft || parentFolder?.root;
|
||||
const folderAuth = get(parentFolderRoot, 'request.auth');
|
||||
if (folderAuth && folderAuth.mode && folderAuth.mode !== 'inherit') {
|
||||
effectiveSource = {
|
||||
type: 'folder',
|
||||
name: parentFolder.name,
|
||||
auth: folderAuth
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return effectiveSource;
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
dispatch(saveFolderRoot(collection.uid, folder.uid));
|
||||
};
|
||||
@@ -98,6 +64,11 @@ const Auth = ({ collection, folder }) => {
|
||||
});
|
||||
};
|
||||
|
||||
const inheritedSource = useMemo(
|
||||
() => (authMode === 'inherit' ? getEffectiveAuthSource(collection, folder) : null),
|
||||
[authMode, folder, collection]
|
||||
);
|
||||
|
||||
const getAuthView = () => {
|
||||
switch (authMode) {
|
||||
case 'basic': {
|
||||
@@ -202,12 +173,11 @@ const Auth = ({ collection, folder }) => {
|
||||
);
|
||||
}
|
||||
case 'inherit': {
|
||||
const source = getEffectiveAuthSource();
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-row w-full mt-2 gap-2">
|
||||
<div>Auth inherited from {source.name}: </div>
|
||||
<div className="inherit-mode-text">{humanizeRequestAuthMode(source.auth?.mode)}</div>
|
||||
<div>Auth inherited from {inheritedSource.name}: </div>
|
||||
<div className="inherit-mode-text" data-testid="inherited-auth-mode">{humanizeRequestAuthMode(inheritedSource.auth?.mode)}</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -81,14 +81,15 @@ const AuthMode = ({ collection, folder }) => {
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="inline-flex items-center cursor-pointer auth-mode-selector">
|
||||
<div className="inline-flex items-center cursor-pointer auth-mode-selector" data-testid="auth-mode-selector">
|
||||
<MenuDropdown
|
||||
items={menuItems}
|
||||
placement="bottom-end"
|
||||
selectedItemId={authMode}
|
||||
showTickMark={true}
|
||||
data-testid="auth-mode-dropdown"
|
||||
>
|
||||
<div className="flex items-center justify-center auth-mode-label select-none">
|
||||
<div className="flex items-center justify-center auth-mode-label select-none" data-testid="auth-mode-label">
|
||||
{humanizeRequestAuthMode(authMode)} <IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
|
||||
</div>
|
||||
</MenuDropdown>
|
||||
|
||||
@@ -3,6 +3,7 @@ import get from 'lodash/get';
|
||||
import find from 'lodash/find';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import CodeEditor from 'components/CodeEditor';
|
||||
import AIAssist from 'components/AIAssist';
|
||||
import { updateFolderRequestScript, updateFolderResponseScript } from 'providers/ReduxStore/slices/collections';
|
||||
import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { updateScriptPaneTab } from 'providers/ReduxStore/slices/tabs';
|
||||
@@ -13,6 +14,7 @@ import { flattenItems, isItemARequest } from 'utils/collections';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import Button from 'ui/Button';
|
||||
import { usePersistedState } from 'hooks/usePersistedState';
|
||||
import { useFocusErrorLine } from 'hooks/useFocusErrorLine';
|
||||
|
||||
const Script = ({ collection, folder }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -22,7 +24,9 @@ const Script = ({ collection, folder }) => {
|
||||
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 focusedTab = find(tabs, (tab) => tab.type === 'folder-settings' && (tab.uid === folder.uid || tab.folderUid === folder.uid))
|
||||
|| find(tabs, (tab) => tab.type === 'folder-settings' && tab.pathname === folder.pathname);
|
||||
const tabUid = focusedTab?.uid || folder.uid;
|
||||
const scriptPaneTab = focusedTab?.scriptPaneTab;
|
||||
|
||||
// Default to post-response if pre-request script is empty (only when scriptPaneTab is null/undefined)
|
||||
@@ -34,7 +38,7 @@ const Script = ({ collection, folder }) => {
|
||||
const activeTab = scriptPaneTab || getDefaultTab();
|
||||
|
||||
const setActiveTab = (tab) => {
|
||||
dispatch(updateScriptPaneTab({ uid: folder.uid, scriptPaneTab: tab }));
|
||||
dispatch(updateScriptPaneTab({ uid: tabUid, scriptPaneTab: tab }));
|
||||
};
|
||||
|
||||
const { displayedTheme } = useTheme();
|
||||
@@ -60,6 +64,20 @@ const Script = ({ collection, folder }) => {
|
||||
return () => clearTimeout(timer);
|
||||
}, [activeTab]);
|
||||
|
||||
useFocusErrorLine({
|
||||
uid: folder.uid,
|
||||
editorRef: preRequestEditorRef,
|
||||
scriptPhase: 'pre-request',
|
||||
isVisible: activeTab === 'pre-request'
|
||||
});
|
||||
|
||||
useFocusErrorLine({
|
||||
uid: folder.uid,
|
||||
editorRef: postResponseEditorRef,
|
||||
scriptPhase: 'post-response',
|
||||
isVisible: activeTab === 'post-response'
|
||||
});
|
||||
|
||||
const onRequestScriptEdit = (value) => {
|
||||
dispatch(
|
||||
updateFolderRequestScript({
|
||||
@@ -111,39 +129,53 @@ const Script = ({ collection, folder }) => {
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="pre-request" className="mt-2" dataTestId="folder-pre-request-script-editor">
|
||||
<CodeEditor
|
||||
ref={preRequestEditorRef}
|
||||
collection={collection}
|
||||
docKey="folder-script:pre-request"
|
||||
value={requestScript || ''}
|
||||
theme={displayedTheme}
|
||||
onEdit={onRequestScriptEdit}
|
||||
mode="javascript"
|
||||
onSave={handleSave}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
showHintsFor={['req', 'bru']}
|
||||
initialScroll={preReqScroll}
|
||||
onScroll={setPreReqScroll}
|
||||
/>
|
||||
<div className="relative h-full">
|
||||
<CodeEditor
|
||||
ref={preRequestEditorRef}
|
||||
collection={collection}
|
||||
docKey="folder-script:pre-request"
|
||||
value={requestScript || ''}
|
||||
theme={displayedTheme}
|
||||
onEdit={onRequestScriptEdit}
|
||||
mode="javascript"
|
||||
onSave={handleSave}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
showHintsFor={['req', 'bru']}
|
||||
initialScroll={preReqScroll}
|
||||
onScroll={setPreReqScroll}
|
||||
/>
|
||||
<AIAssist
|
||||
scriptType="pre-request"
|
||||
currentScript={requestScript || ''}
|
||||
onApply={onRequestScriptEdit}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="post-response" className="mt-2" dataTestId="folder-post-response-script-editor">
|
||||
<CodeEditor
|
||||
ref={postResponseEditorRef}
|
||||
collection={collection}
|
||||
docKey="folder-script:post-response"
|
||||
value={responseScript || ''}
|
||||
theme={displayedTheme}
|
||||
onEdit={onResponseScriptEdit}
|
||||
mode="javascript"
|
||||
onSave={handleSave}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
showHintsFor={['req', 'res', 'bru']}
|
||||
initialScroll={postResScroll}
|
||||
onScroll={setPostResScroll}
|
||||
/>
|
||||
<div className="relative h-full">
|
||||
<CodeEditor
|
||||
ref={postResponseEditorRef}
|
||||
collection={collection}
|
||||
docKey="folder-script:post-response"
|
||||
value={responseScript || ''}
|
||||
theme={displayedTheme}
|
||||
onEdit={onResponseScriptEdit}
|
||||
mode="javascript"
|
||||
onSave={handleSave}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
showHintsFor={['req', 'res', 'bru']}
|
||||
initialScroll={postResScroll}
|
||||
onScroll={setPostResScroll}
|
||||
/>
|
||||
<AIAssist
|
||||
scriptType="post-response"
|
||||
currentScript={responseScript || ''}
|
||||
onApply={onResponseScriptEdit}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
|
||||
@@ -2,12 +2,14 @@ import React, { useRef } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import CodeEditor from 'components/CodeEditor';
|
||||
import AIAssist from 'components/AIAssist';
|
||||
import { updateFolderTests } from 'providers/ReduxStore/slices/collections';
|
||||
import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import Button from 'ui/Button';
|
||||
import { usePersistedState } from 'hooks/usePersistedState';
|
||||
import { useFocusErrorLine } from 'hooks/useFocusErrorLine';
|
||||
|
||||
const Tests = ({ collection, folder }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -30,24 +32,33 @@ const Tests = ({ collection, folder }) => {
|
||||
|
||||
const handleSave = () => dispatch(saveFolderRoot(collection.uid, folder.uid));
|
||||
|
||||
useFocusErrorLine({
|
||||
uid: folder.uid,
|
||||
editorRef: testsEditorRef,
|
||||
scriptPhase: 'test'
|
||||
});
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full flex flex-col h-full">
|
||||
<div className="text-xs mb-4 text-muted">These tests will run any time a request in this collection is sent.</div>
|
||||
<CodeEditor
|
||||
ref={testsEditorRef}
|
||||
collection={collection}
|
||||
docKey="folder-tests"
|
||||
value={tests || ''}
|
||||
theme={displayedTheme}
|
||||
onEdit={onEdit}
|
||||
mode="javascript"
|
||||
onSave={handleSave}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
showHintsFor={['req', 'res', 'bru']}
|
||||
initialScroll={testsScroll}
|
||||
onScroll={setTestsScroll}
|
||||
/>
|
||||
<div className="relative h-full">
|
||||
<CodeEditor
|
||||
ref={testsEditorRef}
|
||||
collection={collection}
|
||||
docKey="folder-tests"
|
||||
value={tests || ''}
|
||||
theme={displayedTheme}
|
||||
onEdit={onEdit}
|
||||
mode="javascript"
|
||||
onSave={handleSave}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
showHintsFor={['req', 'res', 'bru']}
|
||||
initialScroll={testsScroll}
|
||||
onScroll={setTestsScroll}
|
||||
/>
|
||||
<AIAssist scriptType="tests" currentScript={tests || ''} onApply={onEdit} />
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<Button type="submit" size="sm" onClick={handleSave}>
|
||||
|
||||
@@ -5,6 +5,8 @@ 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 DataTypeSelector from 'components/DataTypeSelector';
|
||||
import { valueToString } from '@usebruno/common/utils';
|
||||
import EditableTable from 'components/EditableTable';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import toast from 'react-hot-toast';
|
||||
@@ -62,16 +64,32 @@ const VarsTable = ({ folder, collection, vars, varType, initialScroll = 0 }) =>
|
||||
</div>
|
||||
),
|
||||
placeholder: varType === 'request' ? 'Value' : 'Expr',
|
||||
render: ({ value, onChange }) => (
|
||||
<MultiLineEditor
|
||||
value={value || ''}
|
||||
theme={storedTheme}
|
||||
onSave={onSave}
|
||||
onChange={onChange}
|
||||
collection={collection}
|
||||
item={folder}
|
||||
placeholder={!value ? (varType === 'request' ? 'Value' : 'Expr') : ''}
|
||||
/>
|
||||
render: ({ row, value, onChange, isLastEmptyRow }) => (
|
||||
<div className="flex items-center w-full gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<MultiLineEditor
|
||||
value={valueToString(value)}
|
||||
theme={storedTheme}
|
||||
onSave={onSave}
|
||||
onChange={onChange}
|
||||
collection={collection}
|
||||
item={folder}
|
||||
placeholder={value == null || (typeof value === 'string' && value.trim() === '') ? (varType === 'request' ? 'Value' : 'Expr') : ''}
|
||||
/>
|
||||
</div>
|
||||
{/* DataTypes apply to literal values, not to the JS expression that produces a post-response value. */}
|
||||
{!isLastEmptyRow && varType === 'request' && (
|
||||
<DataTypeSelector
|
||||
variable={row}
|
||||
theme={storedTheme}
|
||||
collection={collection}
|
||||
onChange={(fields) => {
|
||||
const updated = (vars || []).map((v) => v.uid === row.uid ? { ...v, ...fields } : v);
|
||||
handleVarsChange(updated);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
];
|
||||
@@ -86,6 +104,7 @@ const VarsTable = ({ folder, collection, vars, varType, initialScroll = 0 }) =>
|
||||
<StyledWrapper className="w-full">
|
||||
<EditableTable
|
||||
tableId="folder-vars"
|
||||
testId={`folder-vars-${varType === 'response' ? 'res' : 'req'}`}
|
||||
columns={columns}
|
||||
rows={vars}
|
||||
onChange={handleVarsChange}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import classnames from 'classnames';
|
||||
import { updatedFolderSettingsSelectedTab } from 'providers/ReduxStore/slices/collections';
|
||||
import { useDispatch } from 'react-redux';
|
||||
@@ -10,7 +10,7 @@ import Vars from './Vars';
|
||||
import Documentation from './Documentation';
|
||||
import Auth from './Auth';
|
||||
import StatusDot from 'components/StatusDot';
|
||||
import get from 'lodash/get';
|
||||
import { hasEffectiveAuth } from 'utils/auth';
|
||||
|
||||
const FolderSettings = ({ collection, folder }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -31,8 +31,11 @@ const FolderSettings = ({ collection, folder }) => {
|
||||
const responseVars = folderRoot?.request?.vars?.res || [];
|
||||
const activeVarsCount = requestVars.filter((v) => v.enabled).length + responseVars.filter((v) => v.enabled).length;
|
||||
|
||||
const auth = get(folderRoot, 'request.auth.mode');
|
||||
const hasAuth = auth && auth !== 'none';
|
||||
const folderAuthMode = folder?.draft?.request?.auth?.mode ?? folder?.root?.request?.auth?.mode;
|
||||
const hasAuth = useMemo(
|
||||
() => hasEffectiveAuth(collection, folder),
|
||||
[folder, folderAuthMode, collection]
|
||||
);
|
||||
|
||||
const setTab = (tab) => {
|
||||
dispatch(
|
||||
@@ -95,7 +98,7 @@ const FolderSettings = ({ collection, folder }) => {
|
||||
</div>
|
||||
<div className={getTabClassname('auth')} role="tab" data-testid="folder-settings-tab-auth" onClick={() => setTab('auth')}>
|
||||
Auth
|
||||
{hasAuth && <StatusDot />}
|
||||
{hasAuth && <StatusDot dataTestId="auth" />}
|
||||
</div>
|
||||
<div className={getTabClassname('docs')} role="tab" data-testid="folder-settings-tab-docs" onClick={() => setTab('docs')}>
|
||||
Docs
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { IconX } from '@tabler/icons';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import useFocusTrap from 'hooks/useFocusTrap';
|
||||
import Button from 'ui/Button';
|
||||
@@ -12,13 +13,15 @@ const ModalHeader = ({ title, handleCancel, customHeader, hideClose }) => (
|
||||
{handleCancel && !hideClose ? (
|
||||
// TODO: Remove data-test-id and use data-testid instead across the codebase.
|
||||
<div className="close cursor-pointer" onClick={handleCancel ? () => handleCancel() : null} data-testid="modal-close-button">
|
||||
×
|
||||
<IconX size={16} strokeWidth={1.5} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
const ModalContent = ({ children }) => <div className="bruno-modal-content px-4 py-4">{children}</div>;
|
||||
const ModalContent = ({ children, noPadding }) => (
|
||||
<div className={`bruno-modal-content ${noPadding ? '' : 'px-4 py-4'}`}>{children}</div>
|
||||
);
|
||||
|
||||
const ModalFooter = ({
|
||||
confirmText,
|
||||
@@ -28,6 +31,7 @@ const ModalFooter = ({
|
||||
confirmDisabled,
|
||||
hideCancel,
|
||||
hideFooter,
|
||||
footerLeft,
|
||||
confirmButtonColor = 'primary',
|
||||
dataTestId = 'modal'
|
||||
}) => {
|
||||
@@ -39,24 +43,27 @@ const ModalFooter = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-end p-4 bruno-modal-footer">
|
||||
<span className={hideCancel ? 'hidden' : 'mr-2'}>
|
||||
<Button type="button" color="secondary" variant="ghost" onClick={handleCancel}>
|
||||
{cancelText}
|
||||
</Button>
|
||||
</span>
|
||||
<span>
|
||||
<Button
|
||||
type="submit"
|
||||
color={confirmButtonColor}
|
||||
disabled={confirmDisabled}
|
||||
onClick={handleSubmit}
|
||||
className="submit"
|
||||
data-testid={`${dataTestId}-submit-btn`}
|
||||
>
|
||||
{confirmText}
|
||||
</Button>
|
||||
</span>
|
||||
<div className="flex justify-between items-center p-4 bruno-modal-footer">
|
||||
<div>{footerLeft}</div>
|
||||
<div className="flex justify-end">
|
||||
<span className={hideCancel ? 'hidden' : 'mr-2'}>
|
||||
<Button type="button" color="secondary" variant="ghost" onClick={handleCancel}>
|
||||
{cancelText}
|
||||
</Button>
|
||||
</span>
|
||||
<span>
|
||||
<Button
|
||||
type="submit"
|
||||
color={confirmButtonColor}
|
||||
disabled={confirmDisabled}
|
||||
onClick={handleSubmit}
|
||||
className="submit"
|
||||
data-testid={`${dataTestId}-submit-btn`}
|
||||
>
|
||||
{confirmText}
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -74,12 +81,14 @@ const Modal = ({
|
||||
hideCancel,
|
||||
hideFooter,
|
||||
hideClose,
|
||||
footerLeft,
|
||||
disableCloseOnOutsideClick,
|
||||
disableEscapeKey,
|
||||
onClick,
|
||||
closeModalFadeTimeout = 500,
|
||||
dataTestId,
|
||||
confirmButtonColor = 'primary'
|
||||
confirmButtonColor = 'primary',
|
||||
noPadding
|
||||
}) => {
|
||||
const modalRef = useRef(null);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
@@ -143,7 +152,7 @@ const Modal = ({
|
||||
handleCancel={() => closeModal({ type: 'icon' })}
|
||||
customHeader={customHeader}
|
||||
/>
|
||||
<ModalContent>{children}</ModalContent>
|
||||
<ModalContent noPadding={noPadding}>{children}</ModalContent>
|
||||
<ModalFooter
|
||||
confirmText={confirmText}
|
||||
cancelText={cancelText}
|
||||
@@ -152,6 +161,7 @@ const Modal = ({
|
||||
confirmDisabled={confirmDisabled}
|
||||
hideCancel={hideCancel}
|
||||
hideFooter={hideFooter}
|
||||
footerLeft={footerLeft}
|
||||
confirmButtonColor={confirmButtonColor}
|
||||
dataTestId={dataTestId}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
|
||||
.file-chips-row {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.file-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 6px;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
color: ${(props) => props.theme.text};
|
||||
max-width: 140px;
|
||||
min-width: 75px;
|
||||
flex: 0 1 auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.file-chip-icon {
|
||||
flex: 0 0 auto;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
.file-chip-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.file-chip-remove {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
flex: 0 0 auto;
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => props.theme.colors.text.danger};
|
||||
}
|
||||
}
|
||||
|
||||
.file-more-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 4px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
color: ${(props) => props.theme.primary.text};
|
||||
cursor: pointer;
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => props.theme.primary.text};
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.file-summary-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 6px;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
color: ${(props) => props.theme.text};
|
||||
cursor: pointer;
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
|
||||
> span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
> svg {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:hover > span {
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: ${(props) => props.theme.colors.text.muted};
|
||||
background: ${(props) => props.theme.requestTabs.icon.hoverBg};
|
||||
}
|
||||
}
|
||||
|
||||
.upload-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: color 0.15s ease;
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const OverflowList = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 4px;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
min-width: 220px;
|
||||
max-width: 360px;
|
||||
|
||||
.overflow-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
color: ${(props) => props.theme.text};
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.requestTabs.icon.hoverBg};
|
||||
}
|
||||
}
|
||||
|
||||
.overflow-row-icon {
|
||||
flex: 0 0 auto;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
.overflow-row-name {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.overflow-row-remove {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
flex: 0 0 auto;
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => props.theme.colors.text.danger};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default Wrapper;
|
||||
@@ -0,0 +1,197 @@
|
||||
import React, { useLayoutEffect, useRef, useState } from 'react';
|
||||
import { IconUpload, IconX, IconFile, IconChevronDown } from '@tabler/icons';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import ToolHint from 'components/ToolHint';
|
||||
import path, { normalizePath } from 'utils/common/path';
|
||||
import Wrapper, { OverflowList } from './StyledWrapper';
|
||||
|
||||
const basename = (filePath) => (filePath ? path.basename(normalizePath(String(filePath))) : '');
|
||||
|
||||
const FileEntry = ({ filePath, toolhintId, editMode, onRemove, variant }) => {
|
||||
const [overRemove, setOverRemove] = useState(false);
|
||||
const isChip = variant === 'chip';
|
||||
|
||||
return (
|
||||
<ToolHint
|
||||
text={overRemove ? 'Remove file' : filePath}
|
||||
toolhintId={toolhintId}
|
||||
place={overRemove ? 'bottom-end' : 'bottom-start'}
|
||||
positionStrategy="fixed"
|
||||
tooltipStyle={{ maxWidth: '320px', whiteSpace: 'normal', wordBreak: 'break-all' }}
|
||||
delayShow={overRemove ? 200 : 1000}
|
||||
className={isChip ? 'file-chip' : 'overflow-row'}
|
||||
dataTestId={isChip ? 'multipart-file-chip' : 'multipart-file-overflow-row'}
|
||||
>
|
||||
<IconFile size={14} stroke={1.5} className={isChip ? 'file-chip-icon' : 'overflow-row-icon'} />
|
||||
<span className={isChip ? 'file-chip-name' : 'overflow-row-name'}>
|
||||
{basename(filePath)}
|
||||
</span>
|
||||
{editMode && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={isChip ? 'multipart-file-chip-remove' : 'multipart-file-overflow-remove'}
|
||||
className={isChip ? 'file-chip-remove' : 'overflow-row-remove'}
|
||||
onMouseEnter={() => setOverRemove(true)}
|
||||
onMouseLeave={() => setOverRemove(false)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove(filePath);
|
||||
}}
|
||||
>
|
||||
<IconX size={13} stroke={1.5} />
|
||||
</button>
|
||||
)}
|
||||
</ToolHint>
|
||||
);
|
||||
};
|
||||
|
||||
// Keep in sync with the corresponding CSS values in StyledWrapper.js:
|
||||
// MIN_CHIP_W ↔ .file-chip { min-width: 75px }
|
||||
// CHIP_GAP ↔ .file-chips-row { gap: 4px }
|
||||
const MIN_CHIP_W = 75;
|
||||
const CHIP_GAP = 4;
|
||||
const UPLOAD_RESERVE = 28;
|
||||
const MORE_CHIP_RESERVE = 56;
|
||||
|
||||
const MultipartFileChipsCell = ({ files, onRemove, onAdd, editMode = true }) => {
|
||||
const containerRef = useRef(null);
|
||||
const tooltipPrefix = useRef(`mp-tip-${Math.random().toString(36).slice(2, 10)}`).current;
|
||||
const [visibleCount, setVisibleCount] = useState(files.length);
|
||||
const [summaryOpen, setSummaryOpen] = useState(false);
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
// Measure the td (column-width, stable) rather than the content-sized cell,
|
||||
// which would feed back on visibleCount.
|
||||
const td = container.closest('td') || container.parentElement;
|
||||
if (!td) return;
|
||||
|
||||
const compute = () => {
|
||||
const tdStyle = window.getComputedStyle(td);
|
||||
const padX = parseFloat(tdStyle.paddingLeft) + parseFloat(tdStyle.paddingRight);
|
||||
const total = td.clientWidth - padX;
|
||||
if (files.length === 0) {
|
||||
setVisibleCount(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const allAtMin = files.length * MIN_CHIP_W + Math.max(0, files.length - 1) * CHIP_GAP;
|
||||
if (allAtMin + UPLOAD_RESERVE <= total) {
|
||||
setVisibleCount(files.length);
|
||||
return;
|
||||
}
|
||||
|
||||
const available = total - UPLOAD_RESERVE - MORE_CHIP_RESERVE;
|
||||
const n = Math.max(0, Math.floor((available + CHIP_GAP) / (MIN_CHIP_W + CHIP_GAP)));
|
||||
setVisibleCount(n);
|
||||
};
|
||||
|
||||
compute();
|
||||
const ro = new ResizeObserver(compute);
|
||||
ro.observe(td);
|
||||
return () => ro.disconnect();
|
||||
}, [files]);
|
||||
|
||||
const visible = files.slice(0, visibleCount);
|
||||
const overflow = files.slice(visibleCount);
|
||||
const collapsed = visibleCount === 0 && files.length > 0;
|
||||
|
||||
const renderChip = (filePath, idx) => (
|
||||
<FileEntry
|
||||
key={`${filePath}-${idx}`}
|
||||
variant="chip"
|
||||
filePath={filePath}
|
||||
toolhintId={`${tooltipPrefix}-chip-${idx}`}
|
||||
editMode={editMode}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderOverflowList = (list) => (
|
||||
<OverflowList>
|
||||
{list.map((p, i) => (
|
||||
<FileEntry
|
||||
key={`o-${p}-${i}`}
|
||||
variant="overflow"
|
||||
filePath={p}
|
||||
toolhintId={`${tooltipPrefix}-overflow-${i}`}
|
||||
editMode={editMode}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
))}
|
||||
</OverflowList>
|
||||
);
|
||||
|
||||
return (
|
||||
<Wrapper className="file-value-cell" ref={containerRef}>
|
||||
{collapsed ? (
|
||||
<>
|
||||
<Dropdown
|
||||
placement="bottom-start"
|
||||
appendTo={() => document.body}
|
||||
onMount={() => setSummaryOpen(true)}
|
||||
onHidden={() => setSummaryOpen(false)}
|
||||
icon={(
|
||||
<button
|
||||
type="button"
|
||||
data-testid="multipart-file-summary"
|
||||
className="file-summary-chip"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
title={`${files.length} file${files.length > 1 ? 's' : ''}`}
|
||||
>
|
||||
<IconFile size={14} stroke={1.5} className="file-chip-icon" />
|
||||
<span>{files.length} file{files.length > 1 ? 's' : ''}</span>
|
||||
<IconChevronDown size={14} stroke={1.5} />
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
{summaryOpen ? renderOverflowList(files) : null}
|
||||
</Dropdown>
|
||||
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="file-chips-row">
|
||||
{visible.map((p, i) => renderChip(p, i))}
|
||||
</div>
|
||||
{overflow.length > 0 && (
|
||||
<Dropdown
|
||||
placement="bottom-end"
|
||||
appendTo={() => document.body}
|
||||
onMount={() => setMoreOpen(true)}
|
||||
onHidden={() => setMoreOpen(false)}
|
||||
icon={(
|
||||
<button
|
||||
type="button"
|
||||
data-testid="multipart-file-more"
|
||||
className="file-more-chip"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
title={`${overflow.length} more file${overflow.length > 1 ? 's' : ''}`}
|
||||
>
|
||||
+{overflow.length} more
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
{moreOpen ? renderOverflowList(overflow) : null}
|
||||
</Dropdown>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{editMode && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="multipart-file-upload"
|
||||
className="upload-btn ml-1"
|
||||
onClick={onAdd}
|
||||
title="Add files"
|
||||
>
|
||||
<IconUpload size={16} />
|
||||
</button>
|
||||
)}
|
||||
</Wrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default MultipartFileChipsCell;
|
||||
@@ -0,0 +1,95 @@
|
||||
import DOMPurify from 'dompurify';
|
||||
import { parseToRgb, rgba } from 'polished';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { humanizeDate } from 'utils/common';
|
||||
|
||||
// color may be any CSS color (hex, rgb, hsl): solid text on a 15% tinted bg.
|
||||
// Falls back to the theme's purple when the supplied color can't be parsed.
|
||||
export const getBadgeStyle = (color, theme) => {
|
||||
let badgeColor = theme.colors.text.purple;
|
||||
try {
|
||||
parseToRgb(color);
|
||||
badgeColor = color;
|
||||
} catch {
|
||||
// invalid color; keep the fallback
|
||||
}
|
||||
return {
|
||||
backgroundColor: rgba(badgeColor, 0.15),
|
||||
color: badgeColor
|
||||
};
|
||||
};
|
||||
|
||||
const getSanitizedDescription = (description) => {
|
||||
return DOMPurify.sanitize(description || '', {
|
||||
ALLOWED_TAGS: ['a', 'ul', 'img', 'li', 'div', 'span', 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'br', 'strong', 'em'],
|
||||
ALLOWED_ATTR: ['href', 'style', 'target', 'src', 'alt']
|
||||
});
|
||||
};
|
||||
|
||||
const NotificationDetail = ({ notification }) => {
|
||||
const { theme } = useTheme();
|
||||
|
||||
// Rendered in a sandboxed iframe (no allow-scripts); theme CSS is inlined
|
||||
// since the iframe doesn't inherit app styles.
|
||||
const buildDescriptionDocument = (description) => {
|
||||
const body = getSanitizedDescription(description);
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<base target="_blank" />
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; background: ${theme.notifications.bg}; }
|
||||
body {
|
||||
padding: 8px 12px;
|
||||
font-family: Inter, sans-serif;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
font-weight: 500;
|
||||
color: ${theme.colors.text.muted};
|
||||
word-break: break-word;
|
||||
}
|
||||
p { margin: 0 0 0.75rem 0; }
|
||||
a { color: ${theme.textLink}; text-decoration: underline; }
|
||||
h1, h2, h3, h4, h5, h6 { font-size: 13px; font-weight: 600; margin: 0 0 0.5rem 0; color: ${theme.text}; }
|
||||
ul { padding-left: 1.25rem; margin: 0 0 0.75rem 0; }
|
||||
img { max-width: 100%; }
|
||||
</style>
|
||||
</head>
|
||||
<body>${body}</body>
|
||||
</html>`;
|
||||
};
|
||||
|
||||
if (!notification) {
|
||||
return (
|
||||
<div className="notif-detail">
|
||||
<div className="notif-empty">Select a notification to read more.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="notif-detail">
|
||||
<div className="notif-detail-header">
|
||||
<div className="notif-detail-meta">
|
||||
{notification.type && (
|
||||
<span className="notif-type-badge" style={getBadgeStyle(notification.color, theme)}>
|
||||
{notification.type}
|
||||
</span>
|
||||
)}
|
||||
<span className="notif-detail-date">{humanizeDate(notification.date)}</span>
|
||||
</div>
|
||||
<div className="notif-detail-title">{notification.title}</div>
|
||||
</div>
|
||||
<iframe
|
||||
key={notification.id}
|
||||
className="notif-detail-body"
|
||||
title="Notification details"
|
||||
sandbox="allow-popups"
|
||||
srcDoc={buildDescriptionDocument(notification.description)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationDetail;
|
||||
@@ -0,0 +1,40 @@
|
||||
import { rgba } from 'polished';
|
||||
import { getBadgeStyle } from './NotificationDetail';
|
||||
|
||||
describe('getBadgeStyle', () => {
|
||||
const theme = { colors: { text: { purple: '#8e44ad' } } };
|
||||
|
||||
it('uses a valid hex color for both text and tinted background', () => {
|
||||
const style = getBadgeStyle('#ff0000', theme);
|
||||
expect(style).toEqual({
|
||||
backgroundColor: rgba('#ff0000', 0.15),
|
||||
color: '#ff0000'
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts rgb color strings', () => {
|
||||
const style = getBadgeStyle('rgb(0, 128, 255)', theme);
|
||||
expect(style.color).toBe('rgb(0, 128, 255)');
|
||||
expect(style.backgroundColor).toBe(rgba('rgb(0, 128, 255)', 0.15));
|
||||
});
|
||||
|
||||
it('accepts hsl color strings', () => {
|
||||
const style = getBadgeStyle('hsl(210, 100%, 50%)', theme);
|
||||
expect(style.color).toBe('hsl(210, 100%, 50%)');
|
||||
expect(style.backgroundColor).toBe(rgba('hsl(210, 100%, 50%)', 0.15));
|
||||
});
|
||||
|
||||
it('falls back to the theme purple for an unparseable color', () => {
|
||||
const style = getBadgeStyle('not-a-color', theme);
|
||||
expect(style).toEqual({
|
||||
backgroundColor: rgba(theme.colors.text.purple, 0.15),
|
||||
color: theme.colors.text.purple
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the theme purple when color is undefined', () => {
|
||||
const style = getBadgeStyle(undefined, theme);
|
||||
expect(style.color).toBe(theme.colors.text.purple);
|
||||
expect(style.backgroundColor).toBe(rgba(theme.colors.text.purple, 0.15));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import classnames from 'classnames';
|
||||
import { relativeDate } from 'utils/common';
|
||||
|
||||
const NotificationList = ({ items, selectedId, onSelect }) => {
|
||||
return (
|
||||
<ul className="notif-list">
|
||||
{items.map((notification) => {
|
||||
const isActive = selectedId === notification.id;
|
||||
const isUnread = !notification.read;
|
||||
return (
|
||||
<li
|
||||
key={notification.id}
|
||||
className={classnames('notif-list-item', { active: isActive, unread: isUnread })}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onSelect(notification)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onSelect(notification);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className={classnames('notif-item-title', { unread: isUnread })}>{notification.title}</div>
|
||||
<div className="notif-item-date">{relativeDate(notification.date)}</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{items.length === 0 && <li className="notif-list-empty">No notifications to show.</li>}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationList;
|
||||
@@ -0,0 +1,74 @@
|
||||
import classnames from 'classnames';
|
||||
import { IconDotsVertical } from '@tabler/icons';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import { TABS } from '../hooks/useNotifications';
|
||||
|
||||
const menuIcon = (
|
||||
<span className="notif-menu-trigger" aria-label="Notifications menu">
|
||||
<IconDotsVertical size={16} strokeWidth={1.5} />
|
||||
</span>
|
||||
);
|
||||
|
||||
const NotificationTabs = ({ activeTab, unreadCount, onTabChange, onMarkAllRead, onClearAll }) => {
|
||||
const dropdownTippyRef = useRef(null);
|
||||
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
|
||||
const hideDropdown = () => dropdownTippyRef.current?.hide();
|
||||
|
||||
// Clicks inside the detail iframe don't bubble to the parent document, so
|
||||
// tippy's outside-click dismissal never fires. Closing on iframe focus covers it.
|
||||
useEffect(() => {
|
||||
const onWindowBlur = () => {
|
||||
if (document.activeElement?.tagName === 'IFRAME') {
|
||||
hideDropdown();
|
||||
}
|
||||
};
|
||||
window.addEventListener('blur', onWindowBlur);
|
||||
return () => window.removeEventListener('blur', onWindowBlur);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="notif-tabs">
|
||||
<div className="notif-tab-group">
|
||||
<button
|
||||
type="button"
|
||||
className={classnames('notif-tab', { active: activeTab === TABS.ALL })}
|
||||
onClick={() => onTabChange(TABS.ALL)}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={classnames('notif-tab', { active: activeTab === TABS.UNREAD })}
|
||||
onClick={() => onTabChange(TABS.UNREAD)}
|
||||
>
|
||||
Unread
|
||||
{unreadCount > 0 && <span className="notif-tab-badge">{unreadCount}</span>}
|
||||
</button>
|
||||
</div>
|
||||
<Dropdown icon={menuIcon} placement="bottom-end" onCreate={onDropdownCreate}>
|
||||
<div
|
||||
className={classnames('dropdown-item', { disabled: unreadCount === 0 })}
|
||||
onClick={() => {
|
||||
if (unreadCount === 0) return;
|
||||
hideDropdown();
|
||||
onMarkAllRead();
|
||||
}}
|
||||
>
|
||||
Mark all as read
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
hideDropdown();
|
||||
onClearAll();
|
||||
}}
|
||||
>
|
||||
Clear all
|
||||
</div>
|
||||
</Dropdown>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationTabs;
|
||||
@@ -0,0 +1,267 @@
|
||||
import styled from 'styled-components';
|
||||
import { rgba } from 'polished';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: 800px;
|
||||
height: 520px;
|
||||
max-width: 100%;
|
||||
max-height: 70vh;
|
||||
overflow: hidden;
|
||||
background-color: ${(props) => props.theme.notifications.bg};
|
||||
|
||||
/* While dragging, stop the detail iframe from swallowing mousemove events,
|
||||
which would otherwise freeze the resize until the cursor re-enters the handle. */
|
||||
&.dragging .notif-detail-body {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.notif-sidebar {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: ${(props) => props.theme.notifications.list.bg};
|
||||
}
|
||||
|
||||
.notif-resize-handle {
|
||||
flex: 0 0 1px;
|
||||
cursor: col-resize;
|
||||
background: ${(props) => props.theme.notifications.list.borderBottom};
|
||||
position: relative;
|
||||
user-select: none;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
/* widen the hit target without bloating the visible line */
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: -3px;
|
||||
right: -3px;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&.dragging {
|
||||
background: ${(props) => props.theme.colors.text.yellow};
|
||||
}
|
||||
}
|
||||
|
||||
.notif-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 7px 12px;
|
||||
gap: 6px;
|
||||
border-bottom: 1px solid ${(props) => props.theme.notifications.list.borderBottom};
|
||||
}
|
||||
|
||||
.notif-tab-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.notif-tab {
|
||||
height: 24px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid ${(props) => props.theme.notifications.list.borderBottom};
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
font-weight: 400;
|
||||
color: ${(props) => props.theme.text};
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
&.active {
|
||||
background-color: ${(props) => props.theme.brand};
|
||||
color: ${(props) => props.theme.background.base};
|
||||
font-weight: 500;
|
||||
|
||||
.notif-tab-badge {
|
||||
background-color: ${(props) => props.theme.background.base};
|
||||
color: ${(props) => props.theme.brand};
|
||||
border-color: ${(props) => props.theme.background.base};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.notif-tab-badge {
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid ${(props) => props.theme.notifications.list.borderBottom};
|
||||
background-color: ${(props) => rgba(props.theme.brand, 0.1)};
|
||||
color: ${(props) => props.theme.brand};
|
||||
font-size: 11px;
|
||||
line-height: 14px;
|
||||
font-weight: 500;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.notif-menu-trigger {
|
||||
cursor: pointer;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background-color: ${(props) => props.theme.notifications.list.hoverBg};
|
||||
}
|
||||
}
|
||||
|
||||
.notif-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
background-color: ${(props) => props.theme.notifications.list.bg};
|
||||
}
|
||||
|
||||
.notif-list-empty {
|
||||
padding: 16px 12px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
font-size: 12px;
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.notif-list-item {
|
||||
position: relative;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
border-bottom: solid 1px ${(props) => props.theme.notifications.list.borderBottom};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
|
||||
&:hover {
|
||||
background-color: ${(props) => props.theme.notifications.list.hoverBg};
|
||||
}
|
||||
|
||||
&.unread {
|
||||
background-color: ${(props) => props.theme.notifications.list.active.bg};
|
||||
|
||||
&:hover {
|
||||
background-color: ${(props) => props.theme.notifications.list.hoverBg};
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background-color: ${(props) => props.theme.colors.text.yellow};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.notif-item-title {
|
||||
color: ${(props) => props.theme.text};
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
font-weight: 400;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
|
||||
&.unread {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.notif-item-date,
|
||||
.notif-detail-date {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.notif-detail {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 6px 6px 0 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.notif-detail-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.notif-detail-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 1px;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
.notif-type-badge {
|
||||
height: 24px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid ${(props) => props.theme.notifications.list.borderBottom};
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
font-weight: 400;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.notif-detail-title {
|
||||
color: ${(props) => props.theme.text};
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.notif-detail-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.notif-empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.notif-empty-text {
|
||||
font-style: italic;
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useRef } from 'react';
|
||||
import classnames from 'classnames';
|
||||
import { useDragResize } from 'hooks/useDragResize';
|
||||
import { usePersistedState } from 'hooks/usePersistedState';
|
||||
import Modal from 'components/Modal/index';
|
||||
import Portal from 'components/Portal';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import NotificationTabs from './NotificationTabs';
|
||||
import NotificationList from './NotificationList';
|
||||
import NotificationDetail from './NotificationDetail';
|
||||
|
||||
const DEFAULT_SIDEBAR_WIDTH = 260;
|
||||
const SIDEBAR_MIN = 200;
|
||||
// Reserved for the detail pane; caps the sidebar at ~420px in the 800px modal.
|
||||
const DETAIL_MIN = 380;
|
||||
|
||||
const NotificationsModal = ({ notifications, onClose }) => {
|
||||
const {
|
||||
visibleNotifications,
|
||||
listed,
|
||||
unreadCount,
|
||||
activeTab,
|
||||
selectedNotification,
|
||||
onTabChange,
|
||||
onSelect,
|
||||
onMarkAllRead,
|
||||
onClearAll
|
||||
} = notifications;
|
||||
|
||||
const containerRef = useRef(null);
|
||||
const [sidebarWidth, setSidebarWidth] = usePersistedState({
|
||||
key: 'notification-sidebar',
|
||||
default: DEFAULT_SIDEBAR_WIDTH
|
||||
});
|
||||
const { dragging, dragWidth, dragbarProps } = useDragResize({
|
||||
containerRef,
|
||||
width: sidebarWidth,
|
||||
onWidthChange: (w) => setSidebarWidth(w ?? DEFAULT_SIDEBAR_WIDTH),
|
||||
minLeft: SIDEBAR_MIN,
|
||||
minRight: DETAIL_MIN
|
||||
});
|
||||
const effectiveWidth = dragging ? dragWidth : sidebarWidth;
|
||||
const isEmpty = visibleNotifications.length === 0;
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Modal
|
||||
size="md"
|
||||
title="Notifications"
|
||||
confirmText="Close"
|
||||
handleConfirm={onClose}
|
||||
handleCancel={onClose}
|
||||
hideFooter={true}
|
||||
disableCloseOnOutsideClick={true}
|
||||
disableEscapeKey={true}
|
||||
noPadding={true}
|
||||
>
|
||||
<StyledWrapper className={classnames('notifications-modal', { dragging })} ref={containerRef}>
|
||||
<div className="notif-sidebar" style={{ width: effectiveWidth, flexBasis: effectiveWidth }}>
|
||||
<NotificationTabs
|
||||
activeTab={activeTab}
|
||||
unreadCount={unreadCount}
|
||||
onTabChange={onTabChange}
|
||||
onMarkAllRead={onMarkAllRead}
|
||||
onClearAll={onClearAll}
|
||||
/>
|
||||
<NotificationList items={listed} selectedId={selectedNotification?.id} onSelect={onSelect} />
|
||||
</div>
|
||||
<div
|
||||
className={classnames('notif-resize-handle', { dragging })}
|
||||
{...dragbarProps}
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="Resize sidebar"
|
||||
/>
|
||||
{isEmpty ? (
|
||||
<div className="notif-empty">
|
||||
<div className="notif-empty-text">You are all caught up!</div>
|
||||
</div>
|
||||
) : (
|
||||
<NotificationDetail notification={selectedNotification} />
|
||||
)}
|
||||
</StyledWrapper>
|
||||
</Modal>
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationsModal;
|
||||
@@ -1,85 +0,0 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.notifications-modal {
|
||||
margin-inline: -1rem;
|
||||
margin-block: -1.5rem;
|
||||
background-color: ${(props) => props.theme.notifications.bg};
|
||||
}
|
||||
|
||||
.notification-count {
|
||||
display: flex;
|
||||
color: white;
|
||||
position: absolute;
|
||||
top: -0.625rem;
|
||||
right: -0.5rem;
|
||||
margin-right: 0.5rem;
|
||||
justify-content: center;
|
||||
font-size: 0.625rem;
|
||||
border-radius: 50%;
|
||||
background-color: ${(props) => props.theme.colors.text.yellow};
|
||||
border: solid 2px ${(props) => props.theme.sidebar.bg};
|
||||
min-width: 1.25rem;
|
||||
}
|
||||
|
||||
button.mark-as-read {
|
||||
font-weight: 400 !important;
|
||||
}
|
||||
|
||||
ul.notifications {
|
||||
background-color: ${(props) => props.theme.notifications.list.bg};
|
||||
border-right: solid 1px ${(props) => props.theme.notifications.list.borderRight};
|
||||
min-height: 400px;
|
||||
height: 100%;
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
|
||||
li {
|
||||
min-width: 150px;
|
||||
cursor: pointer;
|
||||
padding: 0.5rem 0.625rem;
|
||||
border-left: solid 2px transparent;
|
||||
color: ${(props) => props.theme.textLink};
|
||||
border-bottom: solid 1px ${(props) => props.theme.notifications.list.borderBottom};
|
||||
&:hover {
|
||||
background-color: ${(props) => props.theme.notifications.list.hoverBg};
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: ${(props) => props.theme.text} !important;
|
||||
background-color: ${(props) => props.theme.notifications.list.active.bg} !important;
|
||||
border-left: solid 2px ${(props) => props.theme.notifications.list.active.border};
|
||||
&:hover {
|
||||
background-color: ${(props) => props.theme.notifications.list.active.hoverBg} !important;
|
||||
}
|
||||
}
|
||||
|
||||
&.read {
|
||||
color: ${(props) => props.theme.text} !important;
|
||||
}
|
||||
|
||||
.notification-date {
|
||||
font-size: ${(props) => props.theme.font.size.xs};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.notification-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.notification-date {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
.pagination {
|
||||
background-color: ${(props) => props.theme.notifications.list.bg};
|
||||
border-right: solid 1px ${(props) => props.theme.notifications.list.borderRight};
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,32 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.button`
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
|
||||
.notification-count {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 14px;
|
||||
height: 14px;
|
||||
padding: 0 3px;
|
||||
color: ${(props) => props.theme.background.base};
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
border-radius: 999px;
|
||||
background-color: ${(props) => props.theme.brand};
|
||||
border: 1.5px solid ${(props) => props.theme.sidebar.bg};
|
||||
box-sizing: border-box;
|
||||
pointer-events: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import {
|
||||
clearAllNotifications,
|
||||
markAllNotificationsAsRead,
|
||||
markNotificationAsRead
|
||||
} from 'providers/ReduxStore/slices/notifications';
|
||||
|
||||
export const TABS = { ALL: 'all', UNREAD: 'unread' };
|
||||
|
||||
const useNotifications = () => {
|
||||
const dispatch = useDispatch();
|
||||
const notifications = useSelector((state) => state.notifications.notifications);
|
||||
const clearedIds = useSelector((state) => state.notifications.clearedNotificationIds);
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [selectedNotification, setSelectedNotification] = useState(null);
|
||||
const [activeTab, setActiveTab] = useState(TABS.ALL);
|
||||
const [pinnedUnreadIds, setPinnedUnreadIds] = useState(null);
|
||||
|
||||
const visibleNotifications = useMemo(
|
||||
() => notifications.filter((n) => !clearedIds?.includes(n.id)),
|
||||
[notifications, clearedIds]
|
||||
);
|
||||
const unreadCount = visibleNotifications.filter((n) => !n.read).length;
|
||||
// Pin the Unread set on tab entry so reading items doesn't make them vanish.
|
||||
const listed = useMemo(() => {
|
||||
if (activeTab !== TABS.UNREAD) return visibleNotifications;
|
||||
if (!pinnedUnreadIds) return visibleNotifications.filter((n) => !n.read);
|
||||
return visibleNotifications.filter((n) => pinnedUnreadIds.has(n.id));
|
||||
}, [activeTab, visibleNotifications, pinnedUnreadIds]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
if (selectedNotification && listed.find((n) => n.id === selectedNotification.id)) return;
|
||||
const first = listed[0];
|
||||
if (!first) {
|
||||
setSelectedNotification(null);
|
||||
return;
|
||||
}
|
||||
setSelectedNotification(first);
|
||||
if (!first.read) {
|
||||
dispatch(markNotificationAsRead({ notificationId: first.id }));
|
||||
}
|
||||
}, [listed, selectedNotification, isOpen]);
|
||||
|
||||
const onTabChange = (tab) => {
|
||||
if (tab === TABS.UNREAD) {
|
||||
const ids = visibleNotifications.filter((n) => !n.read).map((n) => n.id);
|
||||
setPinnedUnreadIds(new Set(ids));
|
||||
} else {
|
||||
setPinnedUnreadIds(null);
|
||||
}
|
||||
setActiveTab(tab);
|
||||
};
|
||||
|
||||
const onSelect = (notification) => {
|
||||
setSelectedNotification(notification);
|
||||
if (!notification.read) {
|
||||
dispatch(markNotificationAsRead({ notificationId: notification.id }));
|
||||
}
|
||||
};
|
||||
|
||||
const onMarkAllRead = () => {
|
||||
dispatch(markAllNotificationsAsRead());
|
||||
if (activeTab === TABS.UNREAD) {
|
||||
setPinnedUnreadIds(null);
|
||||
}
|
||||
};
|
||||
const onClearAll = () => dispatch(clearAllNotifications());
|
||||
|
||||
const open = () => {
|
||||
window.ipcRenderer?.send('renderer:notifications-opened');
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
setIsOpen(false);
|
||||
setSelectedNotification(null);
|
||||
setActiveTab(TABS.ALL);
|
||||
setPinnedUnreadIds(null);
|
||||
};
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
visibleNotifications,
|
||||
listed,
|
||||
unreadCount,
|
||||
activeTab,
|
||||
selectedNotification,
|
||||
open,
|
||||
close,
|
||||
onTabChange,
|
||||
onSelect,
|
||||
onMarkAllRead,
|
||||
onClearAll
|
||||
};
|
||||
};
|
||||
|
||||
export default useNotifications;
|
||||
@@ -1,214 +1,24 @@
|
||||
import { IconBell } from '@tabler/icons';
|
||||
import { useState } from 'react';
|
||||
import StyledWrapper from './StyleWrapper';
|
||||
import Modal from 'components/Modal/index';
|
||||
import Portal from 'components/Portal';
|
||||
import { useEffect } from 'react';
|
||||
import { useApp } from 'providers/App';
|
||||
import {
|
||||
fetchNotifications,
|
||||
markAllNotificationsAsRead,
|
||||
markNotificationAsRead
|
||||
} from 'providers/ReduxStore/slices/notifications';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { humanizeDate, relativeDate } from 'utils/common';
|
||||
import ToolHint from 'components/ToolHint';
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
const PAGE_SIZE = 5;
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import NotificationsModal from './NotificationsModal';
|
||||
import useNotifications from './hooks/useNotifications';
|
||||
|
||||
const Notifications = () => {
|
||||
const dispatch = useDispatch();
|
||||
const { version } = useApp();
|
||||
const notifications = useSelector((state) => state.notifications.notifications);
|
||||
|
||||
const [showNotificationsModal, setShowNotificationsModal] = useState(false);
|
||||
const [selectedNotification, setSelectedNotification] = useState(null);
|
||||
const [pageNumber, setPageNumber] = useState(1);
|
||||
|
||||
const notificationsStartIndex = (pageNumber - 1) * PAGE_SIZE;
|
||||
const notificationsEndIndex = pageNumber * PAGE_SIZE;
|
||||
const totalPages = Math.ceil(notifications.length / PAGE_SIZE);
|
||||
const unreadNotifications = notifications.filter((notification) => !notification.read);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchNotifications({
|
||||
currentVersion: version
|
||||
}));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
reset();
|
||||
}, [showNotificationsModal]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedNotification && notifications?.length > 0 && showNotificationsModal) {
|
||||
let firstNotification = notifications[0];
|
||||
setSelectedNotification(firstNotification);
|
||||
dispatch(markNotificationAsRead({ notificationId: firstNotification?.id }));
|
||||
}
|
||||
}, [notifications, selectedNotification, showNotificationsModal]);
|
||||
|
||||
const reset = () => {
|
||||
setSelectedNotification(null);
|
||||
setPageNumber(1);
|
||||
};
|
||||
|
||||
const handlePrev = (e) => {
|
||||
if (pageNumber - 1 < 1) return;
|
||||
setPageNumber(pageNumber - 1);
|
||||
};
|
||||
|
||||
const handleNext = (e) => {
|
||||
if (pageNumber + 1 > totalPages) return;
|
||||
setPageNumber(pageNumber + 1);
|
||||
};
|
||||
|
||||
const handleNotificationItemClick = (notification) => (e) => {
|
||||
e.preventDefault();
|
||||
setSelectedNotification(notification);
|
||||
dispatch(markNotificationAsRead({ notificationId: notification?.id }));
|
||||
};
|
||||
|
||||
const getSanitizedDescription = (description) => {
|
||||
return DOMPurify.sanitize(encodeURIComponent(description), {
|
||||
ALLOWED_TAGS: ['a', 'ul', 'img', 'li', 'div', 'span', 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'],
|
||||
ALLOWED_ATTR: ['href', 'style', 'target', 'src', 'alt']
|
||||
});
|
||||
};
|
||||
|
||||
const modalCustomHeader = (
|
||||
<div className="flex flex-row gap-8">
|
||||
<div className="bruno-modal-header-title">NOTIFICATIONS</div>
|
||||
{unreadNotifications.length > 0 && (
|
||||
<>
|
||||
<div className="normal-case font-normal">
|
||||
{unreadNotifications.length} <span>unread notifications</span>
|
||||
</div>
|
||||
<button
|
||||
className={`select-none ${1 == 2 ? 'opacity-50' : 'text-link mark-as-read cursor-pointer hover:underline'}`}
|
||||
onClick={() => dispatch(markAllNotificationsAsRead())}
|
||||
>
|
||||
Mark all as read
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
const notifications = useNotifications();
|
||||
const { isOpen, unreadCount, open, close } = notifications;
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<a
|
||||
className="relative cursor-pointer"
|
||||
onClick={() => {
|
||||
dispatch(fetchNotifications({
|
||||
currentVersion: version
|
||||
}));
|
||||
setShowNotificationsModal(true);
|
||||
}}
|
||||
aria-label="Check all Notifications"
|
||||
>
|
||||
<>
|
||||
<StyledWrapper onClick={open} aria-label="Check all Notifications">
|
||||
<ToolHint text="Notifications" toolhintId="Notifications" offset={8}>
|
||||
<IconBell
|
||||
size={16}
|
||||
aria-hidden
|
||||
strokeWidth={1.5}
|
||||
className={`${unreadNotifications?.length > 0 ? 'bell' : ''}`}
|
||||
/>
|
||||
{unreadNotifications.length > 0 && (
|
||||
<span className="notification-count text-xs">{unreadNotifications.length}</span>
|
||||
)}
|
||||
<IconBell size={16} aria-hidden strokeWidth={1.5} />
|
||||
{unreadCount > 0 && <span className="notification-count">{unreadCount}</span>}
|
||||
</ToolHint>
|
||||
</a>
|
||||
</StyledWrapper>
|
||||
|
||||
{showNotificationsModal && (
|
||||
<Portal>
|
||||
<Modal
|
||||
size="lg"
|
||||
title="Notifications"
|
||||
confirmText="Close"
|
||||
handleConfirm={() => {
|
||||
setShowNotificationsModal(false);
|
||||
}}
|
||||
handleCancel={() => {
|
||||
setShowNotificationsModal(false);
|
||||
}}
|
||||
hideFooter={true}
|
||||
customHeader={modalCustomHeader}
|
||||
disableCloseOnOutsideClick={true}
|
||||
disableEscapeKey={true}
|
||||
>
|
||||
<div className="notifications-modal">
|
||||
{notifications?.length > 0 ? (
|
||||
<div className="grid grid-cols-4 flex flex-row">
|
||||
<div className="col-span-1 flex flex-col">
|
||||
<ul
|
||||
className="notifications w-full flex flex-col h-[50vh] max-h-[50vh] overflow-y-auto"
|
||||
style={{ maxHeight: '50vh', height: '46vh' }}
|
||||
>
|
||||
{notifications?.slice(notificationsStartIndex, notificationsEndIndex)?.map((notification) => (
|
||||
<li
|
||||
key={notification.id}
|
||||
className={`p-4 flex flex-col justify-center ${
|
||||
selectedNotification?.id == notification.id ? 'active' : notification.read ? 'read' : ''
|
||||
}`}
|
||||
onClick={handleNotificationItemClick(notification)}
|
||||
>
|
||||
<div className="notification-title w-full">{notification?.title}</div>
|
||||
<div className="notification-date text-xs py-2">{relativeDate(notification?.date)}</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="w-full pagination flex flex-row gap-4 justify-center p-2 items-center text-xs">
|
||||
<button
|
||||
className={`pl-2 pr-2 py-3 select-none ${
|
||||
pageNumber <= 1 ? 'opacity-50' : 'text-link cursor-pointer hover:underline'
|
||||
}`}
|
||||
onClick={handlePrev}
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
<div className="flex flex-row items-center justify-center gap-1">
|
||||
Page
|
||||
<div className="w-[20px] flex justify-center" style={{ width: '20px' }}>
|
||||
{pageNumber}
|
||||
</div>
|
||||
of
|
||||
<div className="w-[20px] flex justify-center" style={{ width: '20px' }}>
|
||||
{totalPages}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className={`pl-2 pr-2 py-3 select-none ${
|
||||
pageNumber == totalPages ? 'opacity-50' : 'text-link cursor-pointer hover:underline'
|
||||
}`}
|
||||
onClick={handleNext}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full col-span-3 p-4 flex-col">
|
||||
<div className="w-full text-lg flex flex-wrap h-fit mb-1">{selectedNotification?.title}</div>
|
||||
<div className="w-full notification-date text-xs mb-4">
|
||||
{humanizeDate(selectedNotification?.date)}
|
||||
</div>
|
||||
<iframe
|
||||
src={`data:text/html,${getSanitizedDescription(selectedNotification?.description)}`}
|
||||
sandbox="allow-popups"
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
>
|
||||
</iframe>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="opacity-50 italic text-xs p-12 flex justify-center">You are all caught up!</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
</Portal>
|
||||
)}
|
||||
</StyledWrapper>
|
||||
{isOpen && <NotificationsModal notifications={notifications} onClose={close} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -128,17 +128,6 @@ const ConnectSpecForm = ({ sourceUrl, setSourceUrl, isLoading, error, setError,
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="beta-feedback-inline">
|
||||
OpenAPI Sync is in Beta — we'd love to hear your feedback and suggestions.{' '}
|
||||
<button
|
||||
type="button"
|
||||
className="beta-feedback-link"
|
||||
onClick={() => window?.ipcRenderer?.openExternal('https://github.com/usebruno/bruno/discussions/7401')}
|
||||
>
|
||||
Share feedback
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import {
|
||||
IconChevronRight,
|
||||
@@ -15,7 +15,7 @@ import Help from 'components/Help';
|
||||
import EndpointVisualDiff from './EndpointVisualDiff';
|
||||
|
||||
// Expandable row - can be used with or without decision buttons
|
||||
const ExpandableEndpointRow = ({ endpoint, decision, onDecisionChange, collectionPath, newSpec, showDecisions = true, decisionLabels, diffLeftLabel, diffRightLabel, swapDiffSides, collectionUid, actions }) => {
|
||||
const ExpandableEndpointRow = ({ endpoint, decision, onDecisionChange, collectionPath, newSpec, showDecisions = true, decisionLabels, diffLeftLabel, diffRightLabel, swapDiffSides, collectionUid, actions, preserveValues = true }) => {
|
||||
const dispatch = useDispatch();
|
||||
const rowKey = endpoint.id || `${endpoint.method}-${endpoint.path}`;
|
||||
const isExpanded = useSelector((state) => {
|
||||
@@ -25,9 +25,15 @@ const ExpandableEndpointRow = ({ endpoint, decision, onDecisionChange, collectio
|
||||
const [diffData, setDiffData] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const loadDiffData = useCallback(async () => {
|
||||
if (diffData) return;
|
||||
// Monotonic id so a superseded in-flight fetch (e.g. the user flips the
|
||||
// Preserve toggle mid-request) can't overwrite the latest result.
|
||||
const requestIdRef = useRef(0);
|
||||
|
||||
const loadDiffData = useCallback(async () => {
|
||||
// No internal diffData guard: both callers (the expand effect and handleToggle)
|
||||
// already gate on !diffData. Guarding here would capture a stale diffData from
|
||||
// the render that recreated this callback and silently skip the toggle re-fetch.
|
||||
const requestId = ++requestIdRef.current;
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
@@ -36,20 +42,45 @@ const ExpandableEndpointRow = ({ endpoint, decision, onDecisionChange, collectio
|
||||
const result = await ipcRenderer.invoke('renderer:get-endpoint-diff-data', {
|
||||
collectionPath,
|
||||
endpointId: endpoint.id,
|
||||
newSpec
|
||||
newSpec,
|
||||
preserveValues
|
||||
});
|
||||
|
||||
if (requestId !== requestIdRef.current) return; // superseded by a newer fetch
|
||||
if (result.error) {
|
||||
setError(result.error);
|
||||
} else {
|
||||
setDiffData(result);
|
||||
}
|
||||
} catch (err) {
|
||||
if (requestId !== requestIdRef.current) return;
|
||||
setError(formatIpcError(err) || 'Failed to load diff data');
|
||||
} finally {
|
||||
if (requestId === requestIdRef.current) setIsLoading(false);
|
||||
}
|
||||
}, [collectionPath, endpoint.id, newSpec, preserveValues]);
|
||||
|
||||
// Re-fetch the preview when the preserve toggle changes — the EXPECTED column
|
||||
// depends on it. Expanded rows re-fetch in place (the old diff stays visible
|
||||
// and swaps when the new one arrives, so the row never blanks). Collapsed rows
|
||||
// just drop their cache so the next expand fetches fresh — invisible to the user.
|
||||
const didMountPreserve = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!didMountPreserve.current) {
|
||||
didMountPreserve.current = true;
|
||||
return;
|
||||
}
|
||||
if (isExpanded) {
|
||||
loadDiffData(); // bumps requestId, keeps old diff until the new one lands
|
||||
} else {
|
||||
requestIdRef.current++; // invalidate any in-flight fetch
|
||||
setDiffData(null);
|
||||
setError(null);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [collectionPath, endpoint.id, newSpec]);
|
||||
// Intentionally only re-run when the toggle flips — not on isExpanded/loadDiffData
|
||||
// changes, which the dedicated load effect + handleToggle already cover.
|
||||
}, [preserveValues]);
|
||||
|
||||
// Load diff data when expanded (e.g. restored from Redux state)
|
||||
useEffect(() => {
|
||||
@@ -126,18 +157,21 @@ const ExpandableEndpointRow = ({ endpoint, decision, onDecisionChange, collectio
|
||||
|
||||
{isExpanded && (
|
||||
<div className="review-row-diff">
|
||||
{isLoading && (
|
||||
{/* Spinner only on the initial load. A re-fetch (e.g. toggling Preserve)
|
||||
keeps the previous diff visible and swaps it in place, so the row
|
||||
never blanks/flickers. */}
|
||||
{isLoading && !diffData && !error && (
|
||||
<div className="diff-loading">
|
||||
<IconLoader2 size={16} className="spinning" />
|
||||
<span>Loading diff...</span>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
{error && !diffData && (
|
||||
<div className="diff-error">
|
||||
Error: {error}
|
||||
</div>
|
||||
)}
|
||||
{diffData && !isLoading && !error && (
|
||||
{diffData && !error && (
|
||||
<EndpointVisualDiff
|
||||
oldData={diffData.oldData}
|
||||
newData={diffData.newData}
|
||||
|
||||
@@ -687,7 +687,7 @@ const StyledWrapper = styled.div`
|
||||
background: ${(props) => props.theme.colors.text.muted};
|
||||
|
||||
&.active {
|
||||
background: ${(props) => props.theme.colors.text.green};
|
||||
background: ${(props) => props.theme.button2.color.primary.bg};
|
||||
}
|
||||
|
||||
.toggle-knob {
|
||||
@@ -724,9 +724,9 @@ const StyledWrapper = styled.div`
|
||||
transition: all 0.15s;
|
||||
|
||||
&.active {
|
||||
border-color: ${(props) => props.theme.button2.color.primary.border};
|
||||
background: ${(props) => props.theme.button2.color.primary.bg};
|
||||
color: ${(props) => props.theme.button2.color.primary.text};
|
||||
border-color: ${(props) => props.theme.accents.primary};
|
||||
background: ${(props) => rgba(props.theme.accents.primary, 0.07)};
|
||||
color: ${(props) => props.theme.accents.primary};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1770,6 +1770,7 @@ const StyledWrapper = styled.div`
|
||||
.bulk-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
user-select: none; /* these are controls, not selectable text (e.g. double-click on the info icon) */
|
||||
}
|
||||
|
||||
.bulk-btn {
|
||||
@@ -1804,6 +1805,77 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
}
|
||||
|
||||
/* the three Preserve elements read as one control: label + info + toggle */
|
||||
.preserve-values-control {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-right: 0.25rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: ${(props) => props.theme.font.size.xs};
|
||||
color: ${(props) => props.theme.text};
|
||||
|
||||
.preserve-values-label {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* the shared InfoCircle icon ships a hardcoded ml-2 (8px); override it
|
||||
so the info icon sits tight to the label. It is a hover-only tooltip
|
||||
affordance, not a button — use a help cursor and never show a
|
||||
click/focus box around it. */
|
||||
svg {
|
||||
margin-left: 4px;
|
||||
cursor: help;
|
||||
}
|
||||
svg:focus,
|
||||
svg:focus-visible,
|
||||
span:focus,
|
||||
span:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* compact themed track + knob toggle, sized to the button row height */
|
||||
.preserve-toggle {
|
||||
margin-right: 4px; /* space between the toggle and the label */
|
||||
width: 26px;
|
||||
height: 14px;
|
||||
border-radius: 7px;
|
||||
border: none;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
background: ${(props) => props.theme.colors.text.muted};
|
||||
|
||||
&.active {
|
||||
background: ${(props) => props.theme.button2.color.primary.bg};
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid ${(props) => props.theme.button2.color.primary.bg};
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.preserve-toggle-knob {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
transition: left 0.2s;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
&.active .preserve-toggle-knob {
|
||||
left: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sync-review-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
|
||||
@@ -88,6 +88,7 @@ const SyncReviewPage = ({
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
const tabUiState = useSelector((state) => state.openapiSync?.tabUiState?.[collectionUid] || {});
|
||||
const [preserveValues, setPreserveValues] = useState(true);
|
||||
const [showConfirmation, setShowConfirmation] = useState(false);
|
||||
const [showSpecDiffModal, setShowSpecDiffModal] = useState(false);
|
||||
const [isOpeningSpecDiff, setIsOpeningSpecDiff] = useState(false);
|
||||
@@ -210,7 +211,8 @@ const SyncReviewPage = ({
|
||||
newToCollection: filteredAddedEndpoints,
|
||||
specUpdates: filteredSpecChanges,
|
||||
resolvedConflicts: specUpdatedEndpoints.filter((ep) => ep.conflict && decisions[ep.id] === 'accept-incoming'),
|
||||
localChangesToReset: localUpdatedEndpoints.filter((ep) => decisions[ep.id] === 'accept-incoming')
|
||||
localChangesToReset: localUpdatedEndpoints.filter((ep) => decisions[ep.id] === 'accept-incoming'),
|
||||
preserveValues
|
||||
});
|
||||
};
|
||||
|
||||
@@ -238,6 +240,21 @@ const SyncReviewPage = ({
|
||||
</div>
|
||||
{(specDrift?.unifiedDiff || decidableEndpoints.length > 0) && (
|
||||
<div className="bulk-actions">
|
||||
<div className="preserve-values-control">
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-pressed={preserveValues}
|
||||
className={`preserve-toggle ${preserveValues ? 'active' : ''}`}
|
||||
onClick={() => setPreserveValues((v) => !v)}
|
||||
>
|
||||
<span className="preserve-toggle-knob" />
|
||||
</button>
|
||||
<span className="preserve-values-label">Preserve values</span>
|
||||
<Help icon="info" size={12} placement="top" width={260}>
|
||||
When enabled, your edited values are preserved during sync. When disabled, all values are updated to match the OpenAPI spec.
|
||||
</Help>
|
||||
</div>
|
||||
{specDrift?.unifiedDiff && (
|
||||
<button
|
||||
className="bulk-btn"
|
||||
@@ -329,6 +346,7 @@ const SyncReviewPage = ({
|
||||
showDecisions={true}
|
||||
decisionLabels={{ keep: 'Keep Current', accept: 'Update' }}
|
||||
collectionUid={collectionUid}
|
||||
preserveValues={preserveValues}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@@ -353,6 +371,7 @@ const SyncReviewPage = ({
|
||||
showDecisions={true}
|
||||
decisionLabels={{ keep: 'Skip', accept: 'Add' }}
|
||||
collectionUid={collectionUid}
|
||||
preserveValues={preserveValues}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@@ -377,6 +396,7 @@ const SyncReviewPage = ({
|
||||
showDecisions={true}
|
||||
decisionLabels={{ keep: 'Keep', accept: 'Delete' }}
|
||||
collectionUid={collectionUid}
|
||||
preserveValues={preserveValues}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -14,7 +14,7 @@ const useSyncFlow = ({
|
||||
const [showConfirmModal, setShowConfirmModal] = useState(false);
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
|
||||
const performSync = async (selections = { localOnlyIds: [], endpointDecisions: {} }, mode = 'sync') => {
|
||||
const performSync = async (selections = { localOnlyIds: [], endpointDecisions: {} }, mode = 'sync', preserveValues = true) => {
|
||||
setShowConfirmModal(false);
|
||||
setIsSyncing(true);
|
||||
setError(null);
|
||||
@@ -71,7 +71,8 @@ const useSyncFlow = ({
|
||||
localOnlyToRemove,
|
||||
driftedToReset,
|
||||
mode,
|
||||
endpointDecisions: decisions
|
||||
endpointDecisions: decisions,
|
||||
preserveValues
|
||||
});
|
||||
|
||||
setPendingSyncMode(null);
|
||||
@@ -102,7 +103,7 @@ const useSyncFlow = ({
|
||||
const handleApplySync = (selections) => {
|
||||
const mode = pendingSyncMode || 'sync';
|
||||
setPendingSyncMode(null);
|
||||
performSync(selections, mode);
|
||||
performSync(selections, mode, selections?.preserveValues ?? true);
|
||||
};
|
||||
|
||||
const cancelConfirmModal = () => {
|
||||
|
||||
@@ -131,16 +131,6 @@ const OpenAPISyncTab = ({ collection }) => {
|
||||
error={error}
|
||||
onOpenSettings={() => setShowSettingsModal(true)}
|
||||
/>
|
||||
<p className="beta-feedback-inline">
|
||||
OpenAPI Sync is in Beta — we'd love to hear your feedback and suggestions.{' '}
|
||||
<button
|
||||
type="button"
|
||||
className="beta-feedback-link"
|
||||
onClick={() => window?.ipcRenderer?.openExternal('https://github.com/usebruno/bruno/discussions/7401')}
|
||||
>
|
||||
Share feedback
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
334
packages/bruno-app/src/components/Preferences/AI/ProviderCard.js
Normal file
334
packages/bruno-app/src/components/Preferences/AI/ProviderCard.js
Normal file
@@ -0,0 +1,334 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
IconAlertCircle,
|
||||
IconBolt,
|
||||
IconCheck,
|
||||
IconChevronDown,
|
||||
IconEye,
|
||||
IconEyeOff,
|
||||
IconLoader2,
|
||||
IconPencil,
|
||||
IconTrash,
|
||||
IconX
|
||||
} from '@tabler/icons';
|
||||
import toast from 'react-hot-toast';
|
||||
import { clearAiApiKey, getAiApiKey, setAiApiKey, testAiProvider } from 'utils/ai';
|
||||
|
||||
const OpenAiLogo = (props) => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" {...props}>
|
||||
<path d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.8956zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const AnthropicLogo = (props) => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" {...props}>
|
||||
<path d="M17.304 3.541h-3.672l6.696 16.918h3.672l-6.696-16.918Zm-10.608 0L0 20.459h3.744l1.368-3.584h6.624l1.368 3.584h3.744L10.152 3.54H6.696Zm.432 10.418 2.208-5.784 2.208 5.784H7.128Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const PROVIDER_LOGOS = {
|
||||
openai: OpenAiLogo,
|
||||
anthropic: AnthropicLogo
|
||||
};
|
||||
|
||||
const stopBubble = (e) => e.stopPropagation();
|
||||
|
||||
const ProviderCard = ({
|
||||
provider,
|
||||
providerEnabled,
|
||||
providerToggle,
|
||||
models,
|
||||
isModelEnabled,
|
||||
onToggleModel,
|
||||
onStatusChange
|
||||
}) => {
|
||||
const Logo = PROVIDER_LOGOS[provider.id];
|
||||
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [keyDraft, setKeyDraft] = useState('');
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [feedback, setFeedback] = useState(null);
|
||||
|
||||
const prev = useRef({ enabled: providerEnabled });
|
||||
useEffect(() => {
|
||||
const was = prev.current;
|
||||
if (!was.enabled && providerEnabled) {
|
||||
setExpanded(true);
|
||||
} else if (was.enabled && !providerEnabled) {
|
||||
setExpanded(false);
|
||||
}
|
||||
prev.current = { enabled: providerEnabled };
|
||||
}, [providerEnabled]);
|
||||
|
||||
const isEditing = editing || !provider.configured;
|
||||
|
||||
const handleSave = async () => {
|
||||
const trimmed = keyDraft.trim();
|
||||
if (!trimmed) return;
|
||||
setSaving(true);
|
||||
setFeedback(null);
|
||||
try {
|
||||
const status = await setAiApiKey({ providerId: provider.id, apiKey: trimmed });
|
||||
onStatusChange?.(status);
|
||||
setKeyDraft('');
|
||||
setShowKey(false);
|
||||
setEditing(false);
|
||||
setFeedback({ type: 'success', message: 'API key saved' });
|
||||
} catch (err) {
|
||||
setFeedback({ type: 'error', message: err.message || 'Failed to save API key' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClear = async () => {
|
||||
setFeedback(null);
|
||||
try {
|
||||
const status = await clearAiApiKey({ providerId: provider.id });
|
||||
onStatusChange?.(status);
|
||||
setEditing(false);
|
||||
setKeyDraft('');
|
||||
toast.success(`${provider.label} API key removed`);
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Failed to clear API key');
|
||||
}
|
||||
};
|
||||
|
||||
const handleTest = async () => {
|
||||
setTesting(true);
|
||||
setFeedback(null);
|
||||
try {
|
||||
const result = await testAiProvider({ providerId: provider.id });
|
||||
if (result.ok) {
|
||||
setFeedback({ type: 'success', message: 'Connection successful' });
|
||||
} else {
|
||||
setFeedback({ type: 'error', message: result.error || 'Connection failed' });
|
||||
}
|
||||
} catch (err) {
|
||||
setFeedback({ type: 'error', message: err.message || 'Connection failed' });
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setEditing(false);
|
||||
setKeyDraft('');
|
||||
setShowKey(false);
|
||||
setFeedback(null);
|
||||
};
|
||||
|
||||
const handleStartEdit = async () => {
|
||||
setEditing(true);
|
||||
setFeedback(null);
|
||||
try {
|
||||
const current = await getAiApiKey({ providerId: provider.id });
|
||||
setKeyDraft(current || '');
|
||||
} catch (err) {
|
||||
// If we can't fetch it (decrypt failure etc.), leave the field empty.
|
||||
setKeyDraft('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (keyDraft.trim() && !saving) handleSave();
|
||||
} else if (e.key === 'Escape' && provider.configured) {
|
||||
e.preventDefault();
|
||||
handleCancelEdit();
|
||||
}
|
||||
};
|
||||
|
||||
const enabledModelsCount = models.filter((m) => isModelEnabled(m.id)).length;
|
||||
|
||||
return (
|
||||
<div className={`provider-row ${expanded ? 'expanded' : ''}`} data-testid={`ai-provider-${provider.id}`}>
|
||||
<div
|
||||
className="provider-header flex items-center justify-between gap-3 px-3 py-2.5 cursor-pointer select-none"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setExpanded(!expanded);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2.5 min-w-0 flex-1">
|
||||
{Logo ? <Logo className="provider-logo w-[18px] h-[18px] flex-shrink-0" /> : null}
|
||||
<span className="font-semibold text-[12.5px]">{provider.label}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5 flex-shrink-0">
|
||||
<span className={`provider-status inline-flex items-center gap-1.5 text-[11px] ${provider.configured ? 'configured' : ''}`}>
|
||||
<span className={`status-dot w-[7px] h-[7px] rounded-full ${provider.configured ? 'configured' : ''}`} />
|
||||
{provider.configured
|
||||
? `${enabledModelsCount}/${models.length} models`
|
||||
: 'Not configured'}
|
||||
</span>
|
||||
<span className="flex items-center" onClick={stopBubble}>
|
||||
{providerToggle}
|
||||
</span>
|
||||
<span className={`chevron flex items-center ${expanded ? 'expanded' : ''}`}>
|
||||
<IconChevronDown size={16} strokeWidth={1.5} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`provider-body-wrapper ${expanded ? 'open' : ''}`}>
|
||||
<div className="provider-body-inner">
|
||||
<div className="provider-body flex flex-col gap-3.5 px-3 pt-3 pb-3">
|
||||
{/* API key */}
|
||||
<div>
|
||||
<div className="key-section-label flex items-center justify-between gap-2 text-[11px] mb-1">
|
||||
<span>API Key</span>
|
||||
</div>
|
||||
|
||||
{!isEditing ? (
|
||||
<div
|
||||
className="key-display-row flex items-center justify-between gap-2 h-8 box-border pl-2.5 pr-0.5"
|
||||
onClick={stopBubble}
|
||||
>
|
||||
<span className="key-display-mask text-xs">••••••••••••••••</span>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon w-7 h-7 box-border inline-flex items-center justify-center cursor-pointer"
|
||||
onClick={handleTest}
|
||||
disabled={testing || !providerEnabled}
|
||||
title="Test connection"
|
||||
aria-label="Test connection"
|
||||
>
|
||||
{testing ? <IconLoader2 size={15} className="spin" /> : <IconBolt size={15} />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon w-7 h-7 box-border inline-flex items-center justify-center cursor-pointer"
|
||||
onClick={handleStartEdit}
|
||||
title="Replace key"
|
||||
aria-label="Replace key"
|
||||
>
|
||||
<IconPencil size={15} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon danger w-7 h-7 box-border inline-flex items-center justify-center cursor-pointer"
|
||||
onClick={handleClear}
|
||||
title="Remove key"
|
||||
aria-label="Remove key"
|
||||
>
|
||||
<IconTrash size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5" onClick={stopBubble}>
|
||||
<div className="relative flex-1 flex items-center">
|
||||
<input
|
||||
id={`api-key-${provider.id}`}
|
||||
type={showKey ? 'text' : 'password'}
|
||||
className="key-input w-full h-8 box-border text-xs leading-none pl-2.5 pr-8"
|
||||
placeholder={provider.apiKeyPlaceholder}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
value={keyDraft}
|
||||
onChange={(e) => setKeyDraft(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={stopBubble}
|
||||
autoFocus
|
||||
data-testid={`ai-provider-${provider.id}-key-input`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="key-eye-btn absolute right-1 p-1 inline-flex items-center cursor-pointer"
|
||||
onClick={() => setShowKey(!showKey)}
|
||||
tabIndex={-1}
|
||||
aria-label={showKey ? 'Hide API key' : 'Show API key'}
|
||||
>
|
||||
{showKey ? <IconEyeOff size={14} /> : <IconEye size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary h-8 box-border px-3 text-xs font-medium inline-flex items-center justify-center gap-1 cursor-pointer"
|
||||
disabled={saving || !keyDraft.trim()}
|
||||
onClick={handleSave}
|
||||
data-testid={`ai-provider-${provider.id}-save`}
|
||||
>
|
||||
{saving ? <IconLoader2 size={13} className="spin" /> : <IconCheck size={13} />}
|
||||
Save
|
||||
</button>
|
||||
{provider.configured && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon w-7 h-7 box-border inline-flex items-center justify-center cursor-pointer"
|
||||
onClick={handleCancelEdit}
|
||||
title="Cancel"
|
||||
>
|
||||
<IconX size={15} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{feedback && (
|
||||
<div
|
||||
className={`feedback flex items-center gap-1.5 text-[11px] px-2 py-1 mt-1.5 ${feedback.type}`}
|
||||
role="status"
|
||||
>
|
||||
{feedback.type === 'success' ? <IconCheck size={12} /> : <IconAlertCircle size={12} />}
|
||||
{feedback.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Models */}
|
||||
{models.length > 0 && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="models-label-row flex items-center justify-between text-[11px]">
|
||||
<span>Models</span>
|
||||
{!provider.configured && (
|
||||
<span className="keyless-hint flex items-center gap-1.5 text-[11px] py-1">
|
||||
<IconAlertCircle size={12} />
|
||||
Add an API key to enable
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
{models.map((model) => {
|
||||
const enabled = isModelEnabled(model.id);
|
||||
const disabled = !provider.configured || !providerEnabled;
|
||||
return (
|
||||
<label
|
||||
key={model.id}
|
||||
className={`model-chip flex items-center gap-2 px-2.5 py-1.5 cursor-pointer select-none ${enabled && !disabled ? 'selected' : ''} ${disabled ? 'disabled' : ''}`}
|
||||
onClick={stopBubble}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="cursor-pointer m-0"
|
||||
checked={enabled}
|
||||
disabled={disabled}
|
||||
onChange={() => onToggleModel(model.id, !enabled)}
|
||||
/>
|
||||
<span className="text-xs">{model.label}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProviderCard;
|
||||
@@ -0,0 +1,243 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
color: ${(props) => props.theme.text};
|
||||
|
||||
.ai-master {
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
border-radius: ${(props) => props.theme.border.radius.md};
|
||||
background: ${(props) => props.theme.input.bg};
|
||||
}
|
||||
|
||||
.ai-master-icon {
|
||||
color: ${(props) => props.theme.colors.accent};
|
||||
}
|
||||
|
||||
.ai-master-summary {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
.ai-section-header {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
.ai-empty-notice {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
background: ${(props) => props.theme.input.bg};
|
||||
border: 1px dashed ${(props) => props.theme.input.border};
|
||||
border-radius: ${(props) => props.theme.border.radius.md};
|
||||
}
|
||||
|
||||
.provider-row {
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
border-radius: ${(props) => props.theme.border.radius.md};
|
||||
background: ${(props) => props.theme.input.bg};
|
||||
overflow: hidden;
|
||||
transition: border-color 0.15s ease;
|
||||
|
||||
&.expanded {
|
||||
border-color: ${(props) => props.theme.colors.accent}80;
|
||||
}
|
||||
}
|
||||
|
||||
.provider-header {
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.colors.accent}08;
|
||||
}
|
||||
}
|
||||
|
||||
.provider-logo {
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
.provider-status {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
|
||||
&.configured {
|
||||
color: ${(props) => props.theme.colors.text.green};
|
||||
}
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
background: ${(props) => props.theme.input.border};
|
||||
|
||||
&.configured {
|
||||
background: ${(props) => props.theme.colors.text.green};
|
||||
box-shadow: 0 0 0 2px ${(props) => props.theme.colors.text.green}25;
|
||||
}
|
||||
}
|
||||
|
||||
.chevron {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
transition: transform 0.2s ease;
|
||||
|
||||
&.expanded {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Smooth expand/collapse using grid-template-rows trick */
|
||||
.provider-body-wrapper {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
transition: grid-template-rows 0.2s ease;
|
||||
|
||||
&.open {
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.provider-body-inner {
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.provider-body {
|
||||
border-top: 1px solid ${(props) => props.theme.input.border};
|
||||
}
|
||||
|
||||
.key-section-label {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
.key-input {
|
||||
font-family: ${(props) => props.theme.font.monospace || 'monospace'};
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
background-color: ${(props) => props.theme.input.bg};
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
color: ${(props) => props.theme.text};
|
||||
|
||||
&::placeholder {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: ${(props) => props.theme.input.focusBorder};
|
||||
}
|
||||
}
|
||||
|
||||
.key-eye-btn {
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => props.theme.text};
|
||||
background: ${(props) => props.theme.colors.accent}10;
|
||||
}
|
||||
}
|
||||
|
||||
.key-display-row {
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
background: ${(props) => props.theme.input.bg};
|
||||
}
|
||||
|
||||
.key-display-mask {
|
||||
font-family: ${(props) => props.theme.font.monospace || 'monospace'};
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
border: 1px solid ${(props) => props.theme.colors.accent};
|
||||
background: ${(props) => props.theme.colors.accent};
|
||||
color: white;
|
||||
transition: opacity 0.15s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: ${(props) => props.theme.colors.accent}10;
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
&.danger:hover:not(:disabled) {
|
||||
color: ${(props) => props.theme.colors.text.danger};
|
||||
background: ${(props) => props.theme.colors.bg.danger}15;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.feedback {
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
|
||||
&.success {
|
||||
color: ${(props) => props.theme.colors.text.green};
|
||||
background: ${(props) => props.theme.colors.text.green}10;
|
||||
}
|
||||
|
||||
&.error {
|
||||
color: ${(props) => props.theme.colors.text.danger};
|
||||
background: ${(props) => props.theme.colors.bg.danger}15;
|
||||
}
|
||||
}
|
||||
|
||||
.models-label-row {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
.model-chip {
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
border: 1px solid transparent;
|
||||
transition: background-color 0.15s ease, border-color 0.15s ease;
|
||||
|
||||
&:hover:not(.disabled) {
|
||||
background: ${(props) => props.theme.colors.accent}08;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
border-color: ${(props) => props.theme.input.border};
|
||||
background: ${(props) => props.theme.colors.accent}06;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
|
||||
input,
|
||||
label {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.keyless-hint {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
202
packages/bruno-app/src/components/Preferences/AI/index.js
Normal file
202
packages/bruno-app/src/components/Preferences/AI/index.js
Normal file
@@ -0,0 +1,202 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { useFormik } from 'formik';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import * as Yup from 'yup';
|
||||
import toast from 'react-hot-toast';
|
||||
import { IconStars } from '@tabler/icons';
|
||||
import { savePreferences } from 'providers/ReduxStore/slices/app';
|
||||
import ToggleSwitch from 'components/ToggleSwitch';
|
||||
import { getAiStatus } from 'utils/ai';
|
||||
import ProviderCard from './ProviderCard';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const aiPreferencesSchema = Yup.object().shape({
|
||||
enabled: Yup.boolean(),
|
||||
providers: Yup.object(),
|
||||
models: Yup.object(),
|
||||
defaultModel: Yup.string().max(200).nullable()
|
||||
});
|
||||
|
||||
const AI = () => {
|
||||
const dispatch = useDispatch();
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const [status, setStatus] = useState(null);
|
||||
const [statusError, setStatusError] = useState(null);
|
||||
|
||||
const refreshStatus = useCallback(async () => {
|
||||
try {
|
||||
const next = await getAiStatus();
|
||||
setStatus(next);
|
||||
setStatusError(null);
|
||||
} catch (err) {
|
||||
setStatusError(err.message || 'Failed to load AI status');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refreshStatus();
|
||||
}, [refreshStatus]);
|
||||
|
||||
const providerIds = status ? Object.keys(status.providers) : [];
|
||||
|
||||
const formik = useFormik({
|
||||
enableReinitialize: true,
|
||||
initialValues: {
|
||||
enabled: get(preferences, 'ai.enabled', false),
|
||||
providers: providerIds.reduce((acc, id) => {
|
||||
acc[id] = { enabled: get(preferences, `ai.providers.${id}.enabled`, false) };
|
||||
return acc;
|
||||
}, {}),
|
||||
models: get(preferences, 'ai.models', {}),
|
||||
defaultModel: get(preferences, 'ai.defaultModel', '')
|
||||
},
|
||||
validationSchema: aiPreferencesSchema,
|
||||
onSubmit: () => {}
|
||||
});
|
||||
|
||||
const handleSave = useCallback(
|
||||
(values) => {
|
||||
dispatch(
|
||||
savePreferences({
|
||||
...preferences,
|
||||
ai: {
|
||||
enabled: values.enabled,
|
||||
providers: values.providers,
|
||||
models: values.models,
|
||||
defaultModel: values.defaultModel || ''
|
||||
}
|
||||
})
|
||||
).catch((err) => {
|
||||
console.error('Failed to save AI preferences:', err);
|
||||
toast.error('Failed to save AI preferences');
|
||||
});
|
||||
},
|
||||
[dispatch, preferences]
|
||||
);
|
||||
|
||||
const handleSaveRef = useRef(handleSave);
|
||||
handleSaveRef.current = handleSave;
|
||||
|
||||
const debouncedSave = useCallback(
|
||||
debounce((values) => {
|
||||
aiPreferencesSchema
|
||||
.validate(values, { abortEarly: true })
|
||||
.then((validated) => handleSaveRef.current(validated))
|
||||
.catch(() => {});
|
||||
}, 400),
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (formik.dirty && formik.isValid) {
|
||||
debouncedSave(formik.values);
|
||||
}
|
||||
}, [formik.values, formik.dirty, formik.isValid, debouncedSave]);
|
||||
|
||||
useEffect(() => () => debouncedSave.flush(), [debouncedSave]);
|
||||
|
||||
const modelsByProvider = useMemo(() => {
|
||||
const grouped = {};
|
||||
(status?.models || []).forEach((model) => {
|
||||
if (!grouped[model.provider]) grouped[model.provider] = [];
|
||||
grouped[model.provider].push(model);
|
||||
});
|
||||
return grouped;
|
||||
}, [status]);
|
||||
|
||||
const isModelEnabled = (modelId) => get(formik.values, `models.${modelId}.enabled`, true);
|
||||
|
||||
const handleToggleModel = (modelId, next) => {
|
||||
formik.setFieldValue(`models.${modelId}.enabled`, next);
|
||||
};
|
||||
|
||||
const summary = useMemo(() => {
|
||||
if (!status || !formik.values.enabled) return 'Turn on to configure providers and models';
|
||||
const usableProviders = Object.values(status.providers).filter(
|
||||
(p) => p.configured && formik.values.providers?.[p.id]?.enabled
|
||||
);
|
||||
if (usableProviders.length === 0) return 'Add a provider to get started';
|
||||
// Count models live from formik + current key status, not the electron-side
|
||||
// snapshot which lags behind toggle changes during the save debounce window.
|
||||
const totalEnabledModels = (status.models || []).filter((m) => {
|
||||
if (!formik.values.providers?.[m.provider]?.enabled) return false;
|
||||
if (!status.providers?.[m.provider]?.configured) return false;
|
||||
return isModelEnabled(m.id);
|
||||
}).length;
|
||||
const plural = (n, s) => `${n} ${s}${n === 1 ? '' : 's'}`;
|
||||
return `${plural(usableProviders.length, 'provider')} · ${plural(totalEnabledModels, 'model')} ready`;
|
||||
}, [status, formik.values.enabled, formik.values.providers, formik.values.models]);
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full flex flex-col text-xs min-h-0 max-h-[calc(100%-30px)]">
|
||||
<div className="section-header">AI</div>
|
||||
|
||||
<div className="ai-master flex items-center justify-between gap-4 px-3.5 py-3 mb-4">
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<div className="flex items-center gap-2 text-[13px] font-semibold">
|
||||
<IconStars size={15} strokeWidth={1.75} className="ai-master-icon" />
|
||||
<span>AI Features</span>
|
||||
</div>
|
||||
<span className="ai-master-summary text-[11px]">{summary}</span>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
size="m"
|
||||
isOn={formik.values.enabled}
|
||||
handleToggle={() => formik.setFieldValue('enabled', !formik.values.enabled)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{statusError && (
|
||||
<div className="ai-empty-notice px-3.5 py-3 text-xs" role="alert">
|
||||
{statusError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!formik.values.enabled && !statusError && (
|
||||
<div className="ai-empty-notice px-3.5 py-3 text-xs">
|
||||
Bring your own API key. Bruno talks to providers directly, your keys never leave your machine.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formik.values.enabled && status && (
|
||||
<>
|
||||
<div className="ai-section-header text-[11px] font-medium uppercase tracking-wider mt-[18px] mb-2">
|
||||
Providers
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{providerIds.map((id) => {
|
||||
const provider = status.providers[id];
|
||||
const providerEnabled = get(formik.values, `providers.${id}.enabled`, false);
|
||||
|
||||
const providerToggle = (
|
||||
<ToggleSwitch
|
||||
size="s"
|
||||
isOn={providerEnabled}
|
||||
handleToggle={() =>
|
||||
formik.setFieldValue(`providers.${id}.enabled`, !providerEnabled)}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<ProviderCard
|
||||
key={id}
|
||||
provider={provider}
|
||||
providerEnabled={providerEnabled}
|
||||
providerToggle={providerToggle}
|
||||
models={modelsByProvider[id] || []}
|
||||
isModelEnabled={isModelEnabled}
|
||||
onToggleModel={handleToggleModel}
|
||||
onStatusChange={(next) => setStatus(next)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default AI;
|
||||
@@ -6,20 +6,38 @@ import StyledWrapper from './StyledWrapper';
|
||||
import * as Yup from 'yup';
|
||||
import debounce from 'lodash/debounce';
|
||||
import toast from 'react-hot-toast';
|
||||
import { IconFlask } from '@tabler/icons';
|
||||
import get from 'lodash/get';
|
||||
import { BETA_FEATURES as BETA_FEATURE_IDS } from 'utils/beta-features';
|
||||
// Commented out while there are no active beta features. Re-enable this import when
|
||||
// adding a beta feature its keys are then referenced as BETA_FEATURE_IDS.MY_FEATURE in the BETA_FEATURES array.
|
||||
// import { BETA_FEATURES as BETA_FEATURE_IDS } from 'utils/beta-features';
|
||||
|
||||
/**
|
||||
* UI metadata for beta features rendered in Preferences.
|
||||
* IDs must match keys from utils/beta-features.js BETA_FEATURES.
|
||||
* UI metadata for the Beta Features section in Preferences — one entry per toggle.
|
||||
* The whole tab is data-driven from this array: the form fields, validation schema,
|
||||
* initial values and the rendered checkboxes are all generated from it.
|
||||
*
|
||||
* Each entry has the shape { id, label, description }:
|
||||
* - id (required) the feature key. MUST be a value from BETA_FEATURES in
|
||||
* utils/beta-features.js (imported here as BETA_FEATURE_IDS). It is
|
||||
* used as the preference key (preferences.beta[id]), the form field
|
||||
* name and the checkbox id, so it must be stable and unique.
|
||||
* - label (required) short name shown next to the checkbox.
|
||||
* - description (required) one-line explanation shown under the label.
|
||||
*
|
||||
* To add a beta feature:
|
||||
* 1. Add its key to BETA_FEATURES in utils/beta-features.js (e.g. MY_FEATURE: 'my-feature').
|
||||
* 2. Add an entry to the array below using BETA_FEATURE_IDS.MY_FEATURE.
|
||||
* 3. Gate the feature in code with useBetaFeature(BETA_FEATURES.MY_FEATURE).
|
||||
*
|
||||
* When the array is empty, the Beta tab shows "No beta features are currently available",
|
||||
* so a feature can be hidden by simply removing or commenting out its entry.
|
||||
*/
|
||||
const BETA_FEATURES = [
|
||||
{
|
||||
id: BETA_FEATURE_IDS.OPENAPI_SYNC,
|
||||
label: 'OpenAPI Sync',
|
||||
description: 'Synchronize your Bruno collection with an OpenAPI specification. Detect drift, review changes, and sync with a single click.'
|
||||
}
|
||||
// {
|
||||
// id: BETA_FEATURE_IDS.OPENAPI_SYNC,
|
||||
// label: 'OpenAPI Sync',
|
||||
// description: 'Synchronize your Bruno collection with an OpenAPI specification. Detect drift, review changes, and sync with a single click.'
|
||||
// }
|
||||
];
|
||||
|
||||
const Beta = ({ close }) => {
|
||||
|
||||
@@ -7,7 +7,7 @@ import StyledWrapper from '../StyledWrapper';
|
||||
const SystemProxy = () => {
|
||||
const dispatch = useDispatch();
|
||||
const systemProxyVariables = useSelector((state) => state.app.systemProxyVariables);
|
||||
const { source, http_proxy, https_proxy, no_proxy } = systemProxyVariables || {};
|
||||
const { source, http_proxy, https_proxy, no_proxy, pac_url } = systemProxyVariables || {};
|
||||
const [isFetching, setIsFetching] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
@@ -85,6 +85,12 @@ const SystemProxy = () => {
|
||||
</label>
|
||||
<div className="system-proxy-value">{no_proxy || '-'}</div>
|
||||
</div>
|
||||
<div className="mb-1 flex items-center">
|
||||
<label className="settings-label">
|
||||
pac_url
|
||||
</label>
|
||||
<div className="system-proxy-value">{pac_url || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className="text-link cursor-pointer hover:underline default-collection-location-browse flex flex-row items-center"
|
||||
|
||||
@@ -3,11 +3,11 @@ import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import debounce from 'lodash/debounce';
|
||||
import toast from 'react-hot-toast';
|
||||
import { savePreferences } from 'providers/ReduxStore/slices/app';
|
||||
import { savePreferences, refreshPacCache } from 'providers/ReduxStore/slices/app';
|
||||
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { IconEye, IconEyeOff } from '@tabler/icons';
|
||||
import { IconEye, IconEyeOff, IconRefresh } from '@tabler/icons';
|
||||
import { useState } from 'react';
|
||||
import SystemProxy from './SystemProxy';
|
||||
|
||||
@@ -103,6 +103,12 @@ const ProxySettings = ({ close }) => {
|
||||
[]
|
||||
);
|
||||
|
||||
const handleRefreshPac = () => {
|
||||
dispatch(refreshPacCache())
|
||||
.then(() => toast.success('PAC cache refreshed'))
|
||||
.catch(() => toast.error('Failed to refresh PAC cache'));
|
||||
};
|
||||
|
||||
const [passwordVisible, setPasswordVisible] = useState(false);
|
||||
const [proxyMode, setProxyMode] = useState(() => {
|
||||
if (preferences.proxy.disabled) return 'off';
|
||||
@@ -451,6 +457,15 @@ const ProxySettings = ({ close }) => {
|
||||
? 'Enter the URL to your PAC file'
|
||||
: 'Supports .pac files for automatic proxy configuration'}
|
||||
</p>
|
||||
{formik.values.pac.source ? (
|
||||
<span
|
||||
className="text-link cursor-pointer hover:underline flex flex-row items-center w-fit mt-2"
|
||||
onClick={handleRefreshPac}
|
||||
>
|
||||
<IconRefresh size={14} strokeWidth={1.5} className="mr-1" />
|
||||
Refetch
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
IconKeyboard,
|
||||
IconZoomQuestion,
|
||||
IconSquareLetterB,
|
||||
IconDatabase
|
||||
IconDatabase,
|
||||
IconStars
|
||||
} from '@tabler/icons';
|
||||
|
||||
import Support from './Support';
|
||||
@@ -20,6 +21,7 @@ import Proxy from './ProxySettings';
|
||||
import Display from './Display';
|
||||
import Keybindings from './Keybindings';
|
||||
import Beta from './Beta';
|
||||
import AI from './AI';
|
||||
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import Cache from './Cache/index';
|
||||
@@ -64,6 +66,10 @@ const Preferences = () => {
|
||||
return <Beta />;
|
||||
}
|
||||
|
||||
case 'ai': {
|
||||
return <AI />;
|
||||
}
|
||||
|
||||
case 'support': {
|
||||
return <Support />;
|
||||
}
|
||||
@@ -98,6 +104,10 @@ const Preferences = () => {
|
||||
<IconKeyboard size={16} strokeWidth={1.5} />
|
||||
Keybindings
|
||||
</div>
|
||||
<div className={getTabClassname('ai')} role="tab" onClick={() => setTab('ai')}>
|
||||
<IconStars size={16} strokeWidth={1.5} />
|
||||
AI
|
||||
</div>
|
||||
<div className={getTabClassname('cache')} role="tab" onClick={() => setTab('cache')}>
|
||||
<IconDatabase size={16} strokeWidth={1.5} />
|
||||
Cache
|
||||
|
||||
@@ -81,14 +81,15 @@ const AuthMode = ({ item, collection }) => {
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="inline-flex items-center cursor-pointer auth-mode-selector">
|
||||
<div className="inline-flex items-center cursor-pointer auth-mode-selector" data-testid="auth-mode-selector">
|
||||
<MenuDropdown
|
||||
items={menuItems}
|
||||
placement="bottom-end"
|
||||
selectedItemId={authMode}
|
||||
showTickMark={true}
|
||||
data-testid="auth-mode-dropdown"
|
||||
>
|
||||
<div className="flex items-center justify-center auth-mode-label select-none">
|
||||
<div className="flex items-center justify-center auth-mode-label select-none" data-testid="auth-mode-label">
|
||||
{humanizeRequestAuthMode(authMode)} <IconCaretDown className="caret ml-1" size={14} strokeWidth={2} />
|
||||
</div>
|
||||
</MenuDropdown>
|
||||
|
||||
@@ -295,7 +295,7 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu
|
||||
{
|
||||
tokenPlacement === 'header'
|
||||
? (
|
||||
<div className="flex items-center gap-4 w-full" key="input-token-prefix">
|
||||
<div className="flex items-center gap-4 w-full" key="input-token-prefix" data-testid="token-header-prefix">
|
||||
<label className="block min-w-[140px]">Header Prefix</label>
|
||||
<div className="single-line-editor-wrapper flex-1">
|
||||
<SingleLineEditor
|
||||
@@ -311,7 +311,7 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className="flex items-center gap-4 w-full" key="input-token-query-param-key">
|
||||
<div className="flex items-center gap-4 w-full" key="input-token-query-param-key" data-testid="token-query-param-key">
|
||||
<label className="block min-w-[140px]">Query Param Key</label>
|
||||
<div className="single-line-editor-wrapper flex-1">
|
||||
<SingleLineEditor
|
||||
|
||||
@@ -185,7 +185,7 @@ const OAuth2ClientCredentials = ({ save, item = {}, request, handleRun, updateAu
|
||||
{
|
||||
tokenPlacement === 'header'
|
||||
? (
|
||||
<div className="flex items-center gap-4 w-full" key="input-token-prefix">
|
||||
<div className="flex items-center gap-4 w-full" key="input-token-prefix" data-testid="token-header-prefix">
|
||||
<label className="block min-w-[140px]">Header Prefix</label>
|
||||
<div className="single-line-editor-wrapper flex-1">
|
||||
<SingleLineEditor
|
||||
@@ -201,7 +201,7 @@ const OAuth2ClientCredentials = ({ save, item = {}, request, handleRun, updateAu
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className="flex items-center gap-4 w-full" key="input-token-query-param-key">
|
||||
<div className="flex items-center gap-4 w-full" key="input-token-query-param-key" data-testid="token-query-param-key">
|
||||
<label className="block min-w-[140px]">Query Param Key</label>
|
||||
<div className="single-line-editor-wrapper flex-1">
|
||||
<SingleLineEditor
|
||||
|
||||
@@ -80,6 +80,7 @@ const GrantTypeSelector = ({ item = {}, request, updateAuth, collection }) => {
|
||||
{ id: 'implicit', label: 'Implicit', onClick: () => onGrantTypeChange('implicit') },
|
||||
{ id: 'client_credentials', label: 'Client Credentials', onClick: () => onGrantTypeChange('client_credentials') }
|
||||
]}
|
||||
data-testid="grant-type-dropdown"
|
||||
selectedItemId={oAuth?.grantType}
|
||||
placement="bottom-end"
|
||||
>
|
||||
|
||||
@@ -229,7 +229,7 @@ const OAuth2Implicit = ({ save, item = {}, request, handleRun, updateAuth, colle
|
||||
</div>
|
||||
|
||||
{tokenPlacement == 'header' ? (
|
||||
<div className="flex items-center gap-4 w-full" key="input-token-header-prefix">
|
||||
<div className="flex items-center gap-4 w-full" key="input-token-header-prefix" data-testid="token-header-prefix">
|
||||
<label className="block min-w-[140px]">Header Prefix</label>
|
||||
<div className="oauth2-input-wrapper flex-1">
|
||||
<SingleLineEditor
|
||||
@@ -245,7 +245,7 @@ const OAuth2Implicit = ({ save, item = {}, request, handleRun, updateAuth, colle
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-4 w-full" key="input-token-query-key">
|
||||
<div className="flex items-center gap-4 w-full" key="input-token-query-key" data-testid="token-query-param-key">
|
||||
<label className="block min-w-[140px]">URL Query Key</label>
|
||||
<div className="oauth2-input-wrapper flex-1">
|
||||
<SingleLineEditor
|
||||
|
||||
@@ -189,7 +189,7 @@ const OAuth2PasswordCredentials = ({ save, item = {}, request, handleRun, update
|
||||
{
|
||||
tokenPlacement === 'header'
|
||||
? (
|
||||
<div className="flex items-center gap-4 w-full" key="input-token-prefix">
|
||||
<div className="flex items-center gap-4 w-full" key="input-token-prefix" data-testid="token-header-prefix">
|
||||
<label className="block min-w-[140px]">Header Prefix</label>
|
||||
<div className="single-line-editor-wrapper flex-1">
|
||||
<SingleLineEditor
|
||||
@@ -205,7 +205,7 @@ const OAuth2PasswordCredentials = ({ save, item = {}, request, handleRun, update
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className="flex items-center gap-4 w-full" key="input-token-query-param-key">
|
||||
<div className="flex items-center gap-4 w-full" key="input-token-query-param-key" data-testid="token-query-param-key">
|
||||
<label className="block min-w-[140px]">Query Param Key</label>
|
||||
<div className="single-line-editor-wrapper flex-1">
|
||||
<SingleLineEditor
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import AwsV4Auth from './AwsV4Auth';
|
||||
import BearerAuth from './BearerAuth';
|
||||
@@ -15,22 +15,11 @@ import ApiKeyAuth from './ApiKeyAuth';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { humanizeRequestAuthMode } from 'utils/collections';
|
||||
import OAuth2 from './OAuth2/index';
|
||||
import { findItemInCollection, findParentItemInCollection } from 'utils/collections/index';
|
||||
|
||||
const getTreePathFromCollectionToItem = (collection, _item) => {
|
||||
let path = [];
|
||||
let item = findItemInCollection(collection, _item?.uid);
|
||||
while (item) {
|
||||
path.unshift(item);
|
||||
item = findParentItemInCollection(collection, item?.uid);
|
||||
}
|
||||
return path;
|
||||
};
|
||||
import { getEffectiveAuthSource } from 'utils/auth';
|
||||
|
||||
const Auth = ({ item, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const authMode = item.draft ? get(item, 'draft.request.auth.mode') : get(item, 'request.auth.mode');
|
||||
const requestTreePath = getTreePathFromCollectionToItem(collection, item);
|
||||
|
||||
// Create a request object to pass to the auth components
|
||||
const request = item.draft
|
||||
@@ -42,34 +31,10 @@ const Auth = ({ item, collection }) => {
|
||||
return dispatch(saveRequest(item.uid, collection.uid));
|
||||
};
|
||||
|
||||
const getEffectiveAuthSource = () => {
|
||||
if (authMode !== 'inherit') return null;
|
||||
|
||||
const collectionRoot = collection?.draft?.root || collection?.root || {};
|
||||
const collectionAuth = get(collectionRoot, 'request.auth');
|
||||
let effectiveSource = {
|
||||
type: 'collection',
|
||||
name: 'Collection',
|
||||
auth: collectionAuth
|
||||
};
|
||||
|
||||
// Check folders in reverse to find the closest auth configuration
|
||||
for (let i of [...requestTreePath].reverse()) {
|
||||
if (i.type === 'folder') {
|
||||
const folderAuth = get(i, 'root.request.auth');
|
||||
if (folderAuth && folderAuth.mode && folderAuth.mode !== 'inherit') {
|
||||
effectiveSource = {
|
||||
type: 'folder',
|
||||
name: i.name,
|
||||
auth: folderAuth
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return effectiveSource;
|
||||
};
|
||||
const inheritedSource = useMemo(
|
||||
() => (authMode === 'inherit' ? getEffectiveAuthSource(collection, item) : null),
|
||||
[authMode, item, collection]
|
||||
);
|
||||
|
||||
const getAuthView = () => {
|
||||
switch (authMode) {
|
||||
@@ -104,12 +69,11 @@ const Auth = ({ item, collection }) => {
|
||||
return <ApiKeyAuth collection={collection} item={item} request={request} save={save} updateAuth={updateAuth} />;
|
||||
}
|
||||
case 'inherit': {
|
||||
const source = getEffectiveAuthSource();
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-row w-full gap-2">
|
||||
<div>Auth inherited from {source.name}: </div>
|
||||
<div className="inherit-mode-text">{humanizeRequestAuthMode(source.auth?.mode)}</div>
|
||||
<div>Auth inherited from {inheritedSource.name}: </div>
|
||||
<div className="inherit-mode-text" data-testid="inherited-auth-mode">{humanizeRequestAuthMode(inheritedSource.auth?.mode)}</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -24,10 +24,12 @@ import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collection
|
||||
import Documentation from 'components/Documentation/index';
|
||||
import useGraphqlSchema from '../GraphQLSchemaActions/useGraphqlSchema';
|
||||
import { findEnvironmentInCollection } from 'utils/collections';
|
||||
import { hasEffectiveAuth } from 'utils/auth';
|
||||
import HeightBoundContainer from 'ui/HeightBoundContainer';
|
||||
import Settings from 'components/RequestPane/Settings';
|
||||
import ResponsiveTabs from 'ui/ResponsiveTabs';
|
||||
import AuthMode from '../Auth/AuthMode/index';
|
||||
import StatusDot from 'components/StatusDot';
|
||||
|
||||
const TAB_CONFIG = [
|
||||
{ key: 'query', label: 'Query' },
|
||||
@@ -172,7 +174,20 @@ const GraphQLRequestPane = ({ item, collection, onSchemaLoad, toggleDocs, handle
|
||||
[dispatch, item.uid]
|
||||
);
|
||||
|
||||
const allTabs = useMemo(() => TAB_CONFIG.map(({ key, label }) => ({ key, label })), []);
|
||||
const itemAuthMode = item.draft?.request?.auth?.mode ?? item.request?.auth?.mode ?? item.root?.request?.auth?.mode;
|
||||
const hasAuth = useMemo(
|
||||
() => hasEffectiveAuth(collection, item),
|
||||
[item, itemAuthMode, collection]
|
||||
);
|
||||
|
||||
const allTabs = useMemo(
|
||||
() => TAB_CONFIG.map(({ key, label }) => ({
|
||||
key,
|
||||
label,
|
||||
indicator: key === 'auth' && hasAuth ? <StatusDot dataTestId="auth" /> : null
|
||||
})),
|
||||
[hasAuth]
|
||||
);
|
||||
|
||||
const handlePrettify = useCallback(() => {
|
||||
if (queryEditorRef.current?.beautifyRequestBody) {
|
||||
|
||||
@@ -39,7 +39,7 @@ const MessageToolbar = ({
|
||||
</ToolHint>
|
||||
|
||||
<ToolHint text="Generate sample" toolhintId={`regenerate-msg-${index}`}>
|
||||
<button onClick={onRegenerateMessage} className="toolbar-btn">
|
||||
<button onClick={onRegenerateMessage} className="toolbar-btn" data-testid={`grpc-regenerate-message-${index}`}>
|
||||
<IconRefresh size={16} strokeWidth={1.5} />
|
||||
</button>
|
||||
</ToolHint>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import GrpcAuthMode from './GrpcAuthMode';
|
||||
@@ -9,32 +9,32 @@ import OAuth2 from '../../Auth/OAuth2/index';
|
||||
import WsseAuth from '../../Auth/WsseAuth';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { humanizeRequestAuthMode } from 'utils/collections';
|
||||
import { getTreePathFromCollectionToItem } from 'utils/collections/index';
|
||||
import { getEffectiveAuthSource } from 'utils/auth';
|
||||
import { updateRequestAuthMode, updateAuth } from 'providers/ReduxStore/slices/collections';
|
||||
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
|
||||
// List of auth modes supported by gRPC
|
||||
// Note: Only header-based auth modes work with gRPC
|
||||
// Complex auth modes like AWS Sig v4, Digest, and NTLM require axios interceptors
|
||||
// and cannot be supported in gRPC requests as of now
|
||||
const supportedGrpcAuthModes = ['basic', 'bearer', 'apikey', 'oauth2', 'wsse', 'none', 'inherit'];
|
||||
import { AUTH_MODES_GRPC } from 'utils/common/constants';
|
||||
|
||||
const GrpcAuth = ({ item, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const authMode = item.draft ? get(item, 'draft.request.auth.mode') : get(item, 'request.auth.mode');
|
||||
const requestTreePath = getTreePathFromCollectionToItem(collection, item);
|
||||
|
||||
const request = item.draft
|
||||
? get(item, 'draft.request', {})
|
||||
: get(item, 'request', {});
|
||||
|
||||
const inheritedSource = useMemo(
|
||||
() => (authMode === 'inherit' ? getEffectiveAuthSource(collection, item) : null),
|
||||
[authMode, item, collection]
|
||||
);
|
||||
|
||||
const save = () => {
|
||||
return saveRequest(item.uid, collection.uid);
|
||||
};
|
||||
|
||||
// Reset to 'none' if current auth mode is not supported by gRPC
|
||||
useEffect(() => {
|
||||
if (authMode && !supportedGrpcAuthModes.includes(authMode)) {
|
||||
if (authMode && !AUTH_MODES_GRPC.includes(authMode)) {
|
||||
dispatch(
|
||||
updateRequestAuthMode({
|
||||
itemUid: item.uid,
|
||||
@@ -45,35 +45,6 @@ const GrpcAuth = ({ item, collection }) => {
|
||||
}
|
||||
}, [authMode, collection.uid, dispatch, item.uid]);
|
||||
|
||||
const getEffectiveAuthSource = () => {
|
||||
if (authMode !== 'inherit') return null;
|
||||
|
||||
const collectionRoot = collection?.draft?.root || collection?.root || {};
|
||||
const collectionAuth = get(collectionRoot, 'request.auth');
|
||||
let effectiveSource = {
|
||||
type: 'collection',
|
||||
name: 'Collection',
|
||||
auth: collectionAuth
|
||||
};
|
||||
|
||||
// Check folders in reverse to find the closest auth configuration
|
||||
for (let i of [...requestTreePath].reverse()) {
|
||||
if (i.type === 'folder') {
|
||||
const folderAuth = get(i, 'root.request.auth');
|
||||
if (folderAuth && folderAuth.mode && folderAuth.mode !== 'none' && folderAuth.mode !== 'inherit') {
|
||||
effectiveSource = {
|
||||
type: 'folder',
|
||||
name: i.name,
|
||||
auth: folderAuth
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return effectiveSource;
|
||||
};
|
||||
|
||||
const getAuthView = () => {
|
||||
switch (authMode) {
|
||||
case 'none': {
|
||||
@@ -95,15 +66,13 @@ const GrpcAuth = ({ item, collection }) => {
|
||||
return <WsseAuth collection={collection} item={item} updateAuth={updateAuth} request={request} save={save} />;
|
||||
}
|
||||
case 'inherit': {
|
||||
const source = getEffectiveAuthSource();
|
||||
|
||||
// Only show inherited auth if it's one of the supported types
|
||||
if (source && supportedGrpcAuthModes.includes(source.auth?.mode)) {
|
||||
if (inheritedSource && AUTH_MODES_GRPC.includes(inheritedSource.auth?.mode)) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-row w-full gap-2">
|
||||
<div>Auth inherited from {source.name}: </div>
|
||||
<div className="inherit-mode-text">{humanizeRequestAuthMode(source.auth?.mode)}</div>
|
||||
<div>Auth inherited from {inheritedSource.name}: </div>
|
||||
<div className="inherit-mode-text">{humanizeRequestAuthMode(inheritedSource.auth?.mode)}</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -12,6 +12,8 @@ import Documentation from 'components/Documentation/index';
|
||||
import { getPropertyFromDraftOrRequest } from 'utils/collections/index';
|
||||
import ResponsiveTabs from 'ui/ResponsiveTabs';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { hasEffectiveAuth } from 'utils/auth';
|
||||
import { AUTH_MODES_GRPC } from 'utils/common/constants';
|
||||
|
||||
const GrpcRequestPane = ({ item, collection, handleRun }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -53,8 +55,11 @@ const GrpcRequestPane = ({ item, collection, handleRun }) => {
|
||||
const body = getPropertyFromDraftOrRequest(item, 'request.body');
|
||||
const headers = getPropertyFromDraftOrRequest(item, 'request.headers');
|
||||
const docs = getPropertyFromDraftOrRequest(item, 'request.docs');
|
||||
const auth = getPropertyFromDraftOrRequest(item, 'request.auth');
|
||||
|
||||
const itemAuthMode = item.draft?.request?.auth?.mode ?? item.request?.auth?.mode ?? item.root?.request?.auth?.mode;
|
||||
const hasAuth = useMemo(
|
||||
() => hasEffectiveAuth(collection, item, AUTH_MODES_GRPC),
|
||||
[item, itemAuthMode, collection]
|
||||
);
|
||||
const activeHeadersLength = headers.filter((header) => header.enabled).length;
|
||||
const grpcMessagesCount = body?.grpc?.length || 0;
|
||||
|
||||
@@ -88,7 +93,7 @@ const GrpcRequestPane = ({ item, collection, handleRun }) => {
|
||||
{
|
||||
key: 'auth',
|
||||
label: 'Auth',
|
||||
indicator: auth?.mode && auth.mode !== 'none' ? <StatusDot type="default" /> : null
|
||||
indicator: hasAuth ? <StatusDot type="default" dataTestId="auth" /> : null
|
||||
},
|
||||
{
|
||||
key: 'docs',
|
||||
@@ -96,7 +101,7 @@ const GrpcRequestPane = ({ item, collection, handleRun }) => {
|
||||
indicator: docs && docs.length > 0 ? <StatusDot type="default" /> : null
|
||||
}
|
||||
];
|
||||
}, [grpcMessagesCount, isClientStreaming, activeHeadersLength, auth?.mode, docs]);
|
||||
}, [grpcMessagesCount, isClientStreaming, activeHeadersLength, hasAuth, docs]);
|
||||
|
||||
// Initialize tab to 'body' if no tab is currently set
|
||||
useEffect(() => {
|
||||
|
||||
@@ -18,6 +18,7 @@ import StatusDot from 'components/StatusDot';
|
||||
import ResponsiveTabs from 'ui/ResponsiveTabs';
|
||||
import HeightBoundContainer from 'ui/HeightBoundContainer';
|
||||
import AuthMode from '../Auth/AuthMode/index';
|
||||
import { hasEffectiveAuth } from 'utils/auth';
|
||||
|
||||
const TAB_CONFIG = [
|
||||
{ key: 'params', label: 'Params' },
|
||||
@@ -54,7 +55,6 @@ const HttpRequestPane = ({ item, collection }) => {
|
||||
|
||||
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
|
||||
const requestPaneTab = focusedTab?.requestPaneTab;
|
||||
|
||||
const getProperty = useCallback(
|
||||
(key) => (item.draft ? get(item, `draft.${key}`, []) : get(item, key, [])),
|
||||
[item.draft, item]
|
||||
@@ -86,6 +86,12 @@ const HttpRequestPane = ({ item, collection }) => {
|
||||
[dispatch, item.uid]
|
||||
);
|
||||
|
||||
const itemAuthMode = item.draft?.request?.auth?.mode ?? item.request?.auth?.mode ?? item.root?.request?.auth?.mode;
|
||||
const hasAuth = useMemo(
|
||||
() => hasEffectiveAuth(collection, item),
|
||||
[item, itemAuthMode, collection]
|
||||
);
|
||||
|
||||
const indicators = useMemo(() => {
|
||||
const hasScriptError = item.preRequestScriptErrorMessage || item.postResponseScriptErrorMessage;
|
||||
const hasTestError = item.testScriptErrorMessage;
|
||||
@@ -94,7 +100,7 @@ const HttpRequestPane = ({ item, collection }) => {
|
||||
params: activeCounts.params > 0 ? <sup className="font-medium">{activeCounts.params}</sup> : null,
|
||||
body: body.mode !== 'none' ? <StatusDot /> : null,
|
||||
headers: activeCounts.headers > 0 ? <sup className="font-medium">{activeCounts.headers}</sup> : null,
|
||||
auth: auth.mode !== 'none' ? <StatusDot /> : null,
|
||||
auth: hasAuth ? <StatusDot dataTestId="auth" /> : null,
|
||||
vars: activeCounts.vars > 0 ? <sup className="font-medium">{activeCounts.vars}</sup> : null,
|
||||
script: (script.req || script.res) ? (hasScriptError ? <StatusDot type="error" /> : <StatusDot />) : null,
|
||||
assert: activeCounts.assertions > 0 ? <sup className="font-medium">{activeCounts.assertions}</sup> : null,
|
||||
@@ -102,7 +108,7 @@ const HttpRequestPane = ({ item, collection }) => {
|
||||
docs: docs?.length > 0 ? <StatusDot /> : null,
|
||||
settings: tags?.length > 0 ? <StatusDot /> : null
|
||||
};
|
||||
}, [activeCounts, body.mode, auth.mode, script, item.preRequestScriptErrorMessage, item.postResponseScriptErrorMessage, item.testScriptErrorMessage, tests, docs, tags]);
|
||||
}, [activeCounts, body.mode, hasAuth, script, item.preRequestScriptErrorMessage, item.postResponseScriptErrorMessage, item.testScriptErrorMessage, tests, docs, tags]);
|
||||
|
||||
const allTabs = useMemo(
|
||||
() => TAB_CONFIG.map(({ key, label }) => ({ key, label, indicator: indicators[key] })),
|
||||
|
||||
@@ -13,6 +13,7 @@ const Wrapper = styled.div`
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: color 0.15s ease;
|
||||
flex: 0 0 auto;
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => props.theme.text};
|
||||
@@ -23,15 +24,6 @@ const Wrapper = styled.div`
|
||||
color: ${(props) => props.theme.colors.text.danger};
|
||||
}
|
||||
|
||||
.file-value-cell {
|
||||
width: 100%;
|
||||
|
||||
.file-name {
|
||||
font-size: 12px;
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
}
|
||||
|
||||
.value-cell {
|
||||
width: 100%;
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { useCallback, useRef } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { IconUpload, IconX, IconFile } from '@tabler/icons';
|
||||
import { IconUpload } from '@tabler/icons';
|
||||
import {
|
||||
moveMultipartFormParam,
|
||||
setMultipartFormParams
|
||||
@@ -10,14 +11,18 @@ import {
|
||||
import { browseFiles } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import MultiLineEditor from 'components/MultiLineEditor';
|
||||
import SingleLineEditor from 'components/SingleLineEditor';
|
||||
import MultipartFileChipsCell from 'components/MultipartFileChipsCell';
|
||||
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 { getRelativePathWithinBasePath } from 'utils/common/path';
|
||||
import path, { getRelativePathWithinBasePath, normalizePath } from 'utils/common/path';
|
||||
import { getMultipartAutoContentType } from 'utils/common/multipartContentType';
|
||||
import { usePersistedState } from 'hooks/usePersistedState';
|
||||
import { useTrackScroll } from 'hooks/useTrackScroll';
|
||||
import { isWindowsOS } from 'utils/common/platform';
|
||||
|
||||
const fileBasename = (filePath) =>
|
||||
filePath ? path.basename(normalizePath(String(filePath))) : '';
|
||||
|
||||
const MultipartFormParams = ({ item, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -57,8 +62,10 @@ const MultipartFormParams = ({ item, collection }) => {
|
||||
}, [dispatch, collection.uid, item.uid]);
|
||||
|
||||
const handleBrowseFiles = useCallback((row, onChange) => {
|
||||
dispatch(browseFiles())
|
||||
dispatch(browseFiles([], ['multiSelections']))
|
||||
.then((filePaths) => {
|
||||
if (!Array.isArray(filePaths) || filePaths.length === 0) return;
|
||||
|
||||
const processedPaths = filePaths.map((filePath) => {
|
||||
return getRelativePathWithinBasePath(collection.pathname, filePath);
|
||||
});
|
||||
@@ -66,19 +73,42 @@ 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);
|
||||
const existingParam = (currentParams || []).find((p) => p.uid === row.uid);
|
||||
const existingValue = existingParam && existingParam.type === 'file' && Array.isArray(existingParam.value)
|
||||
? existingParam.value
|
||||
: [];
|
||||
const seen = new Set(existingValue);
|
||||
const merged = [...existingValue];
|
||||
const skipped = [];
|
||||
for (const p of processedPaths) {
|
||||
if (!seen.has(p)) {
|
||||
seen.add(p);
|
||||
merged.push(p);
|
||||
} else {
|
||||
skipped.push(p);
|
||||
}
|
||||
}
|
||||
|
||||
if (skipped.length === 1) {
|
||||
toast(`"${fileBasename(skipped[0])}" is already added`);
|
||||
} else if (skipped.length > 1) {
|
||||
toast(`${skipped.length} files are already added — skipped`);
|
||||
}
|
||||
|
||||
const autoContentType = getMultipartAutoContentType(merged);
|
||||
|
||||
let updatedParams;
|
||||
if (existsInParams) {
|
||||
if (existingParam) {
|
||||
updatedParams = currentParams.map((p) => {
|
||||
if (p.uid === row.uid) {
|
||||
return { ...p, type: 'file', value: processedPaths };
|
||||
return { ...p, type: 'file', value: merged, contentType: autoContentType };
|
||||
}
|
||||
return p;
|
||||
});
|
||||
} else {
|
||||
updatedParams = [
|
||||
...(currentParams || []),
|
||||
{ uid: row.uid, name: row.name || '', enabled: true, type: 'file', value: processedPaths, contentType: '' }
|
||||
{ uid: row.uid, name: row.name || '', enabled: true, type: 'file', value: merged, contentType: autoContentType }
|
||||
];
|
||||
}
|
||||
handleParamsChange(updatedParams);
|
||||
@@ -88,13 +118,21 @@ const MultipartFormParams = ({ item, collection }) => {
|
||||
});
|
||||
}, [dispatch, collection.pathname, item, handleParamsChange]);
|
||||
|
||||
const handleClearFile = useCallback((row) => {
|
||||
const handleRemoveFile = useCallback((row, filePathToRemove) => {
|
||||
const currentParams = params || [];
|
||||
const target = currentParams.find((p) => p.uid === row.uid);
|
||||
if (!target || target.type !== 'file') return;
|
||||
const currentValue = Array.isArray(target.value)
|
||||
? target.value
|
||||
: (target.value ? [target.value] : []);
|
||||
const nextValue = currentValue.filter((p) => p !== filePathToRemove);
|
||||
|
||||
const updatedParams = currentParams.map((p) => {
|
||||
if (p.uid === row.uid) {
|
||||
return { ...p, type: 'text', value: '' };
|
||||
if (p.uid !== row.uid) return p;
|
||||
if (nextValue.length === 0) {
|
||||
return { ...p, type: 'text', value: '', contentType: '' };
|
||||
}
|
||||
return p;
|
||||
return { ...p, type: 'file', value: nextValue, contentType: getMultipartAutoContentType(nextValue) };
|
||||
});
|
||||
handleParamsChange(updatedParams);
|
||||
}, [params, handleParamsChange]);
|
||||
@@ -115,19 +153,12 @@ const MultipartFormParams = ({ item, collection }) => {
|
||||
}
|
||||
}, [params, handleParamsChange]);
|
||||
|
||||
const getFileName = (filePaths) => {
|
||||
const getFileList = (filePaths) => {
|
||||
if (!filePaths || (Array.isArray(filePaths) && filePaths.length === 0)) {
|
||||
return null;
|
||||
return [];
|
||||
}
|
||||
const paths = Array.isArray(filePaths) ? filePaths : [filePaths];
|
||||
const validPaths = paths.filter((v) => v != null && v !== '');
|
||||
if (validPaths.length === 0) return null;
|
||||
|
||||
const separator = isWindowsOS() ? '\\' : '/';
|
||||
if (validPaths.length === 1) {
|
||||
return validPaths[0].split(separator).pop();
|
||||
}
|
||||
return `${validPaths.length} file(s)`;
|
||||
return paths.filter((v) => v != null && v !== '');
|
||||
};
|
||||
|
||||
const columns = [
|
||||
@@ -144,29 +175,14 @@ const MultipartFormParams = ({ item, collection }) => {
|
||||
placeholder: 'Value',
|
||||
width: '35%',
|
||||
render: ({ row, value, onChange }) => {
|
||||
const isFile = row.type === 'file';
|
||||
const fileName = isFile ? getFileName(value) : null;
|
||||
if (fileName) {
|
||||
const files = row.type === 'file' ? getFileList(value) : [];
|
||||
if (files.length > 0) {
|
||||
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>
|
||||
<button
|
||||
className="clear-file-btn ml-1"
|
||||
onClick={() => handleClearFile(row)}
|
||||
title="Remove file"
|
||||
>
|
||||
<IconX size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<MultipartFileChipsCell
|
||||
files={files}
|
||||
onRemove={(filePath) => handleRemoveFile(row, filePath)}
|
||||
onAdd={() => handleBrowseFiles(row, onChange)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -186,6 +202,7 @@ const MultipartFormParams = ({ item, collection }) => {
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
data-testid="multipart-file-upload"
|
||||
className="upload-btn ml-1"
|
||||
onClick={() => handleBrowseFiles(row, onChange)}
|
||||
title="Select File"
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import React, { useEffect, useMemo, 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 AIAssist from 'components/AIAssist';
|
||||
import { buildRequestContextFromItem } from 'utils/ai';
|
||||
import { updateRequestScript, updateResponseScript } from 'providers/ReduxStore/slices/collections';
|
||||
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { updateScriptPaneTab } from 'providers/ReduxStore/slices/tabs';
|
||||
@@ -10,6 +12,7 @@ import { useTheme } from 'providers/Theme';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from 'components/Tabs';
|
||||
import StatusDot from 'components/StatusDot';
|
||||
import { usePersistedState } from 'hooks/usePersistedState';
|
||||
import { useFocusErrorLine } from 'hooks/useFocusErrorLine';
|
||||
|
||||
const Script = ({ item, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -55,6 +58,20 @@ const Script = ({ item, collection }) => {
|
||||
return () => clearTimeout(timer);
|
||||
}, [activeTab]);
|
||||
|
||||
useFocusErrorLine({
|
||||
uid: item.uid,
|
||||
editorRef: preRequestEditorRef,
|
||||
scriptPhase: 'pre-request',
|
||||
isVisible: activeTab === 'pre-request'
|
||||
});
|
||||
|
||||
useFocusErrorLine({
|
||||
uid: item.uid,
|
||||
editorRef: postResponseEditorRef,
|
||||
scriptPhase: 'post-response',
|
||||
isVisible: activeTab === 'post-response'
|
||||
});
|
||||
|
||||
const onRequestScriptEdit = (value) => {
|
||||
dispatch(
|
||||
updateRequestScript({
|
||||
@@ -78,6 +95,8 @@ const Script = ({ item, collection }) => {
|
||||
const onRun = () => dispatch(sendRequest(item, collection.uid));
|
||||
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
|
||||
|
||||
const requestContext = useMemo(() => buildRequestContextFromItem(item), [item]);
|
||||
|
||||
const hasPreRequestScript = requestScript && requestScript.trim().length > 0;
|
||||
const hasPostResponseScript = responseScript && responseScript.trim().length > 0;
|
||||
|
||||
@@ -104,41 +123,57 @@ const Script = ({ item, collection }) => {
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="pre-request" className="mt-2" dataTestId="pre-request-script-editor">
|
||||
<CodeEditor
|
||||
ref={preRequestEditorRef}
|
||||
collection={collection}
|
||||
docKey="script:pre-request"
|
||||
value={requestScript || ''}
|
||||
theme={displayedTheme}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
onEdit={onRequestScriptEdit}
|
||||
mode="javascript"
|
||||
onRun={onRun}
|
||||
onSave={onSave}
|
||||
showHintsFor={['req', 'bru']}
|
||||
initialScroll={preReqScroll}
|
||||
onScroll={setPreReqScroll}
|
||||
/>
|
||||
<div className="relative h-full">
|
||||
<CodeEditor
|
||||
ref={preRequestEditorRef}
|
||||
collection={collection}
|
||||
docKey="script:pre-request"
|
||||
value={requestScript || ''}
|
||||
theme={displayedTheme}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
onEdit={onRequestScriptEdit}
|
||||
mode="javascript"
|
||||
onRun={onRun}
|
||||
onSave={onSave}
|
||||
showHintsFor={['req', 'bru']}
|
||||
initialScroll={preReqScroll}
|
||||
onScroll={setPreReqScroll}
|
||||
/>
|
||||
<AIAssist
|
||||
scriptType="pre-request"
|
||||
currentScript={requestScript || ''}
|
||||
requestContext={requestContext}
|
||||
onApply={onRequestScriptEdit}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="post-response" className="mt-2" dataTestId="post-response-script-editor">
|
||||
<CodeEditor
|
||||
ref={postResponseEditorRef}
|
||||
collection={collection}
|
||||
docKey="script:post-response"
|
||||
value={responseScript || ''}
|
||||
theme={displayedTheme}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
onEdit={onResponseScriptEdit}
|
||||
mode="javascript"
|
||||
onRun={onRun}
|
||||
onSave={onSave}
|
||||
showHintsFor={['req', 'res', 'bru']}
|
||||
initialScroll={postResScroll}
|
||||
onScroll={setPostResScroll}
|
||||
/>
|
||||
<div className="relative h-full">
|
||||
<CodeEditor
|
||||
ref={postResponseEditorRef}
|
||||
collection={collection}
|
||||
docKey="script:post-response"
|
||||
value={responseScript || ''}
|
||||
theme={displayedTheme}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
onEdit={onResponseScriptEdit}
|
||||
mode="javascript"
|
||||
onRun={onRun}
|
||||
onSave={onSave}
|
||||
showHintsFor={['req', 'res', 'bru']}
|
||||
initialScroll={postResScroll}
|
||||
onScroll={setPostResScroll}
|
||||
/>
|
||||
<AIAssist
|
||||
scriptType="post-response"
|
||||
currentScript={responseScript || ''}
|
||||
requestContext={requestContext}
|
||||
onApply={onResponseScriptEdit}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import React, { useRef } from 'react';
|
||||
import React, { useMemo, useRef } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import CodeEditor from 'components/CodeEditor';
|
||||
import AIAssist from 'components/AIAssist';
|
||||
import { buildRequestContextFromItem } from 'utils/ai';
|
||||
import { updateRequestTests } from 'providers/ReduxStore/slices/collections';
|
||||
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { usePersistedState } from 'hooks/usePersistedState';
|
||||
import { useFocusErrorLine } from 'hooks/useFocusErrorLine';
|
||||
|
||||
const Tests = ({ item, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -29,8 +32,16 @@ const Tests = ({ item, collection }) => {
|
||||
const onRun = () => dispatch(sendRequest(item, collection.uid));
|
||||
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
|
||||
|
||||
useFocusErrorLine({
|
||||
uid: item.uid,
|
||||
editorRef: testsEditorRef,
|
||||
scriptPhase: 'test'
|
||||
});
|
||||
|
||||
const requestContext = useMemo(() => buildRequestContextFromItem(item), [item]);
|
||||
|
||||
return (
|
||||
<div data-testid="test-script-editor">
|
||||
<div data-testid="test-script-editor" className="relative h-full">
|
||||
<CodeEditor
|
||||
ref={testsEditorRef}
|
||||
collection={collection}
|
||||
@@ -47,6 +58,7 @@ const Tests = ({ item, collection }) => {
|
||||
initialScroll={testsScroll}
|
||||
onScroll={setTestsScroll}
|
||||
/>
|
||||
<AIAssist scriptType="tests" currentScript={tests || ''} requestContext={requestContext} onApply={onEdit} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,6 +6,8 @@ import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collection
|
||||
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
|
||||
import MultiLineEditor from 'components/MultiLineEditor';
|
||||
import InfoTip from 'components/InfoTip';
|
||||
import DataTypeSelector from 'components/DataTypeSelector';
|
||||
import { valueToString } from '@usebruno/common/utils';
|
||||
import EditableTable from 'components/EditableTable';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import toast from 'react-hot-toast';
|
||||
@@ -72,17 +74,33 @@ const VarsTable = ({ item, collection, vars, varType, initialScroll = 0 }) => {
|
||||
</div>
|
||||
),
|
||||
placeholder: varType === 'request' ? 'Value' : 'Expr',
|
||||
render: ({ value, onChange }) => (
|
||||
<MultiLineEditor
|
||||
value={value || ''}
|
||||
theme={storedTheme}
|
||||
onSave={onSave}
|
||||
onChange={onChange}
|
||||
onRun={handleRun}
|
||||
collection={collection}
|
||||
item={item}
|
||||
placeholder={!value ? (varType === 'request' ? 'Value' : 'Expr') : ''}
|
||||
/>
|
||||
render: ({ row, value, onChange, isLastEmptyRow }) => (
|
||||
<div className="flex items-center w-full gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<MultiLineEditor
|
||||
value={valueToString(value)}
|
||||
theme={storedTheme}
|
||||
onSave={onSave}
|
||||
onChange={onChange}
|
||||
onRun={handleRun}
|
||||
collection={collection}
|
||||
item={item}
|
||||
placeholder={value == null || (typeof value === 'string' && value.trim() === '') ? (varType === 'request' ? 'Value' : 'Expr') : ''}
|
||||
/>
|
||||
</div>
|
||||
{/* DataTypes apply to literal values, not to the JS expression that produces a post-response value. */}
|
||||
{!isLastEmptyRow && varType === 'request' && (
|
||||
<DataTypeSelector
|
||||
variable={row}
|
||||
theme={storedTheme}
|
||||
collection={collection}
|
||||
onChange={(fields) => {
|
||||
const updated = (vars || []).map((v) => v.uid === row.uid ? { ...v, ...fields } : v);
|
||||
handleVarsChange(updated);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
];
|
||||
@@ -97,6 +115,7 @@ const VarsTable = ({ item, collection, vars, varType, initialScroll = 0 }) => {
|
||||
<StyledWrapper className="w-full">
|
||||
<EditableTable
|
||||
tableId="request-vars"
|
||||
testId={`request-vars-${varType === 'response' ? 'res' : 'req'}`}
|
||||
columns={columns}
|
||||
rows={vars || []}
|
||||
onChange={handleVarsChange}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import BearerAuth from '../../Auth/BearerAuth';
|
||||
@@ -6,16 +6,15 @@ import BasicAuth from '../../Auth/BasicAuth';
|
||||
import ApiKeyAuth from '../../Auth/ApiKeyAuth';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { humanizeRequestAuthMode } from 'utils/collections';
|
||||
import { getTreePathFromCollectionToItem } from 'utils/collections/index';
|
||||
import { getEffectiveAuthSource } from 'utils/auth';
|
||||
import { updateRequestAuthMode, updateAuth } from 'providers/ReduxStore/slices/collections';
|
||||
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
|
||||
const supportedAuthModes = ['basic', 'bearer', 'apikey', 'oauth2', 'none', 'inherit'];
|
||||
import { AUTH_MODES_WS } from 'utils/common/constants';
|
||||
|
||||
const WSAuth = ({ item, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const authMode = item.draft ? get(item, 'draft.request.auth.mode') : get(item, 'request.auth.mode');
|
||||
const requestTreePath = getTreePathFromCollectionToItem(collection, item);
|
||||
|
||||
const request = item.draft
|
||||
? get(item, 'draft.request', {})
|
||||
@@ -25,9 +24,14 @@ const WSAuth = ({ item, collection }) => {
|
||||
return saveRequest(item.uid, collection.uid);
|
||||
};
|
||||
|
||||
const inheritedSource = useMemo(
|
||||
() => (authMode === 'inherit' ? getEffectiveAuthSource(collection, item) : null),
|
||||
[authMode, item, collection]
|
||||
);
|
||||
|
||||
// Reset to 'none' if current auth mode is not supported
|
||||
useEffect(() => {
|
||||
if (authMode && !supportedAuthModes.includes(authMode)) {
|
||||
if (authMode && !AUTH_MODES_WS.includes(authMode)) {
|
||||
dispatch(updateRequestAuthMode({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
@@ -36,35 +40,6 @@ const WSAuth = ({ item, collection }) => {
|
||||
}
|
||||
}, [authMode, collection.uid, dispatch, item.uid]);
|
||||
|
||||
const getEffectiveAuthSource = () => {
|
||||
if (authMode !== 'inherit') return null;
|
||||
|
||||
const collectionRoot = collection?.draft?.root || collection?.root || {};
|
||||
const collectionAuth = get(collectionRoot, 'request.auth');
|
||||
let effectiveSource = {
|
||||
type: 'collection',
|
||||
name: 'Collection',
|
||||
auth: collectionAuth
|
||||
};
|
||||
|
||||
// Check folders in reverse to find the closest auth configuration
|
||||
for (let i of [...requestTreePath].reverse()) {
|
||||
if (i.type === 'folder') {
|
||||
const folderAuth = get(i, 'root.request.auth');
|
||||
if (folderAuth && folderAuth.mode && folderAuth.mode !== 'none' && folderAuth.mode !== 'inherit') {
|
||||
effectiveSource = {
|
||||
type: 'folder',
|
||||
name: i.name,
|
||||
auth: folderAuth
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return effectiveSource;
|
||||
};
|
||||
|
||||
const getAuthView = () => {
|
||||
switch (authMode) {
|
||||
case 'none': {
|
||||
@@ -91,26 +66,24 @@ 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') {
|
||||
if (inheritedSource?.auth?.mode === 'oauth1' || inheritedSource?.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.
|
||||
{inheritedSource.auth.mode === 'oauth1' ? 'OAuth 1.0' : 'OAuth 2'} not <strong>yet</strong> supported by WebSockets. Using no auth instead.
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Only show inherited auth if it's one of the supported types
|
||||
if (source && supportedAuthModes.includes(source.auth?.mode)) {
|
||||
if (inheritedSource && AUTH_MODES_WS.includes(inheritedSource.auth?.mode)) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-row w-full gap-2">
|
||||
<div> Auth inherited from {source.name}: </div>
|
||||
<div className="inherit-mode-text">{humanizeRequestAuthMode(source.auth?.mode)}</div>
|
||||
<div> Auth inherited from {inheritedSource.name}: </div>
|
||||
<div className="inherit-mode-text">{humanizeRequestAuthMode(inheritedSource.auth?.mode)}</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -2,17 +2,26 @@ import React, { useMemo, useCallback, useRef } from 'react';
|
||||
import Documentation from 'components/Documentation/index';
|
||||
import RequestHeaders from 'components/RequestPane/RequestHeaders';
|
||||
import StatusDot from 'components/StatusDot/index';
|
||||
import { find } from 'lodash';
|
||||
import ActionIcon from 'ui/ActionIcon';
|
||||
import ToolHint from 'components/ToolHint/index';
|
||||
import { IconPlus, IconWand } from '@tabler/icons';
|
||||
import { find, get } from 'lodash';
|
||||
import { updateRequestPaneTab } from 'providers/ReduxStore/slices/tabs';
|
||||
import { updateRequestBody } from 'providers/ReduxStore/slices/collections';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import HeightBoundContainer from 'ui/HeightBoundContainer';
|
||||
import ResponsiveTabs from 'ui/ResponsiveTabs';
|
||||
import { getPropertyFromDraftOrRequest } from 'utils/collections/index';
|
||||
import { prettifyJsonString, uuid } from 'utils/common/index';
|
||||
import xmlFormat from 'xml-formatter';
|
||||
import toast from 'react-hot-toast';
|
||||
import WsBody from '../WsBody/index';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import WSAuth from './WSAuth';
|
||||
import WSAuthMode from './WSAuth/WSAuthMode';
|
||||
import WSSettingsPane from '../WSSettingsPane/index';
|
||||
import { hasEffectiveAuth } from 'utils/auth';
|
||||
import { AUTH_MODES_WS } from 'utils/common/constants';
|
||||
|
||||
const WSRequestPane = ({ item, collection, handleRun }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -24,6 +33,8 @@ const WSRequestPane = ({ item, collection, handleRun }) => {
|
||||
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
|
||||
const requestPaneTab = focusedTab?.requestPaneTab;
|
||||
|
||||
const body = item.draft ? get(item, 'draft.request.body') : get(item, 'request.body');
|
||||
|
||||
const selectTab = useCallback(
|
||||
(tab) => {
|
||||
dispatch(updateRequestPaneTab({
|
||||
@@ -34,10 +45,70 @@ const WSRequestPane = ({ item, collection, handleRun }) => {
|
||||
[dispatch, item.uid]
|
||||
);
|
||||
|
||||
const addNewMessage = useCallback(() => {
|
||||
const currentMessages = Array.isArray(body?.ws)
|
||||
? body.ws.map((msg) => ({ ...msg, selected: false }))
|
||||
: [];
|
||||
currentMessages.push({
|
||||
uid: uuid(),
|
||||
name: `message ${currentMessages.length + 1}`,
|
||||
content: '{}',
|
||||
type: 'json',
|
||||
selected: true
|
||||
});
|
||||
dispatch(updateRequestBody({
|
||||
content: currentMessages,
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid
|
||||
}));
|
||||
}, [body, dispatch, item.uid, collection.uid]);
|
||||
|
||||
const onPrettifyAll = useCallback(() => {
|
||||
const currentMessages = [...(body?.ws || [])];
|
||||
let changed = false;
|
||||
|
||||
currentMessages.forEach((msg, i) => {
|
||||
if (msg.type === 'json') {
|
||||
try {
|
||||
const pretty = prettifyJsonString(msg.content);
|
||||
if (pretty !== msg.content) {
|
||||
currentMessages[i] = { ...msg, content: pretty };
|
||||
changed = true;
|
||||
}
|
||||
} catch (e) {
|
||||
// skip invalid json
|
||||
}
|
||||
} else if (msg.type === 'xml') {
|
||||
try {
|
||||
const pretty = xmlFormat(msg.content, { collapseContent: true });
|
||||
if (pretty !== msg.content) {
|
||||
currentMessages[i] = { ...msg, content: pretty };
|
||||
changed = true;
|
||||
}
|
||||
} catch (e) {
|
||||
// skip invalid xml
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (changed) {
|
||||
dispatch(updateRequestBody({
|
||||
content: currentMessages,
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid
|
||||
}));
|
||||
} else {
|
||||
toast.error('Nothing to prettify');
|
||||
}
|
||||
}, [body, dispatch, item.uid, collection.uid]);
|
||||
|
||||
const headers = getPropertyFromDraftOrRequest(item, 'request.headers');
|
||||
const docs = getPropertyFromDraftOrRequest(item, 'request.docs');
|
||||
const auth = getPropertyFromDraftOrRequest(item, 'request.auth');
|
||||
|
||||
const itemAuthMode = item.draft?.request?.auth?.mode ?? item.request?.auth?.mode ?? item.root?.request?.auth?.mode;
|
||||
const hasAuth = useMemo(
|
||||
() => hasEffectiveAuth(collection, item, AUTH_MODES_WS),
|
||||
[item, itemAuthMode, collection]
|
||||
);
|
||||
const activeHeadersLength = headers.filter((header) => header.enabled).length;
|
||||
|
||||
const allTabs = useMemo(() => {
|
||||
@@ -55,7 +126,7 @@ const WSRequestPane = ({ item, collection, handleRun }) => {
|
||||
{
|
||||
key: 'auth',
|
||||
label: 'Auth',
|
||||
indicator: auth.mode !== 'none' ? <StatusDot type="default" /> : null
|
||||
indicator: hasAuth ? <StatusDot type="default" dataTestId="auth" /> : null
|
||||
},
|
||||
{
|
||||
key: 'settings',
|
||||
@@ -68,7 +139,7 @@ const WSRequestPane = ({ item, collection, handleRun }) => {
|
||||
indicator: docs && docs.length > 0 ? <StatusDot type="default" /> : null
|
||||
}
|
||||
];
|
||||
}, [activeHeadersLength, auth.mode, docs]);
|
||||
}, [activeHeadersLength, hasAuth, docs]);
|
||||
|
||||
const tabPanel = useMemo(() => {
|
||||
switch (requestPaneTab) {
|
||||
@@ -77,9 +148,8 @@ const WSRequestPane = ({ item, collection, handleRun }) => {
|
||||
<WsBody
|
||||
item={item}
|
||||
collection={collection}
|
||||
hideModeSelector={true}
|
||||
hidePrettifyButton={true}
|
||||
handleRun={handleRun}
|
||||
onAddMessage={addNewMessage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -99,17 +169,41 @@ const WSRequestPane = ({ item, collection, handleRun }) => {
|
||||
return <div className="mt-4">404 | Not found</div>;
|
||||
}
|
||||
}
|
||||
}, [requestPaneTab, item, collection, handleRun]);
|
||||
}, [requestPaneTab, item, collection, handleRun, addNewMessage]);
|
||||
|
||||
if (!activeTabUid || !focusedTab?.uid || !requestPaneTab) {
|
||||
return <div className="pb-4 px-4">An error occurred!</div>;
|
||||
}
|
||||
|
||||
const rightContent = requestPaneTab === 'auth' ? (
|
||||
<div ref={rightContentRef} className="flex flex-grow justify-start items-center">
|
||||
<WSAuthMode item={item} collection={collection} />
|
||||
</div>
|
||||
) : null;
|
||||
let rightContent = null;
|
||||
if (requestPaneTab === 'auth') {
|
||||
rightContent = (
|
||||
<div ref={rightContentRef} className="flex flex-grow justify-start items-center">
|
||||
<WSAuthMode item={item} collection={collection} />
|
||||
</div>
|
||||
);
|
||||
} else if (requestPaneTab === 'body') {
|
||||
rightContent = (
|
||||
<div ref={rightContentRef} className="flex items-center gap-2">
|
||||
<ToolHint text="Prettify All" toolhintId="prettify-all-ws">
|
||||
<ActionIcon
|
||||
data-testid="ws-prettify-all"
|
||||
onClick={onPrettifyAll}
|
||||
>
|
||||
<IconWand size={14} strokeWidth={1.5} />
|
||||
</ActionIcon>
|
||||
</ToolHint>
|
||||
<ToolHint text="Add Message" toolhintId="add-msg-ws">
|
||||
<ActionIcon
|
||||
data-testid="ws-add-message"
|
||||
onClick={addNewMessage}
|
||||
>
|
||||
<IconPlus size={15} strokeWidth={1.5} />
|
||||
</ActionIcon>
|
||||
</ToolHint>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledWrapper className="flex flex-col h-full relative">
|
||||
|
||||
@@ -1,72 +1,108 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-bottom: 1px solid ${(props) => props.theme.border.border0};
|
||||
|
||||
&.single {
|
||||
height: 100%;
|
||||
/* Dim the row content when disabled, but not the tooltip */
|
||||
.accordion-left > :not(.toolhint),
|
||||
.accordion-actions,
|
||||
.accordion-body {
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.editor-container {
|
||||
height: calc(100% - 32px);
|
||||
&.disabled {
|
||||
.accordion-left > :not(.toolhint),
|
||||
.accordion-actions,
|
||||
.accordion-body {
|
||||
opacity: 0.45;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.single) {
|
||||
min-height: 240px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
&.last {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.message-toolbar {
|
||||
.accordion-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
padding: 4px 0px;
|
||||
padding-top: 0px;
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
.message-label {
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
color: ${(props) => props.theme.colors.text.subtext1};
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
.accordion-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
gap: 0.375rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
color: ${(props) => props.theme.text};
|
||||
|
||||
.message-label-anchor {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.message-label {
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
cursor: text;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.name-input {
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
color: inherit;
|
||||
background: ${(props) => props.theme.background.surface1};
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
outline: none;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar-btn {
|
||||
.accordion-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 4px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
transition: all 0.15s ease;
|
||||
gap: 0.125rem;
|
||||
|
||||
&:hover {
|
||||
background-color: ${(props) => props.theme.dropdown.hoverBg};
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
.hover-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.125rem;
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
|
||||
&.delete:hover {
|
||||
color: ${(props) => props.theme.colors.text.danger};
|
||||
.hover-action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border-radius: 0.25rem;
|
||||
color: ${(props) => props.theme.text};
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: ${(props) => props.theme.dropdown.hoverBg};
|
||||
}
|
||||
|
||||
&.delete:hover {
|
||||
color: ${(props) => props.theme.colors.text.danger};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .hover-actions {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.editor-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
&:not(.disabled) .accordion-header .message-label {
|
||||
color: ${(props) => props.theme.primary.text};
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,56 +1,118 @@
|
||||
import { IconTrash, IconWand } from '@tabler/icons';
|
||||
import { IconTrash, IconSend, IconChevronRight, IconChevronDown } from '@tabler/icons';
|
||||
import CodeEditor from 'components/CodeEditor/index';
|
||||
import ToolHint from 'components/ToolHint/index';
|
||||
import { get } from 'lodash';
|
||||
import invert from 'lodash/invert';
|
||||
import { updateRequestBody } from 'providers/ReduxStore/slices/collections';
|
||||
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import React, { useState } from 'react';
|
||||
import React, { useMemo, useState, useEffect, useCallback } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { autoDetectLang } from 'utils/codemirror/lang-detect';
|
||||
import { toastError } from 'utils/common/error';
|
||||
import { prettifyJsonString } from 'utils/common/index';
|
||||
import xmlFormat from 'xml-formatter';
|
||||
import { queueWsMessage, isWsConnectionActive, connectWS } from 'utils/network/index';
|
||||
import { findCollectionByUid, findEnvironmentInCollection } from 'utils/collections/index';
|
||||
import toast from 'react-hot-toast';
|
||||
import WSRequestBodyMode from '../BodyMode/index';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
export const TYPE_BY_DECODER = {
|
||||
base64: 'binary',
|
||||
json: 'json',
|
||||
xml: 'xml'
|
||||
const codemirrorMode = {
|
||||
text: 'application/text',
|
||||
xml: 'application/xml',
|
||||
json: 'application/ld+json'
|
||||
};
|
||||
|
||||
export const DECODER_BY_TYPE = invert(TYPE_BY_DECODER);
|
||||
// Maps stored type to display mode
|
||||
const typeToMode = (type) => {
|
||||
switch (type) {
|
||||
case 'json': return 'json';
|
||||
case 'xml': return 'xml';
|
||||
default: return 'text';
|
||||
}
|
||||
};
|
||||
|
||||
export const SingleWSMessage = ({
|
||||
message,
|
||||
item,
|
||||
collection,
|
||||
index,
|
||||
methodType,
|
||||
handleRun,
|
||||
canClientSendMultipleMessages,
|
||||
isLast
|
||||
isExpanded,
|
||||
onToggle,
|
||||
isNew,
|
||||
onNewRendered,
|
||||
isSelected,
|
||||
onSelect
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
const { displayedTheme } = useTheme();
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const body = item.draft ? get(item, 'draft.request.body') : get(item, 'request.body');
|
||||
const collections = useSelector((state) => state.collections.collections);
|
||||
|
||||
const { name, content, type } = message;
|
||||
const [messageFormat, setMessageFormat] = useState(autoDetectLang(content));
|
||||
const displayMode = typeToMode(type);
|
||||
const displayName = name || `message ${index + 1}`;
|
||||
|
||||
const onUpdateMessageType = (type) => {
|
||||
setMessageFormat(type);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editValue, setEditValue] = useState(displayName);
|
||||
const labelTooltipId = `ws-msg-label-${message.uid ?? index}`;
|
||||
|
||||
// Auto-focus the name input when this is a newly created message
|
||||
useEffect(() => {
|
||||
if (isNew) {
|
||||
setIsEditing(true);
|
||||
setEditValue(displayName);
|
||||
onNewRendered();
|
||||
}
|
||||
}, [isNew]);
|
||||
|
||||
const saveName = (value) => {
|
||||
const trimmed = value.trim() || `message ${index + 1}`;
|
||||
const currentMessages = [...(body.ws || [])];
|
||||
|
||||
currentMessages[index] = {
|
||||
...currentMessages[index],
|
||||
type: DECODER_BY_TYPE[type]
|
||||
name: trimmed
|
||||
};
|
||||
dispatch(updateRequestBody({
|
||||
content: currentMessages,
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid
|
||||
}));
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleNameKeyDown = (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
saveName(editValue);
|
||||
} else if (e.key === 'Escape') {
|
||||
setEditValue(displayName);
|
||||
setIsEditing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNameBlur = () => {
|
||||
saveName(editValue);
|
||||
};
|
||||
|
||||
const handleNameClick = useCallback((e) => {
|
||||
e.stopPropagation();
|
||||
setEditValue(displayName);
|
||||
setIsEditing(true);
|
||||
}, [displayName, onToggle]);
|
||||
|
||||
const fontSize = get(preferences, 'font.codeFontSize', 14);
|
||||
const lineHeight = fontSize * 1.5;
|
||||
|
||||
const editorHeight = useMemo(() => {
|
||||
const lineCount = (content || '').split('\n').length;
|
||||
const lines = lineCount + 1;
|
||||
return `${lines * lineHeight + 10}px`;
|
||||
}, [content, lineHeight]);
|
||||
|
||||
const onUpdateMessageType = (newMode) => {
|
||||
const currentMessages = [...(body.ws || [])];
|
||||
currentMessages[index] = {
|
||||
...currentMessages[index],
|
||||
type: typeToMode(newMode)
|
||||
};
|
||||
dispatch(updateRequestBody({
|
||||
content: currentMessages,
|
||||
itemUid: item.uid,
|
||||
@@ -60,13 +122,11 @@ export const SingleWSMessage = ({
|
||||
|
||||
const onEdit = (value) => {
|
||||
const currentMessages = [...(body.ws || [])];
|
||||
|
||||
currentMessages[index] = {
|
||||
name: name ? name : `message ${index + 1}`,
|
||||
type: DECODER_BY_TYPE[messageFormat],
|
||||
...currentMessages[index],
|
||||
name: name || `message ${index + 1}`,
|
||||
content: value
|
||||
};
|
||||
|
||||
dispatch(updateRequestBody({
|
||||
content: currentMessages,
|
||||
itemUid: item.uid,
|
||||
@@ -78,9 +138,7 @@ export const SingleWSMessage = ({
|
||||
|
||||
const onDeleteMessage = () => {
|
||||
const currentMessages = [...(body.ws || [])];
|
||||
|
||||
currentMessages.splice(index, 1);
|
||||
|
||||
dispatch(updateRequestBody({
|
||||
content: currentMessages,
|
||||
itemUid: item.uid,
|
||||
@@ -88,97 +146,122 @@ export const SingleWSMessage = ({
|
||||
}));
|
||||
};
|
||||
|
||||
let codeType = messageFormat;
|
||||
if (TYPE_BY_DECODER[type]) {
|
||||
codeType = TYPE_BY_DECODER[type];
|
||||
}
|
||||
const onSendMessage = useCallback(async () => {
|
||||
try {
|
||||
const col = findCollectionByUid(collections, collection.uid);
|
||||
const environment = findEnvironmentInCollection(col, col?.activeEnvironmentUid);
|
||||
|
||||
const codemirrorMode = {
|
||||
text: 'application/text',
|
||||
xml: 'application/xml',
|
||||
json: 'application/ld+json'
|
||||
};
|
||||
|
||||
const onPrettify = () => {
|
||||
if (codeType === 'json') {
|
||||
try {
|
||||
const prettyBodyJson = prettifyJsonString(content);
|
||||
const currentMessages = [...(body.ws || [])];
|
||||
currentMessages[index] = {
|
||||
...currentMessages[index],
|
||||
name: name ? name : `message ${index + 1}`,
|
||||
content: prettyBodyJson
|
||||
};
|
||||
dispatch(updateRequestBody({
|
||||
content: currentMessages,
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid
|
||||
}));
|
||||
} catch (e) {
|
||||
toastError(new Error('Unable to prettify. Invalid JSON format.'));
|
||||
// Auto-connect if not already connected
|
||||
const connectionStatus = await isWsConnectionActive(item.uid);
|
||||
if (!connectionStatus.isActive) {
|
||||
await connectWS(item, col, environment, col?.runtimeVariables, { connectOnly: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (codeType === 'xml') {
|
||||
try {
|
||||
const prettyBodyXML = xmlFormat(content, { collapseContent: true });
|
||||
|
||||
const currentMessages = [...(body.ws || [])];
|
||||
currentMessages[index] = {
|
||||
...currentMessages[index],
|
||||
name: name ? name : `message ${index + 1}`,
|
||||
content: prettyBodyXML
|
||||
};
|
||||
|
||||
dispatch(updateRequestBody({
|
||||
content: currentMessages,
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid
|
||||
}));
|
||||
} catch (e) {
|
||||
toastError(new Error('Unable to prettify. Invalid XML format.'));
|
||||
const result = await queueWsMessage(item, col, environment, col?.runtimeVariables, index);
|
||||
if (!result.success) {
|
||||
toast.error(result.error || 'Failed to send message');
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Failed to send message');
|
||||
}
|
||||
};
|
||||
|
||||
const isSingleMessage = !canClientSendMultipleMessages || body.ws.length === 1;
|
||||
}, [collections]);
|
||||
|
||||
return (
|
||||
<StyledWrapper className={`message-container ${isSingleMessage ? 'single' : ''} ${isLast ? 'last' : ''}`}>
|
||||
<div className="message-toolbar">
|
||||
<span className="message-label">Message {index + 1}</span>
|
||||
<div className="toolbar-actions">
|
||||
<WSRequestBodyMode mode={messageFormat} onModeChange={onUpdateMessageType} />
|
||||
|
||||
<ToolHint text="Format" toolhintId={`prettify-msg-${index}`}>
|
||||
<button onClick={onPrettify} className="toolbar-btn">
|
||||
<IconWand size={16} strokeWidth={1.5} />
|
||||
</button>
|
||||
</ToolHint>
|
||||
|
||||
{index > 0 && (
|
||||
<ToolHint text="Delete message" toolhintId={`delete-msg-${index}`}>
|
||||
<button onClick={onDeleteMessage} className="toolbar-btn delete">
|
||||
<IconTrash size={16} strokeWidth={1.5} />
|
||||
</button>
|
||||
<StyledWrapper
|
||||
className={!isSelected ? 'disabled' : ''}
|
||||
onMouseDownCapture={() => {
|
||||
if (!isSelected) setTimeout(onSelect, 0);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="accordion-header"
|
||||
data-testid={`ws-message-header-${index}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onToggle}
|
||||
onKeyDown={(e) => {
|
||||
if (e.target !== e.currentTarget) return;
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onToggle();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="accordion-left">
|
||||
{isExpanded ? (
|
||||
<IconChevronDown size={14} strokeWidth={2} />
|
||||
) : (
|
||||
<IconChevronRight size={14} strokeWidth={2} />
|
||||
)}
|
||||
{isEditing ? (
|
||||
<input
|
||||
ref={(node) => node?.focus()}
|
||||
className="name-input"
|
||||
data-testid={`ws-message-name-input-${index}`}
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={handleNameKeyDown}
|
||||
onBlur={handleNameBlur}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
<ToolHint
|
||||
text={displayName}
|
||||
toolhintId={labelTooltipId}
|
||||
className="message-label-anchor"
|
||||
place="bottom-start"
|
||||
positionStrategy="fixed"
|
||||
tooltipTestId="ws-message-name-tooltip"
|
||||
tooltipStyle={{ maxWidth: '320px', whiteSpace: 'normal', wordBreak: 'break-word' }}
|
||||
>
|
||||
<span
|
||||
className="message-label"
|
||||
data-testid={`ws-message-label-${index}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onToggle();
|
||||
}}
|
||||
onDoubleClick={handleNameClick}
|
||||
>
|
||||
{displayName}
|
||||
</span>
|
||||
</ToolHint>
|
||||
)}
|
||||
</div>
|
||||
<div className="accordion-actions" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="hover-actions">
|
||||
<ToolHint text="Send" toolhintId={`send-msg-${index}`}>
|
||||
<button onClick={onSendMessage} className="hover-action-btn" data-testid={`ws-send-msg-${index}`}>
|
||||
<IconSend size={14} strokeWidth={1.5} />
|
||||
</button>
|
||||
</ToolHint>
|
||||
{(body.ws || []).length > 1 && (
|
||||
<ToolHint text="Delete" toolhintId={`delete-msg-${index}`}>
|
||||
<button onClick={onDeleteMessage} className="hover-action-btn delete" data-testid={`ws-delete-msg-${index}`}>
|
||||
<IconTrash size={14} strokeWidth={1.5} />
|
||||
</button>
|
||||
</ToolHint>
|
||||
)}
|
||||
</div>
|
||||
<WSRequestBodyMode mode={displayMode} onModeChange={onUpdateMessageType} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="editor-container">
|
||||
<CodeEditor
|
||||
collection={collection}
|
||||
theme={displayedTheme}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
value={content}
|
||||
onEdit={onEdit}
|
||||
onRun={handleRun}
|
||||
onSave={onSave}
|
||||
mode={codemirrorMode[codeType] ?? 'text/plain'}
|
||||
enableVariableHighlighting={true}
|
||||
/>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="accordion-body" data-testid={`ws-message-body-${index}`} style={{ height: editorHeight }}>
|
||||
<CodeEditor
|
||||
collection={collection}
|
||||
theme={displayedTheme}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
value={content}
|
||||
onEdit={onEdit}
|
||||
onRun={handleRun}
|
||||
onSave={onSave}
|
||||
mode={codemirrorMode[displayMode] ?? 'text/plain'}
|
||||
enableVariableHighlighting={true}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,21 +5,10 @@ const Wrapper = styled.div`
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
|
||||
.messages-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&.single {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&.multi {
|
||||
overflow-y: auto;
|
||||
padding-bottom: 48px;
|
||||
}
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
@@ -36,13 +25,20 @@ const Wrapper = styled.div`
|
||||
}
|
||||
}
|
||||
|
||||
.add-message-footer {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 8px;
|
||||
background: ${(props) => props.theme.bg};
|
||||
.add-message-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 0.875rem;
|
||||
color: ${(props) => props.theme.primary.text};
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 4px 0;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,99 +1,124 @@
|
||||
import { get } from 'lodash';
|
||||
import { updateRequestBody } from 'providers/ReduxStore/slices/collections';
|
||||
import { IconPlus } from '@tabler/icons';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import Button from 'ui/Button';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { SingleWSMessage } from './SingleWSMessage/index';
|
||||
|
||||
const WSBody = ({ item, collection, handleRun }) => {
|
||||
const getSelectedIndex = (messages) => {
|
||||
const idx = messages.findIndex((msg) => msg.selected);
|
||||
return idx >= 0 ? idx : 0;
|
||||
};
|
||||
|
||||
const WSBody = ({ item, collection, handleRun, onAddMessage }) => {
|
||||
const dispatch = useDispatch();
|
||||
const messagesContainerRef = useRef(null);
|
||||
const body = item.draft ? get(item, 'draft.request.body') : get(item, 'request.body');
|
||||
const messages = body?.ws || [];
|
||||
|
||||
const methodType = item.draft ? get(item, 'draft.request.methodType') : get(item, 'request.methodType');
|
||||
const canClientSendMultipleMessages = false;
|
||||
const selectedIndex = getSelectedIndex(messages);
|
||||
|
||||
// Auto-scroll to the latest message when messages are added
|
||||
useEffect(() => {
|
||||
if (messagesContainerRef.current && body?.ws?.length > 0) {
|
||||
const container = messagesContainerRef.current;
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
}, [body?.ws?.length]);
|
||||
|
||||
const addNewMessage = () => {
|
||||
const currentMessages = Array.isArray(body.ws) ? [...body.ws] : [];
|
||||
|
||||
currentMessages.push({
|
||||
name: `message ${currentMessages.length + 1}`,
|
||||
content: '{}'
|
||||
});
|
||||
// Expand the selected message by default (falls back to first)
|
||||
const [expandedUids, setExpandedUids] = useState(() => {
|
||||
const uid = messages[selectedIndex]?.uid || messages[0]?.uid;
|
||||
return new Set(uid ? [uid] : []);
|
||||
});
|
||||
const [newMessageUid, setNewMessageUid] = useState(null);
|
||||
const prevMessagesLengthRef = useRef(messages.length);
|
||||
|
||||
const setSelectedIndex = useCallback((index) => {
|
||||
const currentMessages = [...(body?.ws || [])];
|
||||
const updated = currentMessages.map((msg, i) => ({
|
||||
...msg,
|
||||
selected: i === index
|
||||
}));
|
||||
dispatch(updateRequestBody({
|
||||
content: currentMessages,
|
||||
content: updated,
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid
|
||||
}));
|
||||
};
|
||||
}, [body, dispatch, item.uid, collection.uid]);
|
||||
|
||||
if (!body?.ws || !Array.isArray(body.ws)) {
|
||||
const toggleMessage = useCallback((uid) => {
|
||||
if (!uid) return;
|
||||
setExpandedUids((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(uid)) {
|
||||
next.delete(uid);
|
||||
} else {
|
||||
next.add(uid);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSelect = useCallback((index) => {
|
||||
if (index !== selectedIndex) {
|
||||
setSelectedIndex(index);
|
||||
}
|
||||
}, [selectedIndex, setSelectedIndex]);
|
||||
|
||||
// React to new message being added (messages.length increased)
|
||||
useEffect(() => {
|
||||
if (messages.length > prevMessagesLengthRef.current) {
|
||||
const newMsg = messages[messages.length - 1];
|
||||
if (newMsg?.uid) {
|
||||
setExpandedUids((prev) => new Set(prev).add(newMsg.uid));
|
||||
setNewMessageUid(newMsg.uid);
|
||||
setSelectedIndex(messages.length - 1);
|
||||
}
|
||||
}
|
||||
prevMessagesLengthRef.current = messages.length;
|
||||
}, [messages.length]);
|
||||
|
||||
const handleNewMessageRendered = useCallback(() => {
|
||||
setNewMessageUid(null);
|
||||
}, []);
|
||||
|
||||
// Auto-scroll to bottom when new message is added
|
||||
useEffect(() => {
|
||||
if (messagesContainerRef.current && messages.length > 0) {
|
||||
const container = messagesContainerRef.current;
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
}, [messages.length]);
|
||||
|
||||
if (!messages.length) {
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="empty-state">
|
||||
<p>No WebSocket messages available</p>
|
||||
<Button
|
||||
onClick={addNewMessage}
|
||||
variant="filled"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
icon={<IconPlus size={14} strokeWidth={1.5} />}
|
||||
>
|
||||
Add Message
|
||||
</Button>
|
||||
<button className="add-message-link" data-testid="ws-add-message" onClick={onAddMessage}>
|
||||
<IconPlus size={14} strokeWidth={1.5} />
|
||||
<span>Add message</span>
|
||||
</button>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const messagesToShow = body.ws.filter((_, index) => canClientSendMultipleMessages || index === 0);
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div
|
||||
ref={messagesContainerRef}
|
||||
className={`messages-container ${canClientSendMultipleMessages && messagesToShow.length > 1 ? 'multi' : 'single'}`}
|
||||
>
|
||||
{messagesToShow.map((message, index) => (
|
||||
<div ref={messagesContainerRef} className="messages-container">
|
||||
{messages.map((message, index) => (
|
||||
<SingleWSMessage
|
||||
key={index}
|
||||
key={message.uid}
|
||||
id={`ws-message-${message.uid}`}
|
||||
message={message}
|
||||
item={item}
|
||||
collection={collection}
|
||||
index={index}
|
||||
methodType={methodType}
|
||||
handleRun={handleRun}
|
||||
canClientSendMultipleMessages={canClientSendMultipleMessages}
|
||||
isLast={index === messagesToShow.length - 1}
|
||||
isExpanded={expandedUids.has(message.uid)}
|
||||
onToggle={() => toggleMessage(message.uid)}
|
||||
isNew={newMessageUid === message.uid}
|
||||
onNewRendered={handleNewMessageRendered}
|
||||
isSelected={selectedIndex === index}
|
||||
onSelect={() => handleSelect(index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{canClientSendMultipleMessages && (
|
||||
<div className="add-message-footer">
|
||||
<Button
|
||||
onClick={addNewMessage}
|
||||
variant="filled"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
fullWidth
|
||||
icon={<IconPlus size={14} strokeWidth={1.5} />}
|
||||
>
|
||||
Add Message
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -19,6 +19,7 @@ import VariablesEditor from 'components/VariablesEditor';
|
||||
import CollectionSettings from 'components/CollectionSettings';
|
||||
import { DocExplorer } from '@usebruno/graphql-docs';
|
||||
|
||||
import FileEditor from 'components/FileEditor';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import FolderSettings from 'components/FolderSettings';
|
||||
import { getGlobalEnvironmentVariables, getGlobalEnvironmentVariablesMasked } from 'utils/collections/index';
|
||||
@@ -42,7 +43,9 @@ import EnvironmentSettings from 'components/Environments/EnvironmentSettings';
|
||||
import GlobalEnvironmentSettings from 'components/Environments/GlobalEnvironmentSettings';
|
||||
import OpenAPISyncTab from 'components/OpenAPISyncTab';
|
||||
import OpenAPISpecTab from 'components/OpenAPISpecTab';
|
||||
import ChangelogTab from 'components/ChangelogTab';
|
||||
import CollapsedPanelIndicator from './CollapsedPanelIndicator';
|
||||
import { clampRequestHeightForResponse } from './paneSize';
|
||||
import { IconLoader2 } from '@tabler/icons';
|
||||
|
||||
const MIN_LEFT_PANE_WIDTH = 300;
|
||||
@@ -51,6 +54,8 @@ const MIN_TOP_PANE_HEIGHT = 150;
|
||||
const MIN_BOTTOM_PANE_HEIGHT = 150;
|
||||
const COLLAPSE_EDGE_THRESHOLD = 80;
|
||||
const EXPAND_EDGE_THRESHOLD = 100;
|
||||
// Minimum response pane height to show placeholder content on click-expand
|
||||
const RESPONSE_EXPAND_MIN_HEIGHT = 300;
|
||||
|
||||
const RequestTabPanel = () => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -262,6 +267,21 @@ const RequestTabPanel = () => {
|
||||
startDragging(e);
|
||||
}, [expandResponse, applyPointerResize, startDragging]);
|
||||
|
||||
const handleResponseIndicatorClickExpand = useCallback(() => {
|
||||
expandResponse();
|
||||
if (!isVerticalLayoutRef.current || !mainSectionRef.current) return;
|
||||
const { height: containerHeight } = mainSectionRef.current.getBoundingClientRect();
|
||||
const clampedHeight = clampRequestHeightForResponse(
|
||||
topPaneHeight,
|
||||
containerHeight,
|
||||
RESPONSE_EXPAND_MIN_HEIGHT,
|
||||
MIN_TOP_PANE_HEIGHT
|
||||
);
|
||||
if (clampedHeight != null) {
|
||||
setTopPaneHeight(clampedHeight);
|
||||
}
|
||||
}, [expandResponse, topPaneHeight, setTopPaneHeight]);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
@@ -316,6 +336,10 @@ const RequestTabPanel = () => {
|
||||
return <Preferences />;
|
||||
}
|
||||
|
||||
if (focusedTab.type === 'changelog') {
|
||||
return <ChangelogTab />;
|
||||
}
|
||||
|
||||
if (focusedTab.type === 'workspaceOverview') {
|
||||
return activeWorkspace ? <WorkspaceOverview workspace={activeWorkspace} /> : null;
|
||||
}
|
||||
@@ -396,7 +420,7 @@ const RequestTabPanel = () => {
|
||||
if (folder) {
|
||||
return (
|
||||
<ScopedPersistenceProvider scope={focusedTab.uid}>
|
||||
<FolderSettings collection={collection} folder={folder} />;
|
||||
<FolderSettings collection={collection} folder={folder} />
|
||||
</ScopedPersistenceProvider>
|
||||
);
|
||||
}
|
||||
@@ -458,6 +482,17 @@ const RequestTabPanel = () => {
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
if (collection.fileMode) {
|
||||
return (
|
||||
<ScopedPersistenceProvider scope={focusedTab.uid}>
|
||||
<StyledWrapper className="flex flex-col flex-grow relative p-4 file-mode overflow-hidden">
|
||||
<FileEditor item={item} collection={collection} />
|
||||
</StyledWrapper>
|
||||
</ScopedPersistenceProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const renderQueryUrl = () => {
|
||||
if (isGrpcRequest) {
|
||||
return <GrpcQueryUrl item={item} collection={collection} handleRun={handleRun} />;
|
||||
@@ -563,7 +598,7 @@ const RequestTabPanel = () => {
|
||||
<CollapsedPanelIndicator
|
||||
panelType="response"
|
||||
isVertical={isVerticalLayout}
|
||||
onExpand={expandResponse}
|
||||
onExpand={handleResponseIndicatorClickExpand}
|
||||
onDragStart={handleResponseIndicatorDragStart}
|
||||
dragThresholdPx={isVerticalLayout ? MIN_BOTTOM_PANE_HEIGHT / 2 : MIN_RIGHT_PANE_WIDTH / 2}
|
||||
/>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user