mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-04 01:48:33 +00:00
Compare commits
61 Commits
bugfix/inc
...
release/v2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d9dfa239d | ||
|
|
b7be6aacce | ||
|
|
955d33f1fe | ||
|
|
f3c38400ff | ||
|
|
7bd6a9a915 | ||
|
|
e1045372d5 | ||
|
|
914b858024 | ||
|
|
36e9a9c137 | ||
|
|
995899dedb | ||
|
|
408dd6bccf | ||
|
|
ab7ead91d5 | ||
|
|
a186df3ac4 | ||
|
|
3fe5299d8e | ||
|
|
57c08350b6 | ||
|
|
68b2625259 | ||
|
|
1719cee440 | ||
|
|
ab4e9eb3bd | ||
|
|
24a36bc355 | ||
|
|
1ac128e35c | ||
|
|
4510cc3414 | ||
|
|
f3cb0d4bae | ||
|
|
7b183887ce | ||
|
|
fa28ab9b50 | ||
|
|
60b437ef9d | ||
|
|
1ef8852a01 | ||
|
|
17cc70f36e | ||
|
|
2deee11718 | ||
|
|
bdc8f391b7 | ||
|
|
d8adb59d04 | ||
|
|
de05fb6137 | ||
|
|
3e3884a6af | ||
|
|
23843bb621 | ||
|
|
c85a1ec1a5 | ||
|
|
68cbb7d9df | ||
|
|
396ff2b196 | ||
|
|
6826e98945 | ||
|
|
e47d1ed353 | ||
|
|
08c182a875 | ||
|
|
f3afb4bf84 | ||
|
|
21e8615247 | ||
|
|
6e8751a27a | ||
|
|
c9a96ee94f | ||
|
|
b69db7b44b | ||
|
|
73caaef42b | ||
|
|
e68b2ae3b7 | ||
|
|
cc7f1ea58f | ||
|
|
6e8cd55b76 | ||
|
|
384aabf2af | ||
|
|
a15dcdb133 | ||
|
|
18848cdb26 | ||
|
|
29b90a7e0d | ||
|
|
4fbe371eb0 | ||
|
|
6fd2b8be6d | ||
|
|
be7f92d77f | ||
|
|
c5325c732f | ||
|
|
a538b27f24 | ||
|
|
77bb8f40fe | ||
|
|
8f1f5e3861 | ||
|
|
e9251a1f3f | ||
|
|
3a011b2a18 | ||
|
|
77681ca51e |
@@ -41,13 +41,6 @@
|
||||
|
||||
 <br /><br />
|
||||
|
||||
### الطبعة الذهبية ✨
|
||||
|
||||
غالبية ميزاتنا مجانية ومفتوحة المصدر.
|
||||
نحن نسعى لتحقيق توازن متناغم بين [مبادئ الشفافية والاستدامة](https://github.com/usebruno/bruno/discussions/269)
|
||||
|
||||
طلبات الشراء لـ [الطبعة الذهبية](https://www.usebruno.com/pricing) ستطلق قريبًا بسعر ~~$19~~ **$9** ! <br/>
|
||||
[اشترك هنا](https://usebruno.ck.page/4c65576bd4) لتصلك إشعارات عند الإطلاق.
|
||||
|
||||
### التثبيت
|
||||
|
||||
|
||||
@@ -43,13 +43,6 @@ Bruno ist ein reines Offline-Tool. Es gibt keine Pläne, Bruno um eine Cloud-Syn
|
||||
|
||||
 <br /><br />
|
||||
|
||||
### Golden Edition ✨
|
||||
|
||||
Die meisten unserer Funktionen sind kostenlos und quelloffen.
|
||||
Wir bemühen uns um ein Gleichgewicht zwischen [Open-Source-Prinzipien und Nachhaltigkeit](https://github.com/usebruno/bruno/discussions/269)
|
||||
|
||||
Du kannst die [Golden Edition](https://www.usebruno.com/pricing) bestellen **$19**! <br/>
|
||||
|
||||
### Installation
|
||||
|
||||
Bruno ist als Download [auf unserer Website](https://www.usebruno.com/downloads) für Mac, Windows und Linux verfügbar.
|
||||
|
||||
@@ -43,13 +43,6 @@ Bruno funciona sin conexión a internet. No tenemos intenciones de añadir sincr
|
||||
|
||||
 <br /><br />
|
||||
|
||||
### Golden Edition ✨
|
||||
|
||||
La mayoría de nuestras funcionalidades son gratis y de código abierto.
|
||||
Queremos alcanzar un equilibrio en armonía entre los [principios open-source y la sostenibilidad](https://github.com/usebruno/bruno/discussions/269).
|
||||
|
||||
¡Puedes reservar la [Golden Edition](https://www.usebruno.com/pricing) por ~~$19~~ **$9**! <br/>
|
||||
|
||||
### Instalación
|
||||
|
||||
Bruno está disponible para su descarga [en nuestro sitio web](https://www.usebruno.com/downloads) para Mac, Windows y Linux.
|
||||
|
||||
@@ -43,13 +43,6 @@ Bruno はオフラインのみで利用できます。Bruno にクラウド同
|
||||
|
||||
 <br /><br />
|
||||
|
||||
### ゴールデンエディション ✨
|
||||
|
||||
機能のほとんどが無料で使用でき、オープンソースとなっています。
|
||||
私たちは[オープンソースの原則と長期的な維持](https://github.com/usebruno/bruno/discussions/269)の間でうまくバランスを取ろうと努力しています。
|
||||
|
||||
[ゴールデンエディション](https://www.usebruno.com/pricing)を **19 ドル** (買い切り)で購入できます!
|
||||
|
||||
### インストール方法
|
||||
|
||||
Bruno は[私たちのウェブサイト](https://www.usebruno.com/downloads)からバイナリをダウンロードできます。Mac, Windows, Linux に対応しています。
|
||||
|
||||
@@ -43,12 +43,6 @@
|
||||
|
||||
 <br /><br />
|
||||
|
||||
### ოქროს გამოცემა ✨
|
||||
|
||||
მთავარი ფუნქციების უმეტესობა უფასოა და ღია წყაროა. ჩვენ ვცდილობთ ჰარმონიული ბალანსის დაცვას [ღია წყაროების პრინციპებსა და მდგრადობას შორის](https://github.com/usebruno/bruno/discussions/269)
|
||||
|
||||
თქვენ შეგიძლიათ შეიძინოთ [ოქროს გამოცემა](https://www.usebruno.com/pricing) ერთჯერადი გადახდით **19 დოლარად**! <br/>
|
||||
|
||||
### ინსტალაცია
|
||||
|
||||
ბრუნო ხელმისაწვდომია როგორც ბინარული ჩამოტვირთვა [ჩვენ的网站上](https://www.usebruno.com/downloads) Mac-ის, Windows-ისა და Linux-ისთვის.
|
||||
|
||||
@@ -26,13 +26,6 @@ Bruno is uitsluitend offline. Er zijn geen plannen om ooit cloud-synchronisatie
|
||||
|
||||
 <br /><br />
|
||||
|
||||
### Golden Edition ✨
|
||||
|
||||
De meeste van onze functies zijn gratis en open source.
|
||||
We streven naar een harmonieuze balans tussen [open-source principes en duurzaamheid](https://github.com/usebruno/bruno/discussions/269).
|
||||
|
||||
Je kunt de [Golden Edition](https://www.usebruno.com/pricing) kopen voor een eenmalige betaling van **$19**! <br/>
|
||||
|
||||
### Installatie
|
||||
|
||||
Bruno is beschikbaar als binaire download [op onze website](https://www.usebruno.com/downloads) voor Mac, Windows en Linux.
|
||||
|
||||
@@ -41,13 +41,6 @@ Bruno é totalmente offline. Não há planos de adicionar sincronização em nuv
|
||||
|
||||
 <br /><br />
|
||||
|
||||
### Golden Edition ✨
|
||||
|
||||
A grande maioria dos nossos recursos são gratuitos e de código aberto.
|
||||
Nós nos esforçamos para encontrar um equilíbrio harmônico entre [princípios de código aberto e sustentabilidade](https://github.com/usebruno/bruno/discussions/269)
|
||||
|
||||
Você pode pré encomendar o plano [Golden Edition](https://www.usebruno.com/pricing) por ~~USD $19~~ **USD $9**! <br/>
|
||||
|
||||
### Instalação
|
||||
|
||||
Bruno está disponível para download como binário [em nosso site](https://www.usebruno.com/downloads) para Mac, Windows e Linux.
|
||||
|
||||
@@ -7,14 +7,14 @@ const eslintPluginDiff = require('eslint-plugin-diff');
|
||||
let stylistic;
|
||||
|
||||
const runESMImports = async () => {
|
||||
stylistic = await import('@stylistic/eslint-plugin').then(d => d.default);
|
||||
stylistic = await import('@stylistic/eslint-plugin').then((d) => d.default);
|
||||
};
|
||||
|
||||
module.exports = runESMImports().then(() => defineConfig([
|
||||
{
|
||||
plugins: {
|
||||
'diff': fixupPluginRules(eslintPluginDiff),
|
||||
'@stylistic': stylistic,
|
||||
'@stylistic': stylistic
|
||||
},
|
||||
languageOptions: {
|
||||
parser: require('@typescript-eslint/parser'),
|
||||
@@ -26,6 +26,7 @@ module.exports = runESMImports().then(() => defineConfig([
|
||||
files: [
|
||||
'./eslint.config.js',
|
||||
'tests/**/*.{ts,js}',
|
||||
'playwright/**/*.{js,ts}',
|
||||
'packages/bruno-app/**/*.{js,jsx,ts}',
|
||||
'packages/bruno-app/src/test-utils/mocks/codemirror.js',
|
||||
'packages/bruno-cli/**/*.js',
|
||||
@@ -37,6 +38,7 @@ module.exports = runESMImports().then(() => defineConfig([
|
||||
'packages/bruno-lang/**/*.js',
|
||||
'packages/bruno-requests/**/*.ts',
|
||||
'packages/bruno-requests/**/*.js',
|
||||
'packages/bruno-tests/**/*.{js,ts}'
|
||||
],
|
||||
processor: 'diff/diff',
|
||||
rules: {
|
||||
@@ -44,7 +46,7 @@ module.exports = runESMImports().then(() => defineConfig([
|
||||
indent: 2,
|
||||
quotes: 'single',
|
||||
semi: true,
|
||||
jsx: true,
|
||||
jsx: true
|
||||
}).rules,
|
||||
'@stylistic/comma-dangle': ['error', 'never'],
|
||||
'@stylistic/brace-style': ['error', '1tbs', { allowSingleLine: true }],
|
||||
@@ -52,7 +54,7 @@ module.exports = runESMImports().then(() => defineConfig([
|
||||
'@stylistic/curly-newline': ['error', {
|
||||
multiline: true,
|
||||
minElements: 2,
|
||||
consistent: true,
|
||||
consistent: true
|
||||
}],
|
||||
'@stylistic/function-paren-newline': ['error', 'never'],
|
||||
'@stylistic/array-bracket-spacing': ['error', 'never'],
|
||||
@@ -63,7 +65,7 @@ module.exports = runESMImports().then(() => defineConfig([
|
||||
'@stylistic/semi-style': ['error', 'last'],
|
||||
'@stylistic/max-len': ['off'],
|
||||
'@stylistic/jsx-one-expression-per-line': ['off']
|
||||
},
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ["packages/bruno-app/**/*.{js,jsx,ts}"],
|
||||
|
||||
85
package-lock.json
generated
85
package-lock.json
generated
@@ -14209,6 +14209,15 @@
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-fuzzy": {
|
||||
"version": "1.12.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-fuzzy/-/fast-fuzzy-1.12.0.tgz",
|
||||
"integrity": "sha512-sXxGgHS+ubYpsdLnvOvJ9w5GYYZrtL9mkosG3nfuD446ahvoWEsSKBP7ieGmWIKVLnaxRDgUJkZMdxRgA2Ni+Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"graphemesplit": "^2.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-glob": {
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz",
|
||||
@@ -14225,6 +14234,12 @@
|
||||
"node": ">=8.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-json-format": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-json-format/-/fast-json-format-0.4.0.tgz",
|
||||
"integrity": "sha512-HEomBtr2fYaVX3iaRdcVLU7Qd3SQhCYvXlMMM9RNaihfIaj5bIC7ADqw/bAPSg/uyX6FIBPq69ioXq0B4Cb6eA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-json-stable-stringify": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
|
||||
@@ -15217,6 +15232,16 @@
|
||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/graphemesplit": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/graphemesplit/-/graphemesplit-2.6.0.tgz",
|
||||
"integrity": "sha512-rG9w2wAfkpg0DILa1pjnjNfucng3usON360shisqIMUBw/87pojcBSrHmeE4UwryAuBih7g8m1oilf5/u8EWdQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-base64": "^3.6.0",
|
||||
"unicode-trie": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/graphiql": {
|
||||
"version": "3.7.1",
|
||||
"resolved": "https://registry.npmjs.org/graphiql/-/graphiql-3.7.1.tgz",
|
||||
@@ -17760,6 +17785,12 @@
|
||||
"jiti": "bin/jiti.js"
|
||||
}
|
||||
},
|
||||
"node_modules/js-base64": {
|
||||
"version": "3.7.8",
|
||||
"resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.8.tgz",
|
||||
"integrity": "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/js-md4": {
|
||||
"version": "0.3.2",
|
||||
"resolved": "https://registry.npmjs.org/js-md4/-/js-md4-0.3.2.tgz",
|
||||
@@ -20297,18 +20328,6 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/pidusage": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pidusage/-/pidusage-4.0.1.tgz",
|
||||
"integrity": "sha512-yCH2dtLHfEBnzlHUJymR/Z1nN2ePG3m392Mv8TFlTP1B0xkpMQNHAnfkY0n2tAi6ceKO6YWhxYfZ96V4vVkh/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/pify": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pify/-/pify-5.0.0.tgz",
|
||||
@@ -25490,6 +25509,12 @@
|
||||
"node": ">=0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tiny-inflate": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
|
||||
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tiny-invariant": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
@@ -25915,6 +25940,22 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/unicode-trie": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
|
||||
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pako": "^0.2.5",
|
||||
"tiny-inflate": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unicode-trie/node_modules/pako": {
|
||||
"version": "0.2.9",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
|
||||
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/universalify": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
|
||||
@@ -26832,6 +26873,8 @@
|
||||
"cookie": "0.7.1",
|
||||
"dompurify": "^3.2.4",
|
||||
"escape-html": "^1.0.3",
|
||||
"fast-fuzzy": "^1.12.0",
|
||||
"fast-json-format": "~0.4.0",
|
||||
"file": "^0.2.2",
|
||||
"file-dialog": "^0.0.8",
|
||||
"file-saver": "^2.0.5",
|
||||
@@ -26842,7 +26885,6 @@
|
||||
"graphql-request": "^3.7.0",
|
||||
"httpsnippet": "^3.0.9",
|
||||
"i18next": "24.1.2",
|
||||
"iconv-lite": "^0.6.3",
|
||||
"idb": "^7.0.0",
|
||||
"immer": "^9.0.15",
|
||||
"jsesc": "^3.0.2",
|
||||
@@ -30130,7 +30172,8 @@
|
||||
"js-yaml": "^4.1.0",
|
||||
"jscodeshift": "^17.3.0",
|
||||
"lodash": "^4.17.21",
|
||||
"nanoid": "3.3.8"
|
||||
"nanoid": "3.3.8",
|
||||
"xml2js": "^0.6.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.25.2",
|
||||
@@ -30271,7 +30314,6 @@
|
||||
"lodash": "^4.17.21",
|
||||
"mime-types": "^2.1.35",
|
||||
"nanoid": "3.3.8",
|
||||
"pidusage": "^4.0.1",
|
||||
"qs": "^6.11.0",
|
||||
"socks-proxy-agent": "^8.0.2",
|
||||
"tough-cookie": "^6.0.0",
|
||||
@@ -31996,6 +32038,7 @@
|
||||
"quickjs-emscripten": "^0.29.2",
|
||||
"tv4": "^1.3.0",
|
||||
"uuid": "^9.0.0",
|
||||
"xml-formatter": "^3.5.0",
|
||||
"xml2js": "^0.6.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -32037,6 +32080,18 @@
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"packages/bruno-js/node_modules/xml-formatter": {
|
||||
"version": "3.5.0",
|
||||
"resolved": "https://registry.npmjs.org/xml-formatter/-/xml-formatter-3.5.0.tgz",
|
||||
"integrity": "sha512-9ij/f2PLIPv+YDywtdztq7U82kYMDa5yPYwpn0TnXnqJRH6Su8RC/oaw91erHe3aSEbfgBaA1hDzReDFb1SVXw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"xml-parser-xo": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"packages/bruno-lang": {
|
||||
"name": "@usebruno/lang",
|
||||
"version": "0.12.0",
|
||||
|
||||
@@ -27,6 +27,8 @@
|
||||
"cookie": "0.7.1",
|
||||
"dompurify": "^3.2.4",
|
||||
"escape-html": "^1.0.3",
|
||||
"fast-fuzzy": "^1.12.0",
|
||||
"fast-json-format": "~0.4.0",
|
||||
"file": "^0.2.2",
|
||||
"file-dialog": "^0.0.8",
|
||||
"file-saver": "^2.0.5",
|
||||
@@ -37,7 +39,6 @@
|
||||
"graphql-request": "^3.7.0",
|
||||
"httpsnippet": "^3.0.9",
|
||||
"i18next": "24.1.2",
|
||||
"iconv-lite": "^0.6.3",
|
||||
"idb": "^7.0.0",
|
||||
"immer": "^9.0.15",
|
||||
"jsesc": "^3.0.2",
|
||||
|
||||
83
packages/bruno-app/src/components/BodyModeSelector/index.js
Normal file
83
packages/bruno-app/src/components/BodyModeSelector/index.js
Normal file
@@ -0,0 +1,83 @@
|
||||
import React, { useRef, forwardRef } from 'react';
|
||||
import { IconCaretDown } from '@tabler/icons';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import { humanizeRequestBodyMode } from 'utils/collections';
|
||||
|
||||
const DEFAULT_MODES = [
|
||||
{ key: 'multipartForm', label: 'Multipart Form', category: 'Form' },
|
||||
{ key: 'formUrlEncoded', label: 'Form URL Encoded', category: 'Form' },
|
||||
{ key: 'json', label: 'JSON', category: 'Raw' },
|
||||
{ key: 'xml', label: 'XML', category: 'Raw' },
|
||||
{ key: 'text', label: 'TEXT', category: 'Raw' },
|
||||
{ key: 'sparql', label: 'SPARQL', category: 'Raw' },
|
||||
{ key: 'file', label: 'File / Binary', category: 'Other' },
|
||||
{ key: 'none', label: 'None', category: 'Other' }
|
||||
];
|
||||
|
||||
const BodyModeSelector = ({
|
||||
currentMode,
|
||||
onModeChange,
|
||||
modes = DEFAULT_MODES,
|
||||
disabled = false,
|
||||
className = '',
|
||||
wrapperClassName = '',
|
||||
showCategories = true,
|
||||
placement = 'bottom-end'
|
||||
}) => {
|
||||
const dropdownTippyRef = useRef();
|
||||
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
|
||||
|
||||
const Icon = forwardRef((props, ref) => {
|
||||
return (
|
||||
<div ref={ref} className="flex items-center justify-center pl-3 py-1 select-none selected-body-mode">
|
||||
{humanizeRequestBodyMode(currentMode)}
|
||||
{' '}
|
||||
<IconCaretDown className="caret ml-2" size={14} strokeWidth={2} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const onModeSelect = (mode) => {
|
||||
dropdownTippyRef.current.hide();
|
||||
onModeChange(mode);
|
||||
};
|
||||
|
||||
// Group modes by category for rendering
|
||||
const groupedModes = modes.reduce((acc, mode) => {
|
||||
const category = mode.category || 'Other';
|
||||
if (!acc[category]) {
|
||||
acc[category] = [];
|
||||
}
|
||||
acc[category].push(mode);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return (
|
||||
<div className={`inline-flex items-center body-mode-selector ${disabled ? 'cursor-default' : 'cursor-pointer'} ${wrapperClassName}`}>
|
||||
<Dropdown
|
||||
onCreate={onDropdownCreate}
|
||||
icon={<Icon />}
|
||||
placement={placement}
|
||||
disabled={disabled}
|
||||
className={className}
|
||||
>
|
||||
{Object.entries(groupedModes).map(([category, categoryModes]) => (
|
||||
<React.Fragment key={category}>
|
||||
{showCategories && <div className="label-item font-medium">{category}</div>}
|
||||
{categoryModes.map((mode) => (
|
||||
<div
|
||||
key={mode.key}
|
||||
className="dropdown-item"
|
||||
onClick={() => onModeSelect(mode.key)}
|
||||
>
|
||||
{mode.label}
|
||||
</div>
|
||||
))}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Dropdown>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BodyModeSelector;
|
||||
79
packages/bruno-app/src/components/Checkbox/StyledWrapper.js
Normal file
79
packages/bruno-app/src/components/Checkbox/StyledWrapper.js
Normal file
@@ -0,0 +1,79 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.checkbox-container {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.checkbox-checkmark {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
visibility: ${(props) => props.checked ? 'visible' : 'hidden'};
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.checkbox-input {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border: 2px solid ${(props) => {
|
||||
if (props.checked && props.disabled) {
|
||||
return props.theme.colors.text.muted;
|
||||
}
|
||||
|
||||
if (props.checked && !props.disabled) {
|
||||
return props.theme.colors.text.yellow;
|
||||
}
|
||||
|
||||
return props.theme.colors.text.muted;
|
||||
}};
|
||||
border-radius: 4px;
|
||||
background-color: ${(props) => {
|
||||
if (props.checked && !props.disabled) {
|
||||
return props.theme.colors.text.yellow;
|
||||
}
|
||||
|
||||
if (props.checked && props.disabled) {
|
||||
return props.theme.colors.text.muted;
|
||||
}
|
||||
|
||||
return 'transparent';
|
||||
}};
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: all 0.2s ease;
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px ${(props) => props.theme.colors.text.yellow}40;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
45
packages/bruno-app/src/components/Checkbox/index.js
Normal file
45
packages/bruno-app/src/components/Checkbox/index.js
Normal file
@@ -0,0 +1,45 @@
|
||||
import React from 'react';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import IconCheckMark from 'components/Icons/IconCheckMark';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
|
||||
const Checkbox = ({
|
||||
checked = false,
|
||||
disabled = false,
|
||||
onChange,
|
||||
className = '',
|
||||
id,
|
||||
name,
|
||||
value,
|
||||
dataTestId = 'checkbox'
|
||||
}) => {
|
||||
const { theme } = useTheme();
|
||||
|
||||
const handleChange = (e) => {
|
||||
if (!disabled && onChange) {
|
||||
onChange(e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper checked={checked} disabled={disabled} className={className}>
|
||||
<div className="checkbox-container">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={id}
|
||||
name={name}
|
||||
value={value}
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={handleChange}
|
||||
className="checkbox-input"
|
||||
data-testid={dataTestId}
|
||||
/>
|
||||
<IconCheckMark className="checkbox-checkmark" color={theme.examples.checkbox.color} size={14} />
|
||||
</div>
|
||||
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default Checkbox;
|
||||
@@ -1,6 +1,12 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
&.read-only {
|
||||
div.CodeMirror .CodeMirror-cursor {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
div.CodeMirror {
|
||||
background: ${(props) => props.theme.codemirror.bg};
|
||||
border: solid 1px ${(props) => props.theme.codemirror.border};
|
||||
|
||||
@@ -245,6 +245,10 @@ export default class CodeEditor extends React.Component {
|
||||
this.editor.setOption('mode', this.props.mode);
|
||||
}
|
||||
|
||||
if (this.props.readOnly !== prevProps.readOnly && this.editor) {
|
||||
this.editor.setOption('readOnly', this.props.readOnly);
|
||||
}
|
||||
|
||||
this.ignoreChangeEvent = false;
|
||||
}
|
||||
|
||||
@@ -262,7 +266,7 @@ export default class CodeEditor extends React.Component {
|
||||
}
|
||||
return (
|
||||
<StyledWrapper
|
||||
className="h-full w-full flex flex-col relative graphiql-container"
|
||||
className={`h-full w-full flex flex-col relative graphiql-container ${this.props.readOnly ? 'read-only' : ''}`}
|
||||
aria-label="Code Editor"
|
||||
font={this.props.font}
|
||||
fontSize={this.props.fontSize}
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
import React from 'react';
|
||||
import { IconCertificate, IconTrash, IconWorld } from '@tabler/icons';
|
||||
import { useFormik } from 'formik';
|
||||
import { uuid } from 'utils/common';
|
||||
import * as Yup from 'yup';
|
||||
import { IconEye, IconEyeOff } from '@tabler/icons';
|
||||
import { useState } from 'react';
|
||||
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { useRef } from 'react';
|
||||
import path from 'utils/common/path';
|
||||
import SensitiveFieldWarning from 'components/SensitiveFieldWarning/index';
|
||||
import SingleLineEditor from 'components/SingleLineEditor/index';
|
||||
import { useDetectSensitiveField } from 'hooks/useDetectSensitiveField/index';
|
||||
import { useTheme } from 'styled-components';
|
||||
|
||||
const ClientCertSettings = ({ root, clientCertConfig, onUpdate, onRemove }) => {
|
||||
const ClientCertSettings = ({ collection, clientCertConfig, onUpdate, onRemove }) => {
|
||||
const certFilePathInputRef = useRef();
|
||||
const keyFilePathInputRef = useRef();
|
||||
const pfxFilePathInputRef = useRef();
|
||||
const { storedTheme } = useTheme();
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
@@ -68,10 +69,13 @@ const ClientCertSettings = ({ root, clientCertConfig, onUpdate, onRemove }) => {
|
||||
}
|
||||
});
|
||||
|
||||
const { isSensitive } = useDetectSensitiveField(collection);
|
||||
const { showWarning, warningMessage } = isSensitive(formik.values.passphrase);
|
||||
|
||||
const getFile = (e) => {
|
||||
const filePath = window?.ipcRenderer?.getFilePath(e?.files?.[0]);
|
||||
if (filePath) {
|
||||
let relativePath = path.relative(root, filePath);
|
||||
let relativePath = path.relative(collection.pathname, filePath);
|
||||
formik.setFieldValue(e.name, relativePath);
|
||||
}
|
||||
};
|
||||
@@ -82,8 +86,6 @@ const ClientCertSettings = ({ root, clientCertConfig, onUpdate, onRemove }) => {
|
||||
pfxFilePathInputRef.current.value = '';
|
||||
};
|
||||
|
||||
const [passwordVisible, setPasswordVisible] = useState(false);
|
||||
|
||||
const handleTypeChange = (e) => {
|
||||
formik.setFieldValue('type', e.target.value);
|
||||
if (e.target.value === 'cert') {
|
||||
@@ -314,21 +316,14 @@ const ClientCertSettings = ({ root, clientCertConfig, onUpdate, onRemove }) => {
|
||||
Passphrase
|
||||
</label>
|
||||
<div className="textbox flex flex-row items-center w-[300px] h-[1.70rem] relative">
|
||||
<input
|
||||
id="passphrase"
|
||||
type={passwordVisible ? 'text' : 'password'}
|
||||
name="passphrase"
|
||||
className="outline-none w-64 bg-transparent"
|
||||
onChange={formik.handleChange}
|
||||
<SingleLineEditor
|
||||
value={formik.values.passphrase || ''}
|
||||
theme={storedTheme}
|
||||
onChange={(val) => formik.setFieldValue('passphrase', val)}
|
||||
collection={collection}
|
||||
isSecret={true}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm absolute right-0 l"
|
||||
onClick={() => setPasswordVisible(!passwordVisible)}
|
||||
>
|
||||
{passwordVisible ? <IconEyeOff size={18} strokeWidth={1.5} /> : <IconEye size={18} strokeWidth={1.5} />}
|
||||
</button>
|
||||
{showWarning && <SensitiveFieldWarning fieldName="basic-password" warningMessage={warningMessage} />}
|
||||
</div>
|
||||
{formik.touched.passphrase && formik.errors.passphrase ? (
|
||||
<div className="ml-1 text-red-500">{formik.errors.passphrase}</div>
|
||||
|
||||
@@ -45,7 +45,7 @@ const CollectionSettings = ({ collection }) => {
|
||||
const authMode = get(collection, 'root.request.auth', {}).mode || 'none';
|
||||
|
||||
const presets = get(collection, 'brunoConfig.presets', []);
|
||||
const hasPresets = presets && presets.requestUrl !== "";
|
||||
const hasPresets = presets && presets.requestUrl !== '';
|
||||
|
||||
const proxyConfig = get(collection, 'brunoConfig.proxy', {});
|
||||
const proxyEnabled = proxyConfig.hostname ? true : false;
|
||||
@@ -120,7 +120,7 @@ const CollectionSettings = ({ collection }) => {
|
||||
case 'clientCert': {
|
||||
return (
|
||||
<ClientCertSettings
|
||||
root={collection.pathname}
|
||||
collection={collection}
|
||||
clientCertConfig={clientCertConfig}
|
||||
onUpdate={onClientCertSettingsUpdate}
|
||||
onRemove={onClientCertSettingsRemove}
|
||||
@@ -167,7 +167,7 @@ const CollectionSettings = ({ collection }) => {
|
||||
</div>
|
||||
<div className={getTabClassname('presets')} role="tab" onClick={() => setTab('presets')}>
|
||||
Presets
|
||||
{hasPresets && <StatusDot />}
|
||||
{hasPresets && <StatusDot />}
|
||||
</div>
|
||||
<div className={getTabClassname('proxy')} role="tab" onClick={() => setTab('proxy')}>
|
||||
Proxy
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
IconChevronDown,
|
||||
IconTerminal2,
|
||||
IconNetwork,
|
||||
IconDashboard,
|
||||
IconDashboard
|
||||
} from '@tabler/icons';
|
||||
import {
|
||||
closeConsole,
|
||||
|
||||
@@ -5,7 +5,7 @@ const StyledWrapper = styled.div`
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: ${props => props.theme.console.bg};
|
||||
background: ${(props) => props.theme.console.bg};
|
||||
}
|
||||
|
||||
.tab-content-area {
|
||||
@@ -30,19 +30,19 @@ const StyledWrapper = styled.div`
|
||||
.section-header {
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid ${props => props.theme.console.border};
|
||||
border-bottom: 1px solid ${(props) => props.theme.console.border};
|
||||
|
||||
h3 {
|
||||
margin: 0 0 4px 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: ${props => props.theme.console.titleColor};
|
||||
color: ${(props) => props.theme.console.titleColor};
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: ${props => props.theme.console.textMuted};
|
||||
color: ${(props) => props.theme.console.textMuted};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ const StyledWrapper = styled.div`
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: ${props => props.theme.console.titleColor};
|
||||
color: ${(props) => props.theme.console.titleColor};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,8 +65,8 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
|
||||
.resource-card {
|
||||
background: ${props => props.theme.console.headerBg};
|
||||
border: 1px solid ${props => props.theme.console.border};
|
||||
background: ${(props) => props.theme.console.headerBg};
|
||||
border: 1px solid ${(props) => props.theme.console.border};
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
}
|
||||
@@ -76,7 +76,7 @@ const StyledWrapper = styled.div`
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 6px;
|
||||
color: ${props => props.theme.console.titleColor};
|
||||
color: ${(props) => props.theme.console.titleColor};
|
||||
}
|
||||
|
||||
.resource-title {
|
||||
@@ -87,13 +87,13 @@ const StyledWrapper = styled.div`
|
||||
.resource-value {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: ${props => props.theme.console.titleColor};
|
||||
color: ${(props) => props.theme.console.titleColor};
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.resource-subtitle {
|
||||
font-size: 11px;
|
||||
color: ${props => props.theme.console.buttonColor};
|
||||
color: ${(props) => props.theme.console.buttonColor};
|
||||
}
|
||||
|
||||
.resource-trend {
|
||||
@@ -112,7 +112,7 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
|
||||
&.stable {
|
||||
color: ${props => props.theme.console.buttonColor};
|
||||
color: ${(props) => props.theme.console.buttonColor};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import {
|
||||
@@ -6,13 +6,44 @@ import {
|
||||
IconDatabase,
|
||||
IconClock,
|
||||
IconServer,
|
||||
IconChartLine,
|
||||
IconChartLine
|
||||
} from '@tabler/icons';
|
||||
|
||||
const Performance = () => {
|
||||
const { systemResources } = useSelector(state => state.performance);
|
||||
const { systemResources } = useSelector((state) => state.performance);
|
||||
|
||||
const formatBytes = bytes => {
|
||||
useEffect(() => {
|
||||
const { ipcRenderer } = window;
|
||||
|
||||
if (!ipcRenderer) {
|
||||
console.warn('IPC Renderer not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const startMonitoring = async () => {
|
||||
try {
|
||||
await ipcRenderer.invoke('renderer:start-system-monitoring', 2000);
|
||||
} catch (error) {
|
||||
console.error('Failed to start system monitoring:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const stopMonitoring = async () => {
|
||||
try {
|
||||
await ipcRenderer.invoke('renderer:stop-system-monitoring');
|
||||
} catch (error) {
|
||||
console.error('Failed to stop system monitoring:', error);
|
||||
}
|
||||
};
|
||||
|
||||
startMonitoring();
|
||||
|
||||
return () => {
|
||||
stopMonitoring();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const formatBytes = (bytes) => {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
@@ -20,7 +51,7 @@ const Performance = () => {
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
const formatUptime = seconds => {
|
||||
const formatUptime = (seconds) => {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
/* Environment item styling */
|
||||
.environment-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
padding: 0.375rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
.environment-name {
|
||||
color: ${(props) => props.theme.text};
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,269 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import Portal from 'components/Portal/index';
|
||||
import Modal from 'components/Modal';
|
||||
import { exportBrunoEnvironment } from 'utils/exporters/bruno-environment';
|
||||
import { browseDirectory } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import toast from 'react-hot-toast';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const ExportEnvironmentModal = ({ onClose, environments = [], environmentType }) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// Helper function to truncate environment names
|
||||
const truncateEnvName = (name) => {
|
||||
if (name.length > 40) {
|
||||
return name.substring(0, 40) + '...';
|
||||
}
|
||||
return name;
|
||||
};
|
||||
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const [filePath, setFilePath] = useState('');
|
||||
const [selectedEnvironments, setSelectedEnvironments] = useState({});
|
||||
const [exportFormat, setExportFormat] = useState(environments.length > 1 ? 'single-file' : 'single-object');
|
||||
|
||||
// Initialize selected environments
|
||||
useEffect(() => {
|
||||
const initialSelection = {};
|
||||
|
||||
// Add all environments and select them by default
|
||||
environments.forEach((env) => {
|
||||
initialSelection[env.uid] = true;
|
||||
});
|
||||
|
||||
setSelectedEnvironments(initialSelection);
|
||||
}, [environments]);
|
||||
|
||||
useEffect(() => {
|
||||
const selectedCount = Object.values(selectedEnvironments).filter(Boolean).length;
|
||||
if (selectedCount <= 1) {
|
||||
setExportFormat('single-object');
|
||||
}
|
||||
if (exportFormat === 'single-object' && selectedCount > 1) {
|
||||
setExportFormat('single-file');
|
||||
}
|
||||
}, [selectedEnvironments]);
|
||||
|
||||
const browse = () => {
|
||||
dispatch(browseDirectory())
|
||||
.then((dirPath) => {
|
||||
if (typeof dirPath === 'string') {
|
||||
setFilePath(dirPath);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
setFilePath('');
|
||||
console.error(error);
|
||||
});
|
||||
};
|
||||
|
||||
const handleEnvironmentToggle = (envUid) => {
|
||||
setSelectedEnvironments((prev) => {
|
||||
const newSelection = {
|
||||
...prev,
|
||||
[envUid]: !prev[envUid]
|
||||
};
|
||||
return newSelection;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
const allSelected = environments.every((env) => selectedEnvironments[env.uid]) || false;
|
||||
|
||||
const newSelection = environments.reduce((acc, env) => ({
|
||||
...acc,
|
||||
[env.uid]: !allSelected
|
||||
}), {}) || {};
|
||||
|
||||
setSelectedEnvironments(newSelection);
|
||||
};
|
||||
|
||||
// Memoized selected environments and count
|
||||
const selectedEnvs = useMemo(() => {
|
||||
return environments.filter((env) => selectedEnvironments[env.uid]) || [];
|
||||
}, [environments, selectedEnvironments]);
|
||||
|
||||
const selectedCount = selectedEnvs.length;
|
||||
|
||||
const exportFormatOptions = useMemo(() => {
|
||||
const isMultiple = selectedCount > 1;
|
||||
|
||||
if (isMultiple) {
|
||||
return [
|
||||
{ value: 'single-file', label: 'Single JSON file', description: 'All environments in one JSON array' },
|
||||
{ value: 'folder', label: 'Separate files in folder', description: 'Each environment as a separate JSON file', disabled: false }
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{ value: 'single-object', label: 'Single JSON file', description: 'Export as a single environment JSON object' },
|
||||
{ value: 'folder', label: 'Separate files in folder', description: 'Each environment as a separate JSON file', disabled: true }
|
||||
];
|
||||
}, [selectedCount, exportFormat]);
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
setIsExporting(true);
|
||||
|
||||
if (!filePath) {
|
||||
toast.error('Please select a location to save the files');
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedCount === 0) {
|
||||
toast.error('Please select at least one environment to export');
|
||||
return;
|
||||
}
|
||||
|
||||
await exportBrunoEnvironment({ environments: selectedEnvs, environmentType, filePath, exportFormat });
|
||||
|
||||
const successMessage = exportFormat === 'folder'
|
||||
? `Environments exported successfully to bruno-${environmentType}-environments folder`
|
||||
: 'Environment(s) exported successfully';
|
||||
toast.success(successMessage);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Export error:', error);
|
||||
toast.error(error.message || 'Failed to export environments');
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<StyledWrapper>
|
||||
<Modal
|
||||
size="md"
|
||||
title="Export Environments"
|
||||
hideFooter={true}
|
||||
handleCancel={onClose}
|
||||
>
|
||||
<div className="py-2">
|
||||
{/* Environments Section */}
|
||||
<div className="mb-4">
|
||||
{environments && environments.length > 0 ? (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex justify-between items-center mb-2 pb-1">
|
||||
<h3 className="font-semibold text-sm text-theme">
|
||||
{environmentType === 'global' ? 'Global Environments' : 'Collection Environments'}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSelectAll}
|
||||
className="text-xs text-link px-1 py-0.5 rounded transition-colors"
|
||||
>
|
||||
{environments.every((env) => selectedEnvironments[env.uid]) ? 'Deselect All' : 'Select All'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 flex-1 overflow-y-auto">
|
||||
{environments.map((env) => (
|
||||
<label key={env.uid} className="environment-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedEnvironments[env.uid] || false}
|
||||
onChange={() => handleEnvironmentToggle(env.uid)}
|
||||
disabled={isExporting}
|
||||
className="w-3.5 h-3.5 flex-shrink-0"
|
||||
/>
|
||||
<span className="environment-name">{truncateEnvName(env.name)}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex justify-between items-center mb-2 pb-1">
|
||||
<h3 className="font-semibold text-sm text-theme">
|
||||
{environmentType === 'global' ? 'Global Environments' : 'Collection Environments'}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex items-center justify-center flex-1 p-4 text-center">
|
||||
<span className="text-xs text-muted">
|
||||
No {environmentType === 'global' ? 'global' : 'collection'} environments
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Export Format Section */}
|
||||
{selectedCount > 0 && (
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium mb-2 text-theme">
|
||||
Export Format
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
{exportFormatOptions.map((option) => (
|
||||
<label key={option.value} className={`flex items-start p-2 rounded transition-colors ${option.disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}`}>
|
||||
<input
|
||||
type="radio"
|
||||
name="exportFormat"
|
||||
value={option.value}
|
||||
checked={exportFormat === option.value}
|
||||
onChange={(e) => setExportFormat(e.target.value)}
|
||||
disabled={isExporting || option.disabled}
|
||||
className={`mt-0.5 mr-3 w-4 h-4 ${option.disabled ? 'cursor-not-allowed' : 'cursor-pointer'}`}
|
||||
/>
|
||||
<div>
|
||||
<div className={`text-sm font-medium ${option.disabled ? 'text-muted' : 'text-theme'}`}>{option.label}</div>
|
||||
<div className="text-xs text-muted">{option.description}</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Location Input Section */}
|
||||
<div className="mb-4">
|
||||
<label htmlFor="export-location" className="block text-sm font-medium mb-2 text-theme">
|
||||
Location
|
||||
</label>
|
||||
<div className="flex flex-col relative items-center">
|
||||
<input
|
||||
id="export-location"
|
||||
type="text"
|
||||
className={`flex-1 textbox w-full ${isExporting || selectedCount <= 0 ? '' : 'cursor-pointer'}`}
|
||||
title={filePath}
|
||||
value={filePath}
|
||||
onClick={browse}
|
||||
onChange={(e) => setFilePath(e.target.value)}
|
||||
disabled={isExporting || selectedCount <= 0}
|
||||
placeholder="Select a target location"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Export Actions */}
|
||||
<div className="flex justify-end gap-2 mt-4 pt-3 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-cancel mt-2 flex items-center"
|
||||
onClick={onClose}
|
||||
disabled={isExporting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-secondary mt-2 flex items-center"
|
||||
onClick={handleExport}
|
||||
disabled={isExporting || selectedCount === 0}
|
||||
>
|
||||
{isExporting ? 'Exporting...' : `Export ${selectedCount || ''} Environment${selectedCount !== 1 ? 's' : ''}`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</StyledWrapper>
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExportEnvironmentModal;
|
||||
@@ -0,0 +1,166 @@
|
||||
import React, { useState } from 'react';
|
||||
import Portal from 'components/Portal';
|
||||
import Modal from 'components/Modal';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import importPostmanEnvironment from 'utils/importers/postman-environment';
|
||||
import importBrunoEnvironment from 'utils/importers/bruno-environment';
|
||||
import { readMultipleFiles } from 'utils/importers/file-reader';
|
||||
import { importEnvironment } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { addGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
|
||||
import { toastError } from 'utils/common/error';
|
||||
import { IconFileImport } from '@tabler/icons';
|
||||
|
||||
const ImportEnvironmentModal = ({ type = 'collection', collection, onClose, onEnvironmentCreated }) => {
|
||||
const dispatch = useDispatch();
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
|
||||
const isGlobal = type === 'global';
|
||||
|
||||
// Validate required props
|
||||
if (!isGlobal && !collection) {
|
||||
console.error('ImportEnvironmentModal: collection prop is required when type is "collection"');
|
||||
return null;
|
||||
}
|
||||
const modalTitle = isGlobal ? 'Import Global Environment' : 'Import Environment';
|
||||
const modalTestId = isGlobal ? 'import-global-environment-modal' : 'import-environment-modal';
|
||||
const importTestId = isGlobal ? 'import-global-environment' : 'import-environment';
|
||||
|
||||
const processEnvironments = async (environments, successMessage) => {
|
||||
const validEnvironments = environments.filter((env) => {
|
||||
if (env.name && env.name !== 'undefined') {
|
||||
return true;
|
||||
} else {
|
||||
toast.error('Failed to import environment: env has no name');
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (validEnvironments.length === 0) {
|
||||
toast.error('No valid environments found to import');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Process environments sequentially to ensure unique name checking considers previously imported environments
|
||||
let importedCount = 0;
|
||||
for (const environment of validEnvironments) {
|
||||
const action = isGlobal
|
||||
? addGlobalEnvironment({ name: environment.name, variables: environment.variables })
|
||||
: importEnvironment({ name: environment.name, variables: environment.variables, collectionUid: collection?.uid });
|
||||
|
||||
await dispatch(action);
|
||||
importedCount++;
|
||||
}
|
||||
|
||||
toast.success(`${importedCount > 1 ? `${importedCount} environments` : 'Environment'} imported successfully`);
|
||||
} catch (error) {
|
||||
toast.error('An error occurred while importing the environment(s)');
|
||||
console.error(error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const detectEnvironmentFormat = (data) => {
|
||||
// bruno environment `single-object` export type
|
||||
if (data.info && data.info.type === 'bruno-environment') {
|
||||
return 'bruno';
|
||||
} else if (Array.isArray(data)) {
|
||||
// bruno environment`single-file` export type
|
||||
return data.some((env) => env.info && env.info.type === 'bruno-environment') ? 'bruno' : 'postman';
|
||||
} else if (data.id && data.values) {
|
||||
// postman environment
|
||||
return 'postman';
|
||||
}
|
||||
return 'bruno';
|
||||
};
|
||||
|
||||
const handleImportEnvironment = async (files) => {
|
||||
try {
|
||||
// Read and parse all files
|
||||
const parsedFiles = await readMultipleFiles(Array.from(files));
|
||||
|
||||
// Detect format from first file's content
|
||||
const format = detectEnvironmentFormat(parsedFiles[0].content);
|
||||
let environments;
|
||||
|
||||
if (format === 'postman') {
|
||||
environments = await importPostmanEnvironment(parsedFiles);
|
||||
} else {
|
||||
environments = await importBrunoEnvironment(parsedFiles);
|
||||
}
|
||||
|
||||
await processEnvironments(environments);
|
||||
onClose();
|
||||
if (onEnvironmentCreated) {
|
||||
onEnvironmentCreated();
|
||||
}
|
||||
} catch (err) {
|
||||
toastError(err, 'Import environment failed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = () => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.multiple = true;
|
||||
input.accept = '.json';
|
||||
input.onchange = (e) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
handleImportEnvironment(e.target.files);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
};
|
||||
|
||||
const handleDragOver = (e) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
if (files.length > 0) {
|
||||
handleImportEnvironment(files);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Modal size="md" title={modalTitle} hideFooter={true} handleConfirm={onClose} handleCancel={onClose} dataTestId={modalTestId}>
|
||||
<div className="py-2">
|
||||
<div
|
||||
className={`flex justify-center flex-col items-center w-full dark:bg-zinc-700 rounded-lg border-2 border-dashed p-12 text-center cursor-pointer transition-colors focus:outline-none focus:ring-2 focus:ring-amber-500 focus:ring-offset-2 ${
|
||||
isDragOver
|
||||
? 'border-amber-400 bg-amber-50 dark:bg-amber-900/20'
|
||||
: 'border-zinc-300 dark:border-zinc-400 hover:border-zinc-400'
|
||||
}`}
|
||||
onClick={handleFileSelect}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
data-testid={importTestId}
|
||||
>
|
||||
<IconFileImport size={64} />
|
||||
<span className="mt-2 block text-sm font-semibold">
|
||||
{isDragOver ? 'Drop your environment files here' : 'Import your environments'}
|
||||
</span>
|
||||
<span className="mt-1 block text-xs text-muted">
|
||||
Drag & drop JSON files/folders or click to browse. Supports both Bruno and Postman formats.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImportEnvironmentModal;
|
||||
@@ -11,9 +11,8 @@ import EnvironmentListContent from './EnvironmentListContent/index';
|
||||
import EnvironmentSettings from '../EnvironmentSettings';
|
||||
import GlobalEnvironmentSettings from 'components/GlobalEnvironments/EnvironmentSettings';
|
||||
import CreateEnvironment from '../EnvironmentSettings/CreateEnvironment';
|
||||
import ImportEnvironment from '../EnvironmentSettings/ImportEnvironment';
|
||||
import ImportEnvironmentModal from 'components/Environments/Common/ImportEnvironmentModal';
|
||||
import CreateGlobalEnvironment from 'components/GlobalEnvironments/EnvironmentSettings/CreateEnvironment';
|
||||
import ImportGlobalEnvironment from 'components/GlobalEnvironments/EnvironmentSettings/ImportEnvironment';
|
||||
import ToolHint from 'components/ToolHint';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
@@ -242,7 +241,8 @@ const EnvironmentSelector = ({ collection }) => {
|
||||
)}
|
||||
|
||||
{showImportGlobalModal && (
|
||||
<ImportGlobalEnvironment
|
||||
<ImportEnvironmentModal
|
||||
type="global"
|
||||
onClose={() => setShowImportGlobalModal(false)}
|
||||
onEnvironmentCreated={() => {
|
||||
setShowGlobalSettings(true);
|
||||
@@ -261,7 +261,8 @@ const EnvironmentSelector = ({ collection }) => {
|
||||
)}
|
||||
|
||||
{showImportCollectionModal && (
|
||||
<ImportEnvironment
|
||||
<ImportEnvironmentModal
|
||||
type="collection"
|
||||
collection={collection}
|
||||
onClose={() => setShowImportCollectionModal(false)}
|
||||
onEnvironmentCreated={() => {
|
||||
|
||||
@@ -185,7 +185,7 @@ const EnvironmentVariables = ({ environment, collection, setIsModified, original
|
||||
</thead>
|
||||
<tbody>
|
||||
{formik.values.map((variable, index) => (
|
||||
<tr key={variable.uid}>
|
||||
<tr key={variable.uid} data-testid={`env-var-row-${variable.name}`}>
|
||||
<td className="text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
|
||||
@@ -4,6 +4,7 @@ import CopyEnvironment from '../../CopyEnvironment';
|
||||
import DeleteEnvironment from '../../DeleteEnvironment';
|
||||
import RenameEnvironment from '../../RenameEnvironment';
|
||||
import EnvironmentVariables from './EnvironmentVariables';
|
||||
import ToolHint from 'components/ToolHint/index';
|
||||
|
||||
const EnvironmentDetails = ({ environment, collection, setIsModified, onClose }) => {
|
||||
const [openEditModal, setOpenEditModal] = useState(false);
|
||||
@@ -30,10 +31,22 @@ const EnvironmentDetails = ({ environment, collection, setIsModified, onClose })
|
||||
<IconDatabase className="cursor-pointer" size={20} strokeWidth={1.5} />
|
||||
<span className="ml-1 font-semibold break-all">{environment.name}</span>
|
||||
</div>
|
||||
<div className="flex gap-x-4 pl-4">
|
||||
<IconEdit className="cursor-pointer" size={20} strokeWidth={1.5} onClick={() => setOpenEditModal(true)} />
|
||||
<IconCopy className="cursor-pointer" size={20} strokeWidth={1.5} onClick={() => setOpenCopyModal(true)} />
|
||||
<IconTrash className="cursor-pointer" size={20} strokeWidth={1.5} onClick={() => setOpenDeleteModal(true)} />
|
||||
<div className="flex gap-x-2 pl-2">
|
||||
<ToolHint text="Edit Environment" toolhintId={`edit-${environment.uid}`}>
|
||||
<IconEdit className="cursor-pointer" size={20} strokeWidth={1.5} onClick={() => setOpenEditModal(true)} />
|
||||
</ToolHint>
|
||||
<ToolHint text="Copy Environment" toolhintId={`copy-${environment.uid}`}>
|
||||
<IconCopy className="cursor-pointer" size={20} strokeWidth={1.5} onClick={() => setOpenCopyModal(true)} />
|
||||
</ToolHint>
|
||||
<ToolHint text="Delete Environment" toolhintId={`delete-${environment.uid}`}>
|
||||
<IconTrash
|
||||
className="cursor-pointer"
|
||||
size={20}
|
||||
strokeWidth={1.5}
|
||||
onClick={() => setOpenDeleteModal(true)}
|
||||
data-testid="delete-environment-button"
|
||||
/>
|
||||
</ToolHint>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -3,15 +3,15 @@ import { findEnvironmentInCollection } from 'utils/collections';
|
||||
import usePrevious from 'hooks/usePrevious';
|
||||
import EnvironmentDetails from './EnvironmentDetails';
|
||||
import CreateEnvironment from '../CreateEnvironment';
|
||||
import { IconDownload, IconShieldLock } from '@tabler/icons';
|
||||
import ImportEnvironment from '../ImportEnvironment';
|
||||
import { IconDownload, IconShieldLock, IconUpload } from '@tabler/icons';
|
||||
import ImportEnvironmentModal from 'components/Environments/Common/ImportEnvironmentModal';
|
||||
import ManageSecrets from '../ManageSecrets';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import ConfirmSwitchEnv from './ConfirmSwitchEnv';
|
||||
import ToolHint from 'components/ToolHint';
|
||||
import { isEqual } from 'lodash';
|
||||
|
||||
const EnvironmentList = ({ selectedEnvironment, setSelectedEnvironment, collection, isModified, setIsModified, onClose }) => {
|
||||
const EnvironmentList = ({ selectedEnvironment, setSelectedEnvironment, collection, isModified, setIsModified, onClose, setShowExportModal }) => {
|
||||
const { environments } = collection;
|
||||
const [openCreateModal, setOpenCreateModal] = useState(false);
|
||||
const [openImportModal, setOpenImportModal] = useState(false);
|
||||
@@ -96,7 +96,7 @@ const EnvironmentList = ({ selectedEnvironment, setSelectedEnvironment, collecti
|
||||
return (
|
||||
<StyledWrapper>
|
||||
{openCreateModal && <CreateEnvironment collection={collection} onClose={() => setOpenCreateModal(false)} />}
|
||||
{openImportModal && <ImportEnvironment collection={collection} onClose={() => setOpenImportModal(false)} />}
|
||||
{openImportModal && <ImportEnvironmentModal type="collection" collection={collection} onClose={() => setOpenImportModal(false)} />}
|
||||
{openManageSecretsModal && <ManageSecrets onClose={() => setOpenManageSecretsModal(false)} />}
|
||||
|
||||
<div className="flex">
|
||||
@@ -129,6 +129,10 @@ const EnvironmentList = ({ selectedEnvironment, setSelectedEnvironment, collecti
|
||||
<IconDownload size={12} strokeWidth={2} />
|
||||
<span className="label ml-1 text-xs">Import</span>
|
||||
</div>
|
||||
<div className="flex items-center mt-2" onClick={() => setShowExportModal(true)}>
|
||||
<IconUpload size={12} strokeWidth={2} />
|
||||
<span className="label ml-1 text-xs">Export</span>
|
||||
</div>
|
||||
<div className="flex items-center mt-2" onClick={() => handleSecretsClick()}>
|
||||
<IconShieldLock size={12} strokeWidth={2} />
|
||||
<span className="label ml-1 text-xs">Managing Secrets</span>
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import React from 'react';
|
||||
import Portal from 'components/Portal';
|
||||
import Modal from 'components/Modal';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import importPostmanEnvironment from 'utils/importers/postman-environment';
|
||||
import { importEnvironment } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { toastError } from 'utils/common/error';
|
||||
import { IconDatabaseImport } from '@tabler/icons';
|
||||
|
||||
const ImportEnvironment = ({ collection, onClose, onEnvironmentCreated }) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const handleImportPostmanEnvironment = () => {
|
||||
importPostmanEnvironment()
|
||||
.then((environments) => {
|
||||
environments
|
||||
.filter((env) =>
|
||||
env.name && env.name !== 'undefined'
|
||||
? true
|
||||
: () => {
|
||||
toast.error('Failed to import environment: env has no name');
|
||||
return false;
|
||||
}
|
||||
)
|
||||
.map((environment) => {
|
||||
dispatch(importEnvironment(environment.name, environment.variables, collection.uid))
|
||||
.then(() => {
|
||||
toast.success('Environment imported successfully');
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error('An error occurred while importing the environment');
|
||||
console.error(error);
|
||||
});
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
onClose();
|
||||
// Call the callback if provided
|
||||
if (onEnvironmentCreated) {
|
||||
onEnvironmentCreated();
|
||||
}
|
||||
})
|
||||
.catch((err) => toastError(err, 'Postman Import environment failed'));
|
||||
};
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Modal size="sm" title="Import Environment" hideFooter={true} handleConfirm={onClose} handleCancel={onClose} dataTestId="import-environment-modal">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleImportPostmanEnvironment}
|
||||
className="flex justify-center flex-col items-center w-full dark:bg-zinc-700 rounded-lg border-2 border-dashed border-zinc-300 dark:border-zinc-400 p-12 text-center hover:border-zinc-400 focus:outline-none focus:ring-2 focus:ring-amber-500 focus:ring-offset-2"
|
||||
data-testid="import-postman-environment"
|
||||
>
|
||||
<IconDatabaseImport size={64} />
|
||||
<span className="mt-2 block text-sm font-semibold">Import your Postman environments</span>
|
||||
</button>
|
||||
</Modal>
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImportEnvironment;
|
||||
@@ -3,8 +3,9 @@ import React, { useState } from 'react';
|
||||
import CreateEnvironment from './CreateEnvironment';
|
||||
import EnvironmentList from './EnvironmentList';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import ImportEnvironment from './ImportEnvironment';
|
||||
import ImportEnvironmentModal from 'components/Environments/Common/ImportEnvironmentModal';
|
||||
import { IconFileAlert } from '@tabler/icons';
|
||||
import ExportEnvironmentModal from 'components/Environments/Common/ExportEnvironmentModal';
|
||||
|
||||
export const SharedButton = ({ children, className, onClick }) => {
|
||||
return (
|
||||
@@ -47,6 +48,7 @@ const EnvironmentSettings = ({ collection, onClose }) => {
|
||||
const { environments } = collection;
|
||||
const [selectedEnvironment, setSelectedEnvironment] = useState(null);
|
||||
const [tab, setTab] = useState('default');
|
||||
const [showExportModal, setShowExportModal] = useState(false);
|
||||
if (!environments || !environments.length) {
|
||||
return (
|
||||
<StyledWrapper>
|
||||
@@ -54,7 +56,7 @@ const EnvironmentSettings = ({ collection, onClose }) => {
|
||||
{tab === 'create' ? (
|
||||
<CreateEnvironment collection={collection} onClose={() => setTab('default')} />
|
||||
) : tab === 'import' ? (
|
||||
<ImportEnvironment collection={collection} onClose={() => setTab('default')} />
|
||||
<ImportEnvironmentModal type="collection" collection={collection} onClose={() => setTab('default')} />
|
||||
) : (
|
||||
<DefaultTab setTab={setTab} />
|
||||
)}
|
||||
@@ -64,16 +66,26 @@ const EnvironmentSettings = ({ collection, onClose }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal size="lg" title="Environments" handleCancel={onClose} hideFooter={true}>
|
||||
<EnvironmentList
|
||||
selectedEnvironment={selectedEnvironment}
|
||||
setSelectedEnvironment={setSelectedEnvironment}
|
||||
collection={collection}
|
||||
isModified={isModified}
|
||||
setIsModified={setIsModified}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</Modal>
|
||||
<StyledWrapper>
|
||||
<Modal size="lg" title="Environments" handleCancel={onClose} hideFooter={true}>
|
||||
<EnvironmentList
|
||||
selectedEnvironment={selectedEnvironment}
|
||||
setSelectedEnvironment={setSelectedEnvironment}
|
||||
collection={collection}
|
||||
isModified={isModified}
|
||||
setIsModified={setIsModified}
|
||||
onClose={onClose}
|
||||
setShowExportModal={setShowExportModal}
|
||||
/>
|
||||
</Modal>
|
||||
{showExportModal && (
|
||||
<ExportEnvironmentModal
|
||||
onClose={() => setShowExportModal(false)}
|
||||
environments={collection.environments}
|
||||
environmentType="collection"
|
||||
/>
|
||||
)}
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { browseFiles } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { IconX } from '@tabler/icons';
|
||||
import { isWindowsOS } from 'utils/common/platform';
|
||||
|
||||
const FilePickerEditor = ({ value, onChange, collection, isSingleFilePicker = false }) => {
|
||||
const FilePickerEditor = ({ value, onChange, collection, isSingleFilePicker = false, readOnly = false }) => {
|
||||
const dispatch = useDispatch();
|
||||
const filenames = (isSingleFilePicker ? [value] : value || [])
|
||||
.filter((v) => v != null && v != '')
|
||||
@@ -50,20 +50,24 @@ const FilePickerEditor = ({ value, onChange, collection, isSingleFilePicker = fa
|
||||
return filenames.length + ' file(s) selected';
|
||||
};
|
||||
|
||||
const buttonClass = `btn btn-secondary px-1 ${readOnly ? 'view-mode' : 'edit-mode'}`;
|
||||
|
||||
return filenames.length > 0 ? (
|
||||
<div
|
||||
className="btn btn-secondary px-1"
|
||||
className={buttonClass}
|
||||
style={{ fontWeight: 400, width: '100%', textOverflow: 'ellipsis', overflowX: 'hidden' }}
|
||||
title={title}
|
||||
>
|
||||
<button className="align-middle" onClick={clear}>
|
||||
<IconX size={18} />
|
||||
</button>
|
||||
|
||||
{!readOnly && (
|
||||
<button className="align-middle" onClick={clear}>
|
||||
<IconX size={18} />
|
||||
</button>
|
||||
)}
|
||||
{!readOnly && <> </>}
|
||||
{renderButtonText(filenames)}
|
||||
</div>
|
||||
) : (
|
||||
<button className="btn btn-secondary px-1" style={{ width: '100%' }} onClick={browse}>
|
||||
<button className={buttonClass} style={{ width: '100%' }} onClick={!readOnly ? browse : undefined} disabled={readOnly}>
|
||||
{isSingleFilePicker ? 'Select File' : 'Select Files'}
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -17,7 +17,7 @@ import NTLMAuth from 'components/RequestPane/Auth/NTLMAuth';
|
||||
import WsseAuth from 'components/RequestPane/Auth/WsseAuth';
|
||||
import ApiKeyAuth from 'components/RequestPane/Auth/ApiKeyAuth';
|
||||
import AwsV4Auth from 'components/RequestPane/Auth/AwsV4Auth';
|
||||
import { humanizeRequestAuthMode, getTreePathFromCollectionToItem } from 'utils/collections/index';
|
||||
import { humanizeRequestAuthMode, getTreePathFromCollectionToItem } from 'utils/collections/index';
|
||||
|
||||
const GrantTypeComponentMap = ({ collection, folder }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -48,8 +48,6 @@ const Auth = ({ collection, folder }) => {
|
||||
let request = get(folder, 'root.request', {});
|
||||
const authMode = get(folder, 'root.request.auth.mode');
|
||||
|
||||
|
||||
|
||||
const getEffectiveAuthSource = () => {
|
||||
if (authMode !== 'inherit') return null;
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentV
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
const addButtonRef = useRef(null);
|
||||
const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector(state => state.globalEnvironments);
|
||||
const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments);
|
||||
|
||||
let _collection = cloneDeep(collection);
|
||||
|
||||
@@ -125,7 +125,7 @@ const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentV
|
||||
</thead>
|
||||
<tbody>
|
||||
{formik.values.map((variable, index) => (
|
||||
<tr key={variable.uid}>
|
||||
<tr key={variable.uid} data-testid={`env-var-row-${variable.name}`}>
|
||||
<td className="text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
|
||||
@@ -4,8 +4,9 @@ import CopyEnvironment from '../../CopyEnvironment';
|
||||
import DeleteEnvironment from '../../DeleteEnvironment';
|
||||
import RenameEnvironment from '../../RenameEnvironment';
|
||||
import EnvironmentVariables from './EnvironmentVariables';
|
||||
import ToolHint from 'components/ToolHint/index';
|
||||
|
||||
const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
|
||||
const EnvironmentDetails = ({ environment, setIsModified, collection, allEnvironments }) => {
|
||||
const [openEditModal, setOpenEditModal] = useState(false);
|
||||
const [openDeleteModal, setOpenDeleteModal] = useState(false);
|
||||
const [openCopyModal, setOpenCopyModal] = useState(false);
|
||||
@@ -29,15 +30,26 @@ const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
|
||||
<IconDatabase className="cursor-pointer" size={20} strokeWidth={1.5} />
|
||||
<span className="ml-1 font-semibold break-all">{environment.name}</span>
|
||||
</div>
|
||||
<div className="flex gap-x-4 pl-4">
|
||||
<IconEdit className="cursor-pointer" size={20} strokeWidth={1.5} onClick={() => setOpenEditModal(true)} />
|
||||
<IconCopy className="cursor-pointer" size={20} strokeWidth={1.5} onClick={() => setOpenCopyModal(true)} />
|
||||
<IconTrash className="cursor-pointer" size={20} strokeWidth={1.5} onClick={() => setOpenDeleteModal(true)} />
|
||||
<div className="flex gap-x-2 pl-2">
|
||||
<ToolHint text="Edit Environment" toolhintId={`edit-${environment.uid}`}>
|
||||
<IconEdit className="cursor-pointer" size={20} strokeWidth={1.5} onClick={() => setOpenEditModal(true)} />
|
||||
</ToolHint>
|
||||
<ToolHint text="Copy Environment" toolhintId={`copy-${environment.uid}`}>
|
||||
<IconCopy className="cursor-pointer" size={20} strokeWidth={1.5} onClick={() => setOpenCopyModal(true)} />
|
||||
</ToolHint>
|
||||
<ToolHint text="Delete Environment" toolhintId={`delete-${environment.uid}`}>
|
||||
<IconTrash className="cursor-pointer" size={20} strokeWidth={1.5} onClick={() => setOpenDeleteModal(true)} data-testid="delete-environment-button" />
|
||||
</ToolHint>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<EnvironmentVariables environment={environment} setIsModified={setIsModified} collection={collection} />
|
||||
<EnvironmentVariables
|
||||
environment={environment}
|
||||
setIsModified={setIsModified}
|
||||
collection={collection}
|
||||
allEnvironments={allEnvironments}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,15 +2,15 @@ import React, { useEffect, useState } from 'react';
|
||||
import usePrevious from 'hooks/usePrevious';
|
||||
import EnvironmentDetails from './EnvironmentDetails';
|
||||
import CreateEnvironment from '../CreateEnvironment';
|
||||
import { IconDownload, IconShieldLock } from '@tabler/icons';
|
||||
import { IconDownload, IconShieldLock, IconUpload } from '@tabler/icons';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import ConfirmSwitchEnv from './ConfirmSwitchEnv';
|
||||
import ManageSecrets from 'components/Environments/EnvironmentSettings/ManageSecrets/index';
|
||||
import ImportEnvironment from '../ImportEnvironment';
|
||||
import ImportEnvironmentModal from 'components/Environments/Common/ImportEnvironmentModal';
|
||||
import { isEqual } from 'lodash';
|
||||
import ToolHint from 'components/ToolHint/index';
|
||||
|
||||
const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironment, setSelectedEnvironment, isModified, setIsModified, collection }) => {
|
||||
const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironment, setSelectedEnvironment, isModified, setIsModified, collection, setShowExportModal }) => {
|
||||
const [openCreateModal, setOpenCreateModal] = useState(false);
|
||||
const [openImportModal, setOpenImportModal] = useState(false);
|
||||
const [openManageSecretsModal, setOpenManageSecretsModal] = useState(false);
|
||||
@@ -38,7 +38,7 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme
|
||||
return;
|
||||
}
|
||||
|
||||
const environment = environments?.find(env => env.uid === activeEnvironmentUid) || environments?.[0];
|
||||
const environment = environments?.find((env) => env.uid === activeEnvironmentUid) || environments?.[0] || null;
|
||||
|
||||
setSelectedEnvironment(environment);
|
||||
setOriginalEnvironmentVariables(environment?.variables || []);
|
||||
@@ -90,6 +90,12 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme
|
||||
setOpenManageSecretsModal(true);
|
||||
};
|
||||
|
||||
const handleExportClick = () => {
|
||||
if (setShowExportModal) {
|
||||
setShowExportModal(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmSwitch = (saveChanges) => {
|
||||
if (!saveChanges) {
|
||||
setSwitchEnvConfirmClose(false);
|
||||
@@ -99,7 +105,7 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme
|
||||
return (
|
||||
<StyledWrapper>
|
||||
{openCreateModal && <CreateEnvironment onClose={() => setOpenCreateModal(false)} />}
|
||||
{openImportModal && <ImportEnvironment onClose={() => setOpenImportModal(false)} />}
|
||||
{openImportModal && <ImportEnvironmentModal type="global" onClose={() => setOpenImportModal(false)} />}
|
||||
{openManageSecretsModal && <ManageSecrets onClose={() => setOpenManageSecretsModal(false)} />}
|
||||
|
||||
<div className="flex">
|
||||
@@ -132,6 +138,10 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme
|
||||
<IconDownload size={12} strokeWidth={2} />
|
||||
<span className="label ml-1 text-xs">Import</span>
|
||||
</div>
|
||||
<div className="flex items-center mt-2" onClick={() => handleExportClick()}>
|
||||
<IconUpload size={12} strokeWidth={2} />
|
||||
<span className="label ml-1 text-xs">Export</span>
|
||||
</div>
|
||||
<div className="flex items-center mt-2" onClick={() => handleSecretsClick()}>
|
||||
<IconShieldLock size={12} strokeWidth={2} />
|
||||
<span className="label ml-1 text-xs">Managing Secrets</span>
|
||||
@@ -144,6 +154,7 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme
|
||||
setIsModified={setIsModified}
|
||||
originalEnvironmentVariables={originalEnvironmentVariables}
|
||||
collection={collection}
|
||||
allEnvironments={environments}
|
||||
/>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import React from 'react';
|
||||
import Portal from 'components/Portal';
|
||||
import Modal from 'components/Modal';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import importPostmanEnvironment from 'utils/importers/postman-environment';
|
||||
import { toastError } from 'utils/common/error';
|
||||
import { IconDatabaseImport } from '@tabler/icons';
|
||||
import { addGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
|
||||
import { uuid } from 'utils/common/index';
|
||||
|
||||
const ImportEnvironment = ({ onClose, onEnvironmentCreated }) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const handleImportPostmanEnvironment = () => {
|
||||
importPostmanEnvironment()
|
||||
.then((environments) => {
|
||||
environments
|
||||
.filter((env) =>
|
||||
env.name && env.name !== 'undefined'
|
||||
? true
|
||||
: () => {
|
||||
toast.error('Failed to import environment: env has no name');
|
||||
return false;
|
||||
}
|
||||
)
|
||||
.map((environment) => {
|
||||
dispatch(addGlobalEnvironment({ name: environment.name, variables: environment.variables }))
|
||||
.then(() => {
|
||||
toast.success('Global Environment imported successfully');
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error('An error occurred while importing the environment');
|
||||
console.error(error);
|
||||
});
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
onClose();
|
||||
// Call the callback if provided
|
||||
if (onEnvironmentCreated) {
|
||||
onEnvironmentCreated();
|
||||
}
|
||||
})
|
||||
.catch((err) => toastError(err, 'Postman Import environment failed'));
|
||||
};
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Modal size="sm" title="Import Global Environment" hideFooter={true} handleConfirm={onClose} handleCancel={onClose} dataTestId="import-global-environment-modal">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleImportPostmanEnvironment}
|
||||
className="flex justify-center flex-col items-center w-full dark:bg-zinc-700 rounded-lg border-2 border-dashed border-zinc-300 dark:border-zinc-400 p-12 text-center hover:border-zinc-400 focus:outline-none focus:ring-2 focus:ring-amber-500 focus:ring-offset-2"
|
||||
data-testid="import-postman-global-environment"
|
||||
>
|
||||
<IconDatabaseImport size={64} />
|
||||
<span className="mt-2 block text-sm font-semibold">Import your Postman environments</span>
|
||||
</button>
|
||||
</Modal>
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImportEnvironment;
|
||||
@@ -4,7 +4,8 @@ import CreateEnvironment from './CreateEnvironment';
|
||||
import EnvironmentList from './EnvironmentList';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { IconFileAlert } from '@tabler/icons';
|
||||
import ImportEnvironment from './ImportEnvironment/index';
|
||||
import ImportEnvironmentModal from 'components/Environments/Common/ImportEnvironmentModal';
|
||||
import ExportEnvironmentModal from 'components/Environments/Common/ExportEnvironmentModal';
|
||||
|
||||
export const SharedButton = ({ children, className, onClick }) => {
|
||||
return (
|
||||
@@ -44,6 +45,7 @@ const EnvironmentSettings = ({ globalEnvironments, collection, activeGlobalEnvir
|
||||
const environments = globalEnvironments;
|
||||
const [selectedEnvironment, setSelectedEnvironment] = useState(null);
|
||||
const [tab, setTab] = useState('default');
|
||||
const [showExportModal, setShowExportModal] = useState(false);
|
||||
if (!environments || !environments.length) {
|
||||
return (
|
||||
<StyledWrapper>
|
||||
@@ -51,7 +53,7 @@ const EnvironmentSettings = ({ globalEnvironments, collection, activeGlobalEnvir
|
||||
{tab === 'create' ? (
|
||||
<CreateEnvironment onClose={() => setTab('default')} />
|
||||
) : tab === 'import' ? (
|
||||
<ImportEnvironment onClose={() => setTab('default')} />
|
||||
<ImportEnvironmentModal type="global" onClose={() => setTab('default')} />
|
||||
) : (
|
||||
<DefaultTab setTab={setTab} />
|
||||
)}
|
||||
@@ -61,17 +63,27 @@ const EnvironmentSettings = ({ globalEnvironments, collection, activeGlobalEnvir
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal size="lg" title="Global Environments" handleCancel={onClose} hideFooter={true}>
|
||||
<EnvironmentList
|
||||
environments={globalEnvironments}
|
||||
activeEnvironmentUid={activeGlobalEnvironmentUid}
|
||||
selectedEnvironment={selectedEnvironment}
|
||||
setSelectedEnvironment={setSelectedEnvironment}
|
||||
isModified={isModified}
|
||||
setIsModified={setIsModified}
|
||||
collection={collection}
|
||||
/>
|
||||
</Modal>
|
||||
<StyledWrapper>
|
||||
<Modal size="lg" title="Global Environments" handleCancel={onClose} hideFooter={true}>
|
||||
<EnvironmentList
|
||||
environments={globalEnvironments}
|
||||
activeEnvironmentUid={activeGlobalEnvironmentUid}
|
||||
selectedEnvironment={selectedEnvironment}
|
||||
setSelectedEnvironment={setSelectedEnvironment}
|
||||
isModified={isModified}
|
||||
setIsModified={setIsModified}
|
||||
collection={collection}
|
||||
setShowExportModal={setShowExportModal}
|
||||
/>
|
||||
</Modal>
|
||||
{showExportModal && (
|
||||
<ExportEnvironmentModal
|
||||
onClose={() => setShowExportModal(false)}
|
||||
environments={globalEnvironments}
|
||||
environmentType="global"
|
||||
/>
|
||||
)}
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ const GlobalSearchModal = ({ isOpen, onClose }) => {
|
||||
|
||||
if (isItemARequest(item)) {
|
||||
// add an optional check for the item name to prevent a crash if it doesn’t exist.
|
||||
const nameMatch = searchTerms.every(term => (item.name || '').toLowerCase().includes(term));
|
||||
const nameMatch = searchTerms.every((term) => (item.name || '').toLowerCase().includes(term));
|
||||
const urlMatch = searchTerms.every(term => (item.request?.url || '').toLowerCase().includes(term));
|
||||
const pathMatch = enablePathMatch && searchTerms.every(term => itemPathLower.includes(term));
|
||||
|
||||
|
||||
21
packages/bruno-app/src/components/Icons/ExampleIcon/index.js
Normal file
21
packages/bruno-app/src/components/Icons/ExampleIcon/index.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
|
||||
const ExampleIcon = ({ color = 'white', size = 16, ...props }) => {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<g clipPath="url(#clip0_486_1191)">
|
||||
<path d="M2.66699 3.33329C2.66699 3.15648 2.73723 2.98691 2.86225 2.86189C2.98728 2.73686 3.15685 2.66663 3.33366 2.66663H12.667C12.8438 2.66663 13.0134 2.73686 13.1384 2.86189C13.2634 2.98691 13.3337 3.15648 13.3337 3.33329V12.6666C13.3337 12.8434 13.2634 13.013 13.1384 13.138C13.0134 13.2631 12.8438 13.3333 12.667 13.3333H3.33366C3.15685 13.3333 2.98728 13.2631 2.86225 13.138C2.73723 13.013 2.66699 12.8434 2.66699 12.6666V3.33329Z" stroke={color} stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M9.33366 5.33337H6.66699V10.6667H9.33366" stroke={color} stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M9.33366 8H6.66699" stroke={color} stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_486_1191">
|
||||
<rect width={size} height={size} fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
);
|
||||
};
|
||||
|
||||
export default ExampleIcon;
|
||||
@@ -0,0 +1,18 @@
|
||||
import React from 'react';
|
||||
|
||||
const IconCaretDown = ({ color = '#8C8C8C', ...props }) => {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<g clipPath="url(#clip0_464_9256)">
|
||||
<path d="M10.5444 5.75H4.46004C4.26888 5.7509 4.08142 5.78521 3.91637 5.84952C3.75132 5.91383 3.61447 6.00587 3.51947 6.11647C3.42448 6.22706 3.37466 6.35234 3.375 6.47978C3.37534 6.60723 3.42583 6.73238 3.52142 6.84275L6.56492 10.23C6.66228 10.3372 6.79942 10.4258 6.96311 10.4874C7.1268 10.5489 7.31151 10.5813 7.49945 10.5814C7.68739 10.5816 7.8722 10.5494 8.03608 10.4881C8.19995 10.4267 8.33735 10.3383 8.43504 10.2312L11.4763 6.8465C11.573 6.73635 11.6246 6.61118 11.626 6.48355C11.6273 6.35591 11.5783 6.23028 11.4839 6.11924C11.3895 6.0082 11.253 5.91564 11.088 5.85084C10.9231 5.78603 10.7359 5.75126 10.5444 5.75Z" fill="#8C8C8C" />
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_464_9256">
|
||||
<rect width="9" height="6" fill="white" transform="translate(3 5)" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default IconCaretDown;
|
||||
@@ -0,0 +1,11 @@
|
||||
import React from 'react';
|
||||
|
||||
const IconCheckMark = ({ color = '#cccccc', size = 16, ...props }) => {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 16 17" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path d="M3.3335 8.49996L6.66683 11.8333L13.3335 5.16663" stroke={color} strokeWidth="1.33333" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default IconCheckMark;
|
||||
19
packages/bruno-app/src/components/Icons/IconEdit/index.js
Normal file
19
packages/bruno-app/src/components/Icons/IconEdit/index.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
|
||||
const IconEdit = ({ color = '#F39D0E', size = 16, ...props }) => {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<g clipPath="url(#clip0_464_9527)">
|
||||
<path d="M12.6665 13.3332H5.66654L2.85988 10.4665C2.73571 10.3416 2.66602 10.1727 2.66602 9.99654C2.66602 9.82042 2.73571 9.65145 2.85988 9.52654L9.52654 2.85988C9.65145 2.73571 9.82042 2.66602 9.99654 2.66602C10.1727 2.66602 10.3416 2.73571 10.4665 2.85988L13.7999 6.19321C13.924 6.31812 13.9937 6.48709 13.9937 6.66321C13.9937 6.83933 13.924 7.0083 13.7999 7.13321L7.66654 13.3332" stroke={color} strokeWidth="1.33333" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M11.9998 8.86663L7.7998 4.66663" stroke={color} strokeWidth="1.33333" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_464_9527">
|
||||
<rect width={size} height={size} fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default IconEdit;
|
||||
@@ -26,7 +26,8 @@ const ModalFooter = ({
|
||||
handleCancel,
|
||||
confirmDisabled,
|
||||
hideCancel,
|
||||
hideFooter
|
||||
hideFooter,
|
||||
confirmButtonClass = 'btn-secondary'
|
||||
}) => {
|
||||
confirmText = confirmText || 'Save';
|
||||
cancelText = cancelText || 'Cancel';
|
||||
@@ -45,7 +46,7 @@ const ModalFooter = ({
|
||||
<span>
|
||||
<button
|
||||
type="submit"
|
||||
className="submit btn btn-md btn-secondary"
|
||||
className={`submit btn btn-md ${confirmButtonClass}`}
|
||||
disabled={confirmDisabled}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
@@ -73,7 +74,8 @@ const Modal = ({
|
||||
disableEscapeKey,
|
||||
onClick,
|
||||
closeModalFadeTimeout = 500,
|
||||
dataTestId
|
||||
dataTestId,
|
||||
confirmButtonClass
|
||||
}) => {
|
||||
const modalRef = useRef(null);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
@@ -139,6 +141,7 @@ const Modal = ({
|
||||
confirmDisabled={confirmDisabled}
|
||||
hideCancel={hideCancel}
|
||||
hideFooter={hideFooter}
|
||||
confirmButtonClass={confirmButtonClass}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -9,14 +9,15 @@ const StyledWrapper = styled.div`
|
||||
&.read-only {
|
||||
.CodeMirror .CodeMirror-lines {
|
||||
cursor: not-allowed !important;
|
||||
user-select: none !important;
|
||||
-webkit-user-select: none !important;
|
||||
-ms-user-select: none !important;
|
||||
}
|
||||
|
||||
.CodeMirror-line {
|
||||
color: ${(props) => props.theme.colors.text.muted} !important;
|
||||
}
|
||||
|
||||
.CodeMirror-cursor {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.CodeMirror {
|
||||
|
||||
@@ -18,6 +18,7 @@ class MultiLineEditor extends Component {
|
||||
this.cachedValue = props.value || '';
|
||||
this.editorRef = React.createRef();
|
||||
this.variables = {};
|
||||
this.readOnly = props.readOnly || false;
|
||||
|
||||
this.state = {
|
||||
maskInput: props.isSecret || false // Always mask the input by default (if it's a secret)
|
||||
@@ -35,7 +36,7 @@ class MultiLineEditor extends Component {
|
||||
brunoVarInfo: {
|
||||
variables
|
||||
},
|
||||
readOnly: this.props.readOnly ? 'nocursor' : false,
|
||||
readOnly: this.props.readOnly,
|
||||
tabindex: 0,
|
||||
extraKeys: {
|
||||
'Ctrl-Enter': () => {
|
||||
@@ -108,8 +109,11 @@ class MultiLineEditor extends Component {
|
||||
if (!this.maskedEditor) this.maskedEditor = new MaskedEditor(this.editor, '*');
|
||||
this.maskedEditor.enable();
|
||||
} else {
|
||||
this.maskedEditor?.disable();
|
||||
this.maskedEditor = null;
|
||||
if (this.maskedEditor) {
|
||||
this.maskedEditor.disable();
|
||||
this.maskedEditor.destroy();
|
||||
this.maskedEditor = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -128,7 +132,7 @@ class MultiLineEditor extends Component {
|
||||
this.editor.setOption('theme', this.props.theme === 'dark' ? 'monokai' : 'default');
|
||||
}
|
||||
if (this.props.readOnly !== prevProps.readOnly && this.editor) {
|
||||
this.editor.setOption('readOnly', this.props.readOnly ? 'nocursor' : false);
|
||||
this.editor.setOption('readOnly', this.props.readOnly);
|
||||
}
|
||||
if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue && this.editor) {
|
||||
this.cachedValue = String(this.props.value);
|
||||
@@ -140,6 +144,9 @@ class MultiLineEditor extends Component {
|
||||
// also set the maskInput flag to the new value
|
||||
this.setState({ maskInput: this.props.isSecret });
|
||||
}
|
||||
if (this.props.readOnly !== prevProps.readOnly && this.editor) {
|
||||
this.editor.setOption('readOnly', this.props.readOnly || false);
|
||||
}
|
||||
this.ignoreChangeEvent = false;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.radio-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.radio-input {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid ${(props) => props.theme.colors.text.muted};
|
||||
border-radius: 50%;
|
||||
background-color: transparent;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
margin: 0;
|
||||
|
||||
&:checked {
|
||||
border-color: ${(props) => props.theme.colors.text.yellow};
|
||||
background-color: transparent;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
background-color: ${(props) => props.theme.colors.text.yellow};
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
border-color: ${(props) => props.theme.colors.text.muted};
|
||||
background-color: transparent;
|
||||
|
||||
&:checked {
|
||||
border-color: ${(props) => props.theme.colors.text.muted};
|
||||
|
||||
&::after {
|
||||
background-color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.radio-label {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
pointer-events: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
40
packages/bruno-app/src/components/RadioButton/index.js
Normal file
40
packages/bruno-app/src/components/RadioButton/index.js
Normal file
@@ -0,0 +1,40 @@
|
||||
import React from 'react';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const RadioButton = ({
|
||||
checked,
|
||||
disabled = false,
|
||||
onChange,
|
||||
name,
|
||||
value,
|
||||
id,
|
||||
className = '',
|
||||
dataTestId = 'radio-button'
|
||||
}) => {
|
||||
const handleChange = (e) => {
|
||||
if (!disabled && onChange) {
|
||||
onChange(e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className={`radio-container ${className}`}>
|
||||
<input
|
||||
type="radio"
|
||||
id={id}
|
||||
name={name}
|
||||
value={value}
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={handleChange}
|
||||
className="radio-input"
|
||||
data-testid={dataTestId}
|
||||
/>
|
||||
<label htmlFor={id} className="radio-label" />
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default RadioButton;
|
||||
@@ -169,7 +169,6 @@ const AssertionRow = ({
|
||||
<SingleLineEditor
|
||||
value={value}
|
||||
theme={storedTheme}
|
||||
readOnly={true}
|
||||
onSave={onSave}
|
||||
onChange={(newValue) => {
|
||||
handleAssertionChange(
|
||||
|
||||
@@ -6,9 +6,9 @@ import { updateRequestGraphqlVariables } from 'providers/ReduxStore/slices/colle
|
||||
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { format, applyEdits } from 'jsonc-parser';
|
||||
import { IconWand } from '@tabler/icons';
|
||||
import toast from 'react-hot-toast';
|
||||
import { prettifyJsonString } from 'utils/common/index';
|
||||
|
||||
const GraphQLVariables = ({ variables, item, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -19,8 +19,7 @@ const GraphQLVariables = ({ variables, item, collection }) => {
|
||||
const onPrettify = () => {
|
||||
if (!variables) return;
|
||||
try {
|
||||
const edits = format(variables, undefined, { tabSize: 2, insertSpaces: true });
|
||||
const prettyVariables = applyEdits(variables, edits);
|
||||
const prettyVariables = prettifyJsonString(variables);
|
||||
dispatch(
|
||||
updateRequestGraphqlVariables({
|
||||
variables: prettyVariables,
|
||||
|
||||
@@ -12,9 +12,9 @@ import StyledWrapper from './StyledWrapper';
|
||||
import { IconSend, IconRefresh, IconWand, IconPlus, IconTrash, IconChevronDown, IconChevronUp } from '@tabler/icons';
|
||||
import ToolHint from 'components/ToolHint/index';
|
||||
import { toastError } from 'utils/common/error';
|
||||
import { format, applyEdits } from 'jsonc-parser';
|
||||
import toast from 'react-hot-toast'
|
||||
import { getAbsoluteFilePath } from 'utils/common/path';
|
||||
import { prettifyJsonString } from 'utils/common/index';
|
||||
|
||||
const SingleGrpcMessage = ({ message, item, collection, index, methodType, isCollapsed, onToggleCollapse, handleRun, canClientSendMultipleMessages }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -130,8 +130,7 @@ const SingleGrpcMessage = ({ message, item, collection, index, methodType, isCol
|
||||
|
||||
const onPrettify = () => {
|
||||
try {
|
||||
const edits = format(content, undefined, { tabSize: 2, insertSpaces: true });
|
||||
const prettyBodyJson = applyEdits(content, edits);
|
||||
const prettyBodyJson = prettifyJsonString(content);
|
||||
|
||||
const currentMessages = [...(body.grpc || [])];
|
||||
currentMessages[index] = {
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import React, { forwardRef } from 'react';
|
||||
import { IconChevronDown } from '@tabler/icons';
|
||||
import Dropdown from 'components/Dropdown/index';
|
||||
import {
|
||||
IconGrpcUnary,
|
||||
IconGrpcBidiStreaming,
|
||||
IconGrpcClientStreaming,
|
||||
IconGrpcServerStreaming,
|
||||
IconGrpcBidiStreaming
|
||||
IconGrpcUnary
|
||||
} from 'components/Icons/Grpc';
|
||||
import SearchInput from 'components/SearchInput/index';
|
||||
import { search } from 'fast-fuzzy';
|
||||
import React, { forwardRef, useEffect, useRef, useState } from 'react';
|
||||
|
||||
const MethodDropdown = ({
|
||||
grpcMethods,
|
||||
@@ -14,6 +16,19 @@ const MethodDropdown = ({
|
||||
onMethodSelect,
|
||||
onMethodDropdownCreate
|
||||
}) => {
|
||||
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [focusedIndex, setFocusedIndex] = useState(-1);
|
||||
const searchInputRef = useRef();
|
||||
const listRef = useRef();
|
||||
|
||||
useEffect(() => {
|
||||
const activeItem = listRef.current?.querySelector(`[data-index="${focusedIndex}"]`);
|
||||
if (activeItem) {
|
||||
activeItem.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}, [focusedIndex]);
|
||||
|
||||
const groupMethodsByService = (methods) => {
|
||||
if (!methods || !methods.length) return {};
|
||||
|
||||
@@ -62,7 +77,7 @@ const MethodDropdown = ({
|
||||
{selectedGrpcMethod && <div className="mr-2">{getIconForMethodType(selectedGrpcMethod.type)}</div>}
|
||||
<span className="text-xs">
|
||||
{selectedGrpcMethod ? (
|
||||
<span className="dark:text-neutral-300 text-neutral-700 text-nowrap">
|
||||
<span className="dark:text-neutral-300 text-neutral-700 text-nowrap" data-testid="selected-grpc-method-name">
|
||||
{selectedGrpcMethod.path.split('.').at(-1) || selectedGrpcMethod.path}
|
||||
</span>
|
||||
) : (
|
||||
@@ -79,49 +94,125 @@ const MethodDropdown = ({
|
||||
onMethodSelect({ path: method.path, type: methodType });
|
||||
};
|
||||
|
||||
const filteredMethods = searchText ? search(String(searchText), grpcMethods, { keySelector: (obj) => obj.path }) : grpcMethods;
|
||||
|
||||
const groupedMethods = groupMethodsByService(filteredMethods);
|
||||
|
||||
// Flatten grouped methods for keyboard navigation
|
||||
const flatMethodList = Object.values(groupedMethods).flat();
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (!flatMethodList.length) return;
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setFocusedIndex((prev) =>
|
||||
prev < flatMethodList.length - 1 ? prev + 1 : flatMethodList.length - 1);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setFocusedIndex((prev) =>
|
||||
prev >= 0 ? prev - 1 : -1);
|
||||
} else if (e.key === 'Enter' && focusedIndex >= 0) {
|
||||
e.preventDefault();
|
||||
handleGrpcMethodSelect(flatMethodList[focusedIndex]);
|
||||
}
|
||||
};
|
||||
|
||||
const focusSearchInput = () => {
|
||||
setTimeout(() => {
|
||||
if (searchInputRef.current) {
|
||||
searchInputRef.current.focus();
|
||||
}
|
||||
}, 0); // 0ms to ensure the dropdown is fully rendered and focused
|
||||
};
|
||||
|
||||
const handleDropdownShow = () => {
|
||||
focusSearchInput();
|
||||
setSearchText('');
|
||||
setFocusedIndex(-1);
|
||||
};
|
||||
|
||||
const handleSearchChange = (e) => {
|
||||
// auto focus the first method when the search input is not empty
|
||||
if (e.target.value.trim().length > 0) {
|
||||
setFocusedIndex(0);
|
||||
} else {
|
||||
setFocusedIndex(-1);
|
||||
}
|
||||
};
|
||||
|
||||
if (!grpcMethods || grpcMethods.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center h-full mr-2" data-testid="grpc-methods-dropdown">
|
||||
<Dropdown onCreate={onMethodDropdownCreate} icon={<MethodsDropdownIcon />} placement="bottom-end" style={{ maxWidth: 'unset' }}>
|
||||
<div className="max-h-96 overflow-y-auto max-w-96 min-w-60" data-testid="grpc-methods-list">
|
||||
{Object.entries(groupMethodsByService(grpcMethods)).map(([serviceName, methods], serviceIndex) => (
|
||||
<div key={serviceIndex} className="service-group mb-2">
|
||||
<Dropdown onCreate={onMethodDropdownCreate} icon={<MethodsDropdownIcon />} placement="bottom-end" style={{ maxWidth: 'unset' }} onShow={handleDropdownShow}>
|
||||
<SearchInput
|
||||
searchText={searchText}
|
||||
setSearchText={setSearchText}
|
||||
placeholder="Search"
|
||||
ref={searchInputRef}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={focusSearchInput}
|
||||
onChange={handleSearchChange}
|
||||
className="mt-2 mb-3 "
|
||||
data-testid="grpc-methods-search-input"
|
||||
/>
|
||||
<div ref={listRef} className="max-h-96 overflow-y-auto w-96 min-w-60" data-testid="grpc-methods-list">
|
||||
{Object.entries(groupedMethods).map(([serviceName, methods], serviceIndex) => (
|
||||
<div key={serviceIndex} className="service-group mb-2" onKeyDown={handleKeyDown} tabIndex={0}>
|
||||
<div className="service-header px-3 py-1 bg-neutral-100 dark:bg-neutral-800 text-sm font-medium truncate sticky top-0 z-10">
|
||||
{serviceName || 'Default Service'}
|
||||
</div>
|
||||
<div className="service-methods">
|
||||
{methods.map((method, methodIndex) => (
|
||||
<div
|
||||
key={`${serviceIndex}-${methodIndex}`}
|
||||
className={`py-2 px-3 w-full border-l-2 transition-all duration-200 relative group ${
|
||||
selectedGrpcMethod && selectedGrpcMethod.path === method.path
|
||||
? 'border-yellow-500 bg-yellow-500/20 dark:bg-yellow-900/20'
|
||||
: 'border-transparent hover:bg-black/5 dark:hover:bg-white/5'
|
||||
}`}
|
||||
onClick={() => handleGrpcMethodSelect(method)}
|
||||
data-testid="grpc-method-item"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className="text-xs mr-3 text-gray-500">
|
||||
{getIconForMethodType(method.type)}
|
||||
</div>
|
||||
<div className="flex flex-col flex-1">
|
||||
<div className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{method.methodName}
|
||||
{methods.map((method, methodIndex) => {
|
||||
const globalMethodIndex
|
||||
= Object.values(groupedMethods)
|
||||
.slice(0, serviceIndex)
|
||||
.reduce((acc, group) => acc + group.length, 0) + methodIndex;
|
||||
return (
|
||||
<div
|
||||
key={`${serviceIndex}-${methodIndex}`}
|
||||
className={`py-2 px-3 w-full border-l-2 transition-all duration-200 relative group ${
|
||||
selectedGrpcMethod && selectedGrpcMethod.path === method.path
|
||||
? 'border-yellow-500 bg-yellow-500/20 dark:bg-yellow-900/20'
|
||||
: 'border-transparent hover:bg-black/5 dark:hover:bg-white/5'
|
||||
} ${focusedIndex === globalMethodIndex
|
||||
? 'bg-black/5 dark:bg-white/5' : ''}`}
|
||||
onClick={() => handleGrpcMethodSelect(method)}
|
||||
data-index={globalMethodIndex}
|
||||
data-testid="grpc-method-item"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className="text-xs mr-3 text-gray-500">
|
||||
{getIconForMethodType(method.type)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{method.type}
|
||||
<div className="flex flex-col flex-1">
|
||||
<div className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{method.methodName}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{method.type}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{filteredMethods.length === 0 && (
|
||||
<div className="py-2 px-3 w-full transition-all duration-200 relative group">
|
||||
<div className="flex items-center">
|
||||
<div className="text-xs mr-3 text-gray-500">
|
||||
No methods found for the search term
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
@@ -346,6 +346,7 @@ const GrpcQueryUrl = ({ item, collection, handleRun }) => {
|
||||
strokeWidth={1.5}
|
||||
size={22}
|
||||
className={`${(isReflectionMode ? reflectionManagement.isLoadingMethods : protoFileManagement.isLoadingMethods) ? 'animate-spin' : 'cursor-pointer'}`}
|
||||
data-testid="refresh-methods-icon"
|
||||
/>
|
||||
<span className="infotip-text text-xs">
|
||||
{isReflectionMode ? 'Refresh server reflection' : 'Refresh proto file methods'}
|
||||
@@ -407,6 +408,7 @@ const GrpcQueryUrl = ({ item, collection, handleRun }) => {
|
||||
{(!isConnectionActive || !isStreamingMethod) && (
|
||||
<div
|
||||
className="cursor-pointer"
|
||||
data-testid="grpc-send-request-button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRun(e);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { requestUrlChanged, updateRequestMethod } from 'providers/ReduxStore/slices/collections';
|
||||
@@ -8,6 +8,7 @@ import { useTheme } from 'providers/Theme';
|
||||
import { IconDeviceFloppy, IconArrowRight, IconCode } from '@tabler/icons';
|
||||
import SingleLineEditor from 'components/SingleLineEditor';
|
||||
import { isMacOS } from 'utils/common/platform';
|
||||
import { hasRequestChanges } from 'utils/collections';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import GenerateCodeItem from 'components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/index';
|
||||
import toast from 'react-hot-toast';
|
||||
@@ -24,6 +25,7 @@ const QueryUrl = ({ item, collection, handleRun }) => {
|
||||
|
||||
const [methodSelectorWidth, setMethodSelectorWidth] = useState(90);
|
||||
const [generateCodeItemModalOpen, setGenerateCodeItemModalOpen] = useState(false);
|
||||
const hasChanges = useMemo(() => hasRequestChanges(item), [item]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = document.querySelector('.method-selector-container');
|
||||
@@ -133,15 +135,15 @@ const QueryUrl = ({ item, collection, handleRun }) => {
|
||||
className="infotip mr-3"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!item.draft) return;
|
||||
if (!hasChanges) return;
|
||||
onSave();
|
||||
}}
|
||||
>
|
||||
<IconDeviceFloppy
|
||||
color={item.draft ? theme.colors.text.yellow : theme.requestTabs.icon.color}
|
||||
color={hasChanges ? theme.colors.text.yellow : theme.requestTabs.icon.color}
|
||||
strokeWidth={1.5}
|
||||
size={22}
|
||||
className={`${item.draft ? 'cursor-pointer' : 'cursor-default'}`}
|
||||
className={`${hasChanges ? 'cursor-pointer' : 'cursor-default'}`}
|
||||
/>
|
||||
<span className="infotiptext text-xs">
|
||||
Save <span className="shortcut">({saveShortcut})</span>
|
||||
|
||||
@@ -8,7 +8,7 @@ import { humanizeRequestBodyMode } from 'utils/collections';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { updateRequestBody } from 'providers/ReduxStore/slices/collections/index';
|
||||
import { toastError } from 'utils/common/error';
|
||||
import { prettifyJSON } from 'utils/common';
|
||||
import { prettifyJsonString } from 'utils/common/index';
|
||||
import xmlFormat from 'xml-formatter';
|
||||
|
||||
const RequestBodyMode = ({ item, collection }) => {
|
||||
@@ -39,7 +39,7 @@ const RequestBodyMode = ({ item, collection }) => {
|
||||
const onPrettify = () => {
|
||||
if (body?.json && bodyMode === 'json') {
|
||||
try {
|
||||
const prettyBodyJson = prettifyJSON(body.json);
|
||||
const prettyBodyJson = prettifyJsonString(body.json);
|
||||
dispatch(
|
||||
updateRequestBody({
|
||||
content: prettyBodyJson,
|
||||
|
||||
@@ -10,7 +10,7 @@ import React, { useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { autoDetectLang } from 'utils/codemirror/lang-detect';
|
||||
import { toastError } from 'utils/common/error';
|
||||
import { prettifyJSON } from 'utils/common/index';
|
||||
import { prettifyJsonString } from 'utils/common/index';
|
||||
import xmlFormat from 'xml-formatter';
|
||||
import WSRequestBodyMode from '../BodyMode/index';
|
||||
|
||||
@@ -105,7 +105,7 @@ export const SingleWSMessage = ({
|
||||
const onPrettify = () => {
|
||||
if (codeType === 'json') {
|
||||
try {
|
||||
const prettyBodyJson = prettifyJSON(content);
|
||||
const prettyBodyJson = prettifyJsonString(content);
|
||||
const currentMessages = [...(body.ws || [])];
|
||||
currentMessages[index] = {
|
||||
...currentMessages[index],
|
||||
|
||||
@@ -5,11 +5,12 @@ import SingleLineEditor from 'components/SingleLineEditor/index';
|
||||
import { requestUrlChanged } from 'providers/ReduxStore/slices/collections';
|
||||
import { wsConnectOnly, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { getPropertyFromDraftOrRequest } from 'utils/collections';
|
||||
import { isMacOS } from 'utils/common/platform';
|
||||
import { hasRequestChanges } from 'utils/collections';
|
||||
import { closeWsConnection, isWsConnectionActive } from 'utils/network/index';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import get from 'lodash/get';
|
||||
@@ -23,6 +24,7 @@ const WsQueryUrl = ({ item, collection, handleRun }) => {
|
||||
const url = getPropertyFromDraftOrRequest(item, 'request.url');
|
||||
const response = item.draft ? get(item, 'draft.response', {}) : get(item, 'response', {});
|
||||
const saveShortcut = isMacOS() ? '⌘S' : 'Ctrl+S';
|
||||
const hasChanges = useMemo(() => hasRequestChanges(item), [item]);
|
||||
|
||||
const showConnectingPulse = isConnecting && response.status !== 'CLOSED';
|
||||
|
||||
@@ -108,15 +110,15 @@ const WsQueryUrl = ({ item, collection, handleRun }) => {
|
||||
className="infotip mr-3"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!item.draft) return;
|
||||
if (!hasChanges) return;
|
||||
onSave();
|
||||
}}
|
||||
>
|
||||
<IconDeviceFloppy
|
||||
color={item.draft ? theme.colors.text.yellow : theme.requestTabs.icon.color}
|
||||
color={hasChanges ? theme.colors.text.yellow : theme.requestTabs.icon.color}
|
||||
strokeWidth={1.5}
|
||||
size={22}
|
||||
className={`${item.draft ? 'cursor-pointer' : 'cursor-default'}`}
|
||||
className={`${hasChanges ? 'cursor-pointer' : 'cursor-default'}`}
|
||||
/>
|
||||
<span className="infotip-text text-xs">
|
||||
Save <span className="shortcut">({saveShortcut})</span>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { closeTabs } from 'providers/ReduxStore/slices/tabs';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
const ExampleNotFound = ({ exampleUid }) => {
|
||||
const dispatch = useDispatch();
|
||||
const [showErrorMessage, setShowErrorMessage] = useState(false);
|
||||
|
||||
const closeTab = () => {
|
||||
dispatch(closeTabs({
|
||||
tabUids: [exampleUid]
|
||||
}));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
setShowErrorMessage(true);
|
||||
}, 300);
|
||||
}, []);
|
||||
|
||||
if (!showErrorMessage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-6 px-6">
|
||||
<div className="p-4 bg-orange-100 border-l-4 border-yellow-500 text-yellow-700">
|
||||
<div>Response example no longer exists.</div>
|
||||
<div className="mt-2">
|
||||
This can occur when the example definition in your local file has been deleted or updated.
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn btn-md btn-secondary mt-6" onClick={closeTab}>
|
||||
Close Tab
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExampleNotFound;
|
||||
@@ -29,9 +29,11 @@ import CollectionOverview from 'components/CollectionSettings/Overview';
|
||||
import RequestNotLoaded from './RequestNotLoaded';
|
||||
import RequestIsLoading from './RequestIsLoading';
|
||||
import FolderNotFound from './FolderNotFound';
|
||||
import ExampleNotFound from './ExampleNotFound';
|
||||
import WsQueryUrl from 'components/RequestPane/WsQueryUrl';
|
||||
import WSRequestPane from 'components/RequestPane/WSRequestPane';
|
||||
import WSResponsePane from 'components/ResponsePane/WsResponsePane';
|
||||
import ResponseExample from 'components/ResponseExample';
|
||||
|
||||
const MIN_LEFT_PANE_WIDTH = 300;
|
||||
const MIN_RIGHT_PANE_WIDTH = 350;
|
||||
@@ -186,6 +188,16 @@ const RequestTabPanel = () => {
|
||||
return <div className="pb-4 px-4">Collection not found!</div>;
|
||||
}
|
||||
|
||||
if (focusedTab.type === 'response-example') {
|
||||
const item = findItemInCollection(collection, focusedTab.itemUid);
|
||||
const example = item?.examples?.find((ex) => ex.uid === focusedTab.uid);
|
||||
|
||||
if (!example) {
|
||||
return <ExampleNotFound itemUid={focusedTab.itemUid} exampleUid={focusedTab.uid} />;
|
||||
}
|
||||
return <ResponseExample item={item} collection={collection} example={example} />;
|
||||
}
|
||||
|
||||
const item = findItemInCollection(collection, activeTabUid);
|
||||
const isGrpcRequest = item?.type === 'grpc-request';
|
||||
const isWsRequest = item?.type === 'ws-request';
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import React, { useState, useRef, useMemo } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { closeTabs, makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
|
||||
import { deleteRequestDraft } from 'providers/ReduxStore/slices/collections';
|
||||
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { hasExampleChanges, findItemInCollection } from 'utils/collections';
|
||||
import ExampleIcon from 'components/Icons/ExampleIcon';
|
||||
import ConfirmRequestClose from '../RequestTab/ConfirmRequestClose';
|
||||
import RequestTabNotFound from '../RequestTab/RequestTabNotFound';
|
||||
import StyledWrapper from '../RequestTab/StyledWrapper';
|
||||
import CloseTabIcon from '../RequestTab/CloseTabIcon';
|
||||
import DraftTabIcon from '../RequestTab/DraftTabIcon';
|
||||
|
||||
const ExampleTab = ({ tab, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const [showConfirmClose, setShowConfirmClose] = useState(false);
|
||||
|
||||
const dropdownTippyRef = useRef();
|
||||
|
||||
// Get item and example data
|
||||
const item = findItemInCollection(collection, tab.itemUid);
|
||||
const example = useMemo(() => item?.examples?.find((ex) => ex.uid === tab.uid), [item?.examples, tab.uid]);
|
||||
|
||||
const hasChanges = useMemo(() => hasExampleChanges(item, tab.uid), [item, tab.uid]);
|
||||
|
||||
const handleCloseClick = (event) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
dispatch(closeTabs({
|
||||
tabUids: [tab.uid]
|
||||
}));
|
||||
};
|
||||
|
||||
const handleRightClick = (_event) => {
|
||||
const menuDropdown = dropdownTippyRef.current;
|
||||
if (!menuDropdown) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (menuDropdown.state.isShown) {
|
||||
menuDropdown.hide();
|
||||
} else {
|
||||
menuDropdown.show();
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = (e) => {
|
||||
if (e.button === 1) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Close the tab
|
||||
dispatch(closeTabs({
|
||||
tabUids: [tab.uid]
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
if (!item || !example) {
|
||||
return (
|
||||
<StyledWrapper
|
||||
className="flex items-center justify-between tab-container px-1"
|
||||
onMouseUp={(e) => {
|
||||
if (e.button === 1) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
dispatch(closeTabs({ tabUids: [tab.uid] }));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<RequestTabNotFound handleCloseClick={handleCloseClick} />
|
||||
</StyledWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledWrapper className="flex items-center justify-between tab-container px-1">
|
||||
{showConfirmClose && (
|
||||
<ConfirmRequestClose
|
||||
item={item}
|
||||
example={example}
|
||||
onCancel={() => setShowConfirmClose(false)}
|
||||
onCloseWithoutSave={() => {
|
||||
dispatch(deleteRequestDraft({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid
|
||||
}));
|
||||
dispatch(closeTabs({
|
||||
tabUids: [tab.uid]
|
||||
}));
|
||||
setShowConfirmClose(false);
|
||||
}}
|
||||
onSaveAndClose={() => {
|
||||
// For examples, we don't have a separate save action
|
||||
// The changes are saved automatically when the request is saved
|
||||
dispatch(saveRequest(item.uid, collection.uid));
|
||||
dispatch(closeTabs({
|
||||
tabUids: [tab.uid]
|
||||
}));
|
||||
setShowConfirmClose(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={`flex items-center tab-label pl-2 ${tab.preview ? 'italic' : ''}`}
|
||||
onContextMenu={handleRightClick}
|
||||
onDoubleClick={() => dispatch(makeTabPermanent({ uid: tab.uid }))}
|
||||
onMouseUp={(e) => {
|
||||
if (!hasChanges) return handleMouseUp(e);
|
||||
|
||||
if (e.button === 1) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setShowConfirmClose(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ExampleIcon size={16} color="currentColor" className="mr-2 text-gray-500 flex-shrink-0" />
|
||||
<span className="tab-name" title={example.name}>
|
||||
{example.name}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="flex px-2 close-icon-container"
|
||||
onClick={(e) => {
|
||||
if (!hasChanges) {
|
||||
return handleCloseClick(e);
|
||||
}
|
||||
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setShowConfirmClose(true);
|
||||
}}
|
||||
>
|
||||
{!hasChanges ? (
|
||||
<CloseTabIcon />
|
||||
) : (
|
||||
<DraftTabIcon />
|
||||
)}
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExampleTab;
|
||||
@@ -2,7 +2,11 @@ import React from 'react';
|
||||
import { IconAlertTriangle } from '@tabler/icons';
|
||||
import Modal from 'components/Modal';
|
||||
|
||||
const ConfirmRequestClose = ({ item, onCancel, onCloseWithoutSave, onSaveAndClose }) => {
|
||||
const ConfirmRequestClose = ({ item, example, onCancel, onCloseWithoutSave, onSaveAndClose }) => {
|
||||
const isExample = !!example;
|
||||
const itemName = isExample ? example.name : item.name;
|
||||
const itemType = isExample ? 'example' : 'request';
|
||||
|
||||
return (
|
||||
<Modal
|
||||
size="md"
|
||||
@@ -24,7 +28,7 @@ const ConfirmRequestClose = ({ item, onCancel, onCloseWithoutSave, onSaveAndClos
|
||||
<h1 className="ml-2 text-lg font-semibold">Hold on..</h1>
|
||||
</div>
|
||||
<div className="font-normal mt-4">
|
||||
You have unsaved changes in request <span className="font-semibold">{item.name}</span>.
|
||||
You have unsaved changes in {itemType} <span className="font-semibold">{itemName}</span>.
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between mt-6">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useState, useRef, Fragment } from 'react';
|
||||
import React, { useCallback, useState, useRef, Fragment, useMemo } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { closeTabs, makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
|
||||
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
@@ -7,7 +7,7 @@ import { useTheme } from 'providers/Theme';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import darkTheme from 'themes/dark';
|
||||
import lightTheme from 'themes/light';
|
||||
import { findItemInCollection } from 'utils/collections';
|
||||
import { findItemInCollection, hasRequestChanges } from 'utils/collections';
|
||||
import ConfirmRequestClose from './ConfirmRequestClose';
|
||||
import RequestTabNotFound from './RequestTabNotFound';
|
||||
import SpecialTab from './SpecialTab';
|
||||
@@ -19,6 +19,7 @@ import CloseTabIcon from './CloseTabIcon';
|
||||
import DraftTabIcon from './DraftTabIcon';
|
||||
import { flattenItems } from 'utils/collections/index';
|
||||
import { closeWsConnection } from 'utils/network/index';
|
||||
import ExampleTab from '../ExampleTab';
|
||||
|
||||
const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUid }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -29,6 +30,8 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
|
||||
const dropdownTippyRef = useRef();
|
||||
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
|
||||
|
||||
const item = findItemInCollection(collection, tab.uid);
|
||||
|
||||
const handleCloseClick = (event) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
@@ -92,7 +95,19 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
|
||||
);
|
||||
}
|
||||
|
||||
const item = findItemInCollection(collection, tab.uid);
|
||||
// Handle response-example tabs specially
|
||||
if (tab.type === 'response-example') {
|
||||
return (
|
||||
<ExampleTab
|
||||
tab={tab}
|
||||
collection={collection}
|
||||
tabIndex={tabIndex}
|
||||
collectionRequestTabs={collectionRequestTabs}
|
||||
folderUid={folderUid}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const getMethodText = useCallback((item) => {
|
||||
if (!item) return;
|
||||
@@ -109,6 +124,8 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
|
||||
}
|
||||
}, [item]);
|
||||
|
||||
const hasChanges = useMemo(() => hasRequestChanges(item), [item]);
|
||||
|
||||
if (!item) {
|
||||
return (
|
||||
<StyledWrapper
|
||||
@@ -172,7 +189,7 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
|
||||
onContextMenu={handleRightClick}
|
||||
onDoubleClick={() => dispatch(makeTabPermanent({ uid: tab.uid }))}
|
||||
onMouseUp={(e) => {
|
||||
if (!item.draft) return handleMouseUp(e);
|
||||
if (!hasChanges) return handleMouseUp(e);
|
||||
|
||||
if (e.button === 1) {
|
||||
e.stopPropagation();
|
||||
@@ -200,7 +217,7 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
|
||||
<div
|
||||
className="flex px-2 close-icon-container"
|
||||
onClick={(e) => {
|
||||
if (!item.draft) {
|
||||
if (!hasChanges) {
|
||||
isWS && closeWsConnection(item.uid);
|
||||
return handleCloseClick(e);
|
||||
};
|
||||
@@ -210,7 +227,7 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
|
||||
setShowConfirmClose(true);
|
||||
}}
|
||||
>
|
||||
{!item.draft ? (
|
||||
{!hasChanges ? (
|
||||
<CloseTabIcon />
|
||||
) : (
|
||||
<DraftTabIcon />
|
||||
@@ -227,6 +244,7 @@ function RequestTabMenu({ onDropdownCreate, collectionRequestTabs, tabIndex, col
|
||||
const totalTabs = collectionRequestTabs.length || 0;
|
||||
const currentTabUid = collectionRequestTabs[tabIndex]?.uid;
|
||||
const currentTabItem = findItemInCollection(collection, currentTabUid);
|
||||
const currentTabHasChanges = useMemo(() => hasRequestChanges(currentTabItem), [currentTabItem]);
|
||||
|
||||
const hasLeftTabs = tabIndex !== 0;
|
||||
const hasRightTabs = totalTabs > tabIndex + 1;
|
||||
@@ -243,7 +261,7 @@ function RequestTabMenu({ onDropdownCreate, collectionRequestTabs, tabIndex, col
|
||||
try {
|
||||
const item = findItemInCollection(collection, tabUid);
|
||||
// silently save unsaved changes before closing the tab
|
||||
if (item.draft) {
|
||||
if (hasRequestChanges(item)) {
|
||||
await dispatch(saveRequest(item.uid, collection.uid, true));
|
||||
}
|
||||
|
||||
@@ -251,7 +269,6 @@ function RequestTabMenu({ onDropdownCreate, collectionRequestTabs, tabIndex, col
|
||||
} catch (err) {}
|
||||
}
|
||||
|
||||
|
||||
function handleRevertChanges(event) {
|
||||
event.stopPropagation();
|
||||
dropdownTippyRef.current.hide();
|
||||
@@ -263,12 +280,10 @@ function RequestTabMenu({ onDropdownCreate, collectionRequestTabs, tabIndex, col
|
||||
try {
|
||||
const item = findItemInCollection(collection, currentTabUid);
|
||||
if (item.draft) {
|
||||
dispatch(
|
||||
deleteRequestDraft({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid
|
||||
})
|
||||
);
|
||||
dispatch(deleteRequestDraft({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid
|
||||
}));
|
||||
}
|
||||
} catch (err) {}
|
||||
}
|
||||
@@ -298,7 +313,7 @@ function RequestTabMenu({ onDropdownCreate, collectionRequestTabs, tabIndex, col
|
||||
event.stopPropagation();
|
||||
|
||||
const items = flattenItems(collection?.items);
|
||||
const savedTabs = items?.filter?.((item) => !item.draft);
|
||||
const savedTabs = items?.filter?.((item) => !hasRequestChanges(item));
|
||||
const savedTabIds = savedTabs?.map((item) => item.uid) || [];
|
||||
dispatch(closeTabs({ tabUids: savedTabIds }));
|
||||
}
|
||||
@@ -340,7 +355,7 @@ function RequestTabMenu({ onDropdownCreate, collectionRequestTabs, tabIndex, col
|
||||
>
|
||||
Clone Request
|
||||
</button>
|
||||
<button
|
||||
<button
|
||||
className="dropdown-item w-full"
|
||||
onClick={handleRevertChanges}
|
||||
disabled={!currentTabItem?.draft}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import Modal from 'components/Modal';
|
||||
import Portal from 'components/Portal';
|
||||
|
||||
const CreateExampleModal = ({ isOpen, onClose, onSave, title = 'Create Response Example', initialName = '' }) => {
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [nameError, setNameError] = useState('');
|
||||
|
||||
const handleNameChange = (e) => {
|
||||
setName(e.target.value);
|
||||
// Clear error when user starts typing
|
||||
if (nameError) {
|
||||
setNameError('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (name.trim()) {
|
||||
onSave(name.trim(), description.trim());
|
||||
// Reset form
|
||||
setName('');
|
||||
setDescription('');
|
||||
setNameError('');
|
||||
} else {
|
||||
setNameError('Example name is required');
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
// Reset form when closing
|
||||
setName('');
|
||||
setDescription('');
|
||||
setNameError('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setName(initialName);
|
||||
setDescription('');
|
||||
setNameError('');
|
||||
}
|
||||
}, [isOpen, initialName]);
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Modal
|
||||
size="md"
|
||||
title={title}
|
||||
handleCancel={handleClose}
|
||||
handleConfirm={handleConfirm}
|
||||
confirmText="Create Example"
|
||||
cancelText="Cancel"
|
||||
isOpen={isOpen}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="exampleName" className="block font-semibold">
|
||||
Example Name<span className="text-red-600">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="exampleName"
|
||||
type="text"
|
||||
className="textbox mt-2 w-full"
|
||||
value={name}
|
||||
onChange={handleNameChange}
|
||||
autoFocus
|
||||
required
|
||||
data-testid="create-example-name-input"
|
||||
/>
|
||||
{nameError && (
|
||||
<div className="text-red-500 text-sm mt-1" data-testid="name-error">
|
||||
{nameError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="exampleDescription" className="block font-semibold">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="exampleDescription"
|
||||
className="textbox mt-2 w-full"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
data-testid="create-example-description-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateExampleModal;
|
||||
@@ -0,0 +1,80 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
|
||||
.title {
|
||||
font-weight: 700;
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
font-size: 0.8125rem;
|
||||
|
||||
.body-mode-selector {
|
||||
background: transparent;
|
||||
border-radius: 3px;
|
||||
|
||||
.dropdown-item {
|
||||
padding: 0.2rem 0.6rem !important;
|
||||
padding-left: 1.5rem !important;
|
||||
}
|
||||
|
||||
.label-item {
|
||||
padding: 0.2rem 0.6rem !important;
|
||||
}
|
||||
|
||||
.selected-body-mode {
|
||||
color: ${(props) => props.theme.colors.text.yellow};
|
||||
}
|
||||
|
||||
&.cursor-default {
|
||||
opacity: 0.6;
|
||||
|
||||
.selected-body-mode {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.caret {
|
||||
color: rgb(140, 140, 140);
|
||||
fill: rgb(140 140 140);
|
||||
}
|
||||
|
||||
.btn-action {
|
||||
border-radius: 3px;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
|
||||
&:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.no-body-text {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
/* CodeEditor container */
|
||||
.code-editor-container {
|
||||
flex: 1;
|
||||
min-height: 200px;
|
||||
height: 200px;
|
||||
border-top: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,80 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import get from 'lodash/get';
|
||||
import { updateResponseExampleRequest } from 'providers/ReduxStore/slices/collections';
|
||||
import ResponseExampleBodyMode from '../ResponseExampleBodyMode';
|
||||
import ResponseExampleBodyRenderer from '../ResponseExampleBodyRenderer';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const ResponseExampleBody = ({ editMode, item, collection, exampleUid, onSave }) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const body = useMemo(() => {
|
||||
return item.draft
|
||||
? get(item, 'draft.examples', []).find((e) => e.uid === exampleUid)?.request?.body || { mode: 'none' }
|
||||
: get(item, 'examples', []).find((e) => e.uid === exampleUid)?.request?.body || { mode: 'none' };
|
||||
}, [item, exampleUid]);
|
||||
|
||||
const onBodyEdit = (value) => {
|
||||
if (editMode && item && collection.uid && exampleUid) {
|
||||
const updatedBody = { ...body };
|
||||
switch (body.mode) {
|
||||
case 'json':
|
||||
updatedBody.json = value;
|
||||
break;
|
||||
case 'text':
|
||||
updatedBody.text = value;
|
||||
break;
|
||||
case 'xml':
|
||||
updatedBody.xml = value;
|
||||
break;
|
||||
case 'sparql':
|
||||
updatedBody.sparql = value;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
dispatch(updateResponseExampleRequest({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
exampleUid: exampleUid,
|
||||
request: {
|
||||
body: updatedBody
|
||||
}
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full mt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<div className="title text-xs mr-2">Body</div>
|
||||
</div>
|
||||
<ResponseExampleBodyMode
|
||||
item={item}
|
||||
collection={collection}
|
||||
exampleUid={exampleUid}
|
||||
body={body}
|
||||
bodyMode={body.mode}
|
||||
onBodyEdit={onBodyEdit}
|
||||
editMode={editMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ResponseExampleBodyRenderer
|
||||
bodyMode={body.mode}
|
||||
body={body}
|
||||
editMode={editMode}
|
||||
item={item}
|
||||
collection={collection}
|
||||
exampleUid={exampleUid}
|
||||
onBodyEdit={onBodyEdit}
|
||||
onSave={onSave}
|
||||
/>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResponseExampleBody;
|
||||
@@ -0,0 +1,98 @@
|
||||
import React from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { updateResponseExampleRequest } from 'providers/ReduxStore/slices/collections';
|
||||
import BodyModeSelector from 'components/BodyModeSelector';
|
||||
import { format, applyEdits } from 'jsonc-parser';
|
||||
import xmlFormat from 'xml-formatter';
|
||||
import { toastError } from 'utils/common/error';
|
||||
|
||||
const ResponseExampleBodyMode = ({ item, collection, exampleUid, body, bodyMode, onBodyEdit, editMode = false }) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const onModeChange = (value) => {
|
||||
if (item && collection && exampleUid) {
|
||||
// Initialize the new body structure based on the selected mode
|
||||
let newBody = { mode: value };
|
||||
|
||||
// Preserve existing data for the new mode if it exists
|
||||
if (body) {
|
||||
switch (value) {
|
||||
case 'json':
|
||||
newBody.json = body.json || '';
|
||||
break;
|
||||
case 'text':
|
||||
newBody.text = body.text || '';
|
||||
break;
|
||||
case 'xml':
|
||||
newBody.xml = body.xml || '';
|
||||
break;
|
||||
case 'sparql':
|
||||
newBody.sparql = body.sparql || '';
|
||||
break;
|
||||
case 'formUrlEncoded':
|
||||
newBody.formUrlEncoded = body.formUrlEncoded || [];
|
||||
break;
|
||||
case 'multipartForm':
|
||||
newBody.multipartForm = body.multipartForm || [];
|
||||
break;
|
||||
case 'file':
|
||||
newBody.file = body.file || { name: '', data: '' };
|
||||
break;
|
||||
case 'none':
|
||||
// No additional data needed for 'none' mode
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
dispatch(updateResponseExampleRequest({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
exampleUid: exampleUid,
|
||||
request: {
|
||||
body: newBody
|
||||
}
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const onPrettify = () => {
|
||||
if (body?.json && bodyMode === 'json') {
|
||||
try {
|
||||
const edits = format(body.json, undefined, { tabSize: 2, insertSpaces: true });
|
||||
const prettyBodyJson = applyEdits(body.json, edits);
|
||||
onBodyEdit(prettyBodyJson);
|
||||
} catch (e) {
|
||||
toastError(new Error('Unable to prettify. Invalid JSON format.'));
|
||||
}
|
||||
} else if (body?.xml && bodyMode === 'xml') {
|
||||
try {
|
||||
const prettyBodyXML = xmlFormat(body.xml, { collapseContent: true });
|
||||
onBodyEdit(prettyBodyXML);
|
||||
} catch (e) {
|
||||
toastError(new Error('Unable to prettify. Invalid XML format.'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
{['json', 'xml'].includes(bodyMode) && (
|
||||
<button
|
||||
className="btn-action text-link mr-2 py-1 px-2 text-xs"
|
||||
onClick={onPrettify}
|
||||
>
|
||||
Prettify
|
||||
</button>
|
||||
)}
|
||||
<BodyModeSelector
|
||||
currentMode={bodyMode}
|
||||
onModeChange={onModeChange}
|
||||
disabled={!editMode}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResponseExampleBodyMode;
|
||||
@@ -0,0 +1,104 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import get from 'lodash/get';
|
||||
import CodeEditor from 'components/CodeEditor';
|
||||
import ResponseExampleFormUrlEncodedParams from '../ResponseExampleFormUrlEncodedParams';
|
||||
import ResponseExampleMultipartFormParams from '../ResponseExampleMultipartFormParams';
|
||||
import ResponseExampleFileBody from '../ResponseExampleFileBody';
|
||||
|
||||
const ResponseExampleBodyRenderer = ({
|
||||
bodyMode,
|
||||
body,
|
||||
editMode,
|
||||
item,
|
||||
collection,
|
||||
exampleUid,
|
||||
onBodyEdit,
|
||||
onSave
|
||||
}) => {
|
||||
const { displayedTheme } = useTheme();
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
|
||||
const getBodyContent = () => {
|
||||
if (!body) return '';
|
||||
|
||||
switch (bodyMode) {
|
||||
case 'json':
|
||||
return body.json || '';
|
||||
case 'text':
|
||||
return body.text || '';
|
||||
case 'xml':
|
||||
return body.xml || '';
|
||||
case 'sparql':
|
||||
return body.sparql || '';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const getCodeMirrorMode = () => {
|
||||
const modeMap = {
|
||||
json: 'application/ld+json',
|
||||
text: 'application/text',
|
||||
xml: 'application/xml',
|
||||
sparql: 'application/sparql-query'
|
||||
};
|
||||
return modeMap[bodyMode] || 'application/text';
|
||||
};
|
||||
|
||||
const renderBodyContent = () => {
|
||||
switch (bodyMode) {
|
||||
case 'none':
|
||||
return (
|
||||
<div className="text-sm no-body-text">
|
||||
No Body
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'json':
|
||||
case 'xml':
|
||||
case 'text':
|
||||
case 'sparql':
|
||||
return (
|
||||
<div className="min-h-96">
|
||||
<CodeEditor
|
||||
collection={collection}
|
||||
item={item}
|
||||
theme={displayedTheme}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
value={getBodyContent()}
|
||||
onEdit={onBodyEdit}
|
||||
onRun={() => {}}
|
||||
onSave={onSave}
|
||||
mode={getCodeMirrorMode()}
|
||||
enableVariableHighlighting={true}
|
||||
showHintsFor={['variables']}
|
||||
readOnly={!editMode}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'formUrlEncoded':
|
||||
return <ResponseExampleFormUrlEncodedParams item={item} collection={collection} exampleUid={exampleUid} editMode={editMode} />;
|
||||
|
||||
case 'multipartForm':
|
||||
return <ResponseExampleMultipartFormParams item={item} collection={collection} exampleUid={exampleUid} editMode={editMode} />;
|
||||
|
||||
case 'file':
|
||||
return <ResponseExampleFileBody item={item} collection={collection} exampleUid={exampleUid} editMode={editMode} />;
|
||||
|
||||
default:
|
||||
return (
|
||||
<div className="text-sm no-body-text">
|
||||
No Body
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return renderBodyContent();
|
||||
};
|
||||
|
||||
export default ResponseExampleBodyRenderer;
|
||||
@@ -0,0 +1,38 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
textarea {
|
||||
background-color: transparent;
|
||||
color: ${(props) => props.theme.text};
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
border: 1px solid transparent;
|
||||
padding: 0;
|
||||
|
||||
&:not([readonly]) {
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
border: 1px solid ${(props) => props.theme.examples.urlBar.border};
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: transparent;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
cursor: not-allowed;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: ${(props) => props.theme.input.placeholder.color};
|
||||
opacity: ${(props) => props.theme.input.placeholder.opacity};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,48 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import get from 'lodash/get';
|
||||
import { updateResponseExampleDetails } from 'providers/ReduxStore/slices/collections';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const ResponseExampleDescription = ({ editMode, item, collection, exampleUid }) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const description = useMemo(() => {
|
||||
return item.draft
|
||||
? get(item, 'draft.examples', []).find((e) => e.uid === exampleUid)?.description || ''
|
||||
: get(item, 'examples', []).find((e) => e.uid === exampleUid)?.description || '';
|
||||
}, [item, exampleUid]);
|
||||
|
||||
const handleChange = (e) => {
|
||||
const newValue = e.target.value;
|
||||
|
||||
if (editMode && item && collection && exampleUid) {
|
||||
dispatch(updateResponseExampleDetails({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
exampleUid: exampleUid,
|
||||
details: {
|
||||
description: newValue
|
||||
}
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full">
|
||||
<div className="mb-2">
|
||||
<textarea
|
||||
data-testid="response-example-description-input"
|
||||
value={description}
|
||||
onChange={handleChange}
|
||||
readOnly={!editMode}
|
||||
placeholder="Enter example description..."
|
||||
className="w-full p-3 border rounded-md"
|
||||
rows={1}
|
||||
/>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResponseExampleDescription;
|
||||
@@ -0,0 +1,131 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-weight: 600;
|
||||
table-layout: fixed;
|
||||
|
||||
thead,
|
||||
td {
|
||||
border: 1px solid ${(props) => props.theme.table.border};
|
||||
}
|
||||
|
||||
thead {
|
||||
color: ${(props) => props.theme.table.thead.color};
|
||||
font-size: 0.8125rem;
|
||||
user-select: none;
|
||||
}
|
||||
td {
|
||||
padding: 6px 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-add-param {
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
input[type='text'] {
|
||||
width: 100%;
|
||||
border: solid 1px transparent;
|
||||
outline: none !important;
|
||||
color: ${(props) => props.theme.table.input.color};
|
||||
background: transparent;
|
||||
|
||||
&:focus {
|
||||
outline: none !important;
|
||||
border: solid 1px transparent;
|
||||
}
|
||||
}
|
||||
|
||||
input[type='checkbox'] {
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
top: 1px;
|
||||
}
|
||||
|
||||
.btn-action {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
&.edit-mode {
|
||||
background-color: ${(props) => props.theme.colors.text.yellow}20;
|
||||
border-color: ${(props) => props.theme.colors.text.yellow};
|
||||
color: ${(props) => props.theme.colors.text.yellow};
|
||||
}
|
||||
|
||||
&.view-mode {
|
||||
background-color: transparent;
|
||||
border-color: ${(props) => props.theme.colors.text.muted};
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Fix alignment for file picker content */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
tr {
|
||||
position: relative;
|
||||
|
||||
&:hover .delete-button.edit-mode {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
.delete-button {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
margin-left: 8px;
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => props.theme.colors.text.red};
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default Wrapper;
|
||||
@@ -0,0 +1,222 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { get, cloneDeep } from 'lodash';
|
||||
import { IconTrash } from '@tabler/icons';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { updateResponseExampleFileBodyParams } from 'providers/ReduxStore/slices/collections';
|
||||
import mime from 'mime-types';
|
||||
import path from 'utils/common/path';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import FilePickerEditor from 'components/FilePickerEditor/index';
|
||||
import SingleLineEditor from 'components/SingleLineEditor/index';
|
||||
import Table from 'components/Table-v2';
|
||||
import ReorderTable from 'components/ReorderTable';
|
||||
import RadioButton from 'components/RadioButton';
|
||||
|
||||
const ResponseExampleFileBody = ({ item, collection, exampleUid, editMode = false }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
|
||||
// Get file data from the specific example
|
||||
const params = useMemo(() => {
|
||||
const _params = item.draft
|
||||
? get(item, 'draft.examples', []).find((e) => e.uid === exampleUid)?.request?.body?.file || []
|
||||
: get(item, 'examples', []).find((e) => e.uid === exampleUid)?.request?.body?.file || [];
|
||||
return Array.isArray(_params) ? _params : [];
|
||||
}, [item.draft, item.examples, item, exampleUid]);
|
||||
|
||||
const [enabledFileUid, setEnableFileUid] = useState(params.length > 0 ? params[0].uid : '');
|
||||
|
||||
const addFile = () => {
|
||||
const newParam = {
|
||||
filePath: '',
|
||||
contentType: '',
|
||||
selected: true
|
||||
};
|
||||
|
||||
const updatedParams = [...params, newParam];
|
||||
|
||||
dispatch(updateResponseExampleFileBodyParams({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
exampleUid: exampleUid,
|
||||
params: updatedParams
|
||||
}));
|
||||
};
|
||||
|
||||
const handleParamChange = (e, _param, type) => {
|
||||
if (!editMode) return;
|
||||
|
||||
const param = cloneDeep(_param);
|
||||
switch (type) {
|
||||
case 'filePath': {
|
||||
param.filePath = e.target.filePath;
|
||||
// Auto-detect content type from file extension using mime library (same as updateFile)
|
||||
const contentType = mime.contentType(path.extname(e.target.filePath));
|
||||
param.contentType = contentType || '';
|
||||
break;
|
||||
}
|
||||
case 'contentType': {
|
||||
param.contentType = e.target.contentType;
|
||||
break;
|
||||
}
|
||||
case 'selected': {
|
||||
// When a file is selected, deselect all others and select this one
|
||||
const updatedParams = params.map((p) => ({
|
||||
...p,
|
||||
selected: p.uid === param.uid ? e.target.checked : false
|
||||
}));
|
||||
|
||||
dispatch(updateResponseExampleFileBodyParams({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
exampleUid: exampleUid,
|
||||
params: updatedParams
|
||||
}));
|
||||
|
||||
// Update the enabled file UID state
|
||||
if (e.target.checked) {
|
||||
setEnableFileUid(param.uid);
|
||||
}
|
||||
return; // Early return since we already dispatched
|
||||
}
|
||||
}
|
||||
|
||||
const updatedParams = params.map((p) => p.uid === param.uid ? param : p);
|
||||
|
||||
dispatch(updateResponseExampleFileBodyParams({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
exampleUid: exampleUid,
|
||||
params: updatedParams
|
||||
}));
|
||||
};
|
||||
|
||||
const handleRemoveParams = (param) => {
|
||||
if (!editMode) return;
|
||||
|
||||
const updatedParams = params.filter((p) => p.uid !== param.uid);
|
||||
|
||||
dispatch(updateResponseExampleFileBodyParams({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
exampleUid: exampleUid,
|
||||
params: updatedParams
|
||||
}));
|
||||
};
|
||||
|
||||
const handleParamDrag = ({ updateReorderedItem }) => {
|
||||
if (!editMode) return;
|
||||
|
||||
const reorderedParams = updateReorderedItem.map((uid) => {
|
||||
return params.find((p) => p.uid === uid);
|
||||
});
|
||||
|
||||
dispatch(updateResponseExampleFileBodyParams({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
exampleUid: exampleUid,
|
||||
params: reorderedParams
|
||||
}));
|
||||
};
|
||||
|
||||
if (params.length === 0 && !editMode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full mt-4">
|
||||
<Table
|
||||
headers={[
|
||||
{ name: 'File', accessor: 'file', width: '50%' },
|
||||
{ name: 'Content-Type', accessor: 'contentType', width: '30%' },
|
||||
{ name: 'Selected', accessor: 'selected', width: '20%' }
|
||||
]}
|
||||
>
|
||||
<ReorderTable updateReorderedItem={handleParamDrag}>
|
||||
{params && params.length
|
||||
? params.map((param, index) => {
|
||||
return (
|
||||
<tr key={param.uid} data-uid={param.uid}>
|
||||
<td className="flex relative">
|
||||
<FilePickerEditor
|
||||
isSingleFilePicker={true}
|
||||
value={param.filePath}
|
||||
onChange={editMode ? (path) =>
|
||||
handleParamChange({
|
||||
target: {
|
||||
filePath: path
|
||||
}
|
||||
},
|
||||
param,
|
||||
'filePath') : () => {}}
|
||||
collection={collection}
|
||||
readOnly={!editMode}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex items-center justify-center pl-4">
|
||||
<SingleLineEditor
|
||||
className="flex items-center justify-center"
|
||||
onSave={() => {}}
|
||||
theme={storedTheme}
|
||||
placeholder="Auto"
|
||||
value={param.contentType}
|
||||
onChange={editMode ? (newValue) =>
|
||||
handleParamChange({
|
||||
target: {
|
||||
contentType: newValue
|
||||
}
|
||||
},
|
||||
param,
|
||||
'contentType') : () => {}}
|
||||
onRun={() => {}}
|
||||
collection={collection}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex items-center justify-center pl-4">
|
||||
<RadioButton
|
||||
key={param.uid}
|
||||
id={`file-${param.uid}`}
|
||||
name="selectedFile"
|
||||
value={param.uid}
|
||||
checked={enabledFileUid === param.uid || param.selected}
|
||||
onChange={editMode ? (e) => handleParamChange(e, param, 'selected') : () => {}}
|
||||
disabled={!editMode}
|
||||
className="mr-1 mousetrap"
|
||||
dataTestId={`file-radio-button-${index}`}
|
||||
/>
|
||||
<button
|
||||
tabIndex="-1"
|
||||
onClick={() => handleRemoveParams(param)}
|
||||
className={`delete-button ${editMode ? 'edit-mode' : ''}`}
|
||||
disabled={!editMode}
|
||||
>
|
||||
<IconTrash strokeWidth={1.5} size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
</ReorderTable>
|
||||
</Table>
|
||||
|
||||
{editMode && (
|
||||
<div className="flex justify-between mt-2">
|
||||
<button
|
||||
className="btn-action pr-2 py-3 select-none"
|
||||
onClick={addFile}
|
||||
>
|
||||
+ Add File
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResponseExampleFileBody;
|
||||
@@ -0,0 +1,80 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-weight: 600;
|
||||
table-layout: fixed;
|
||||
|
||||
thead,
|
||||
td {
|
||||
border: 1px solid ${(props) => props.theme.table.border};
|
||||
}
|
||||
|
||||
thead {
|
||||
color: ${(props) => props.theme.table.thead.color};
|
||||
font-size: 0.8125rem;
|
||||
user-select: none;
|
||||
}
|
||||
td {
|
||||
padding: 6px 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-add-param {
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
input[type='text'] {
|
||||
width: 100%;
|
||||
border: solid 1px transparent;
|
||||
outline: none !important;
|
||||
color: ${(props) => props.theme.table.input.color};
|
||||
background: transparent;
|
||||
|
||||
&:focus {
|
||||
outline: none !important;
|
||||
border: solid 1px transparent;
|
||||
}
|
||||
}
|
||||
|
||||
input[type='checkbox'] {
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
top: 1px;
|
||||
}
|
||||
|
||||
tr {
|
||||
position: relative;
|
||||
|
||||
&:hover .delete-button.edit-mode {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
.delete-button {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
margin-left: 8px;
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => props.theme.colors.text.red};
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default Wrapper;
|
||||
@@ -0,0 +1,180 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import { IconTrash } from '@tabler/icons';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { updateResponseExampleFormUrlEncodedParams } from 'providers/ReduxStore/slices/collections';
|
||||
import MultiLineEditor from 'components/MultiLineEditor';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import ReorderTable from 'components/ReorderTable/index';
|
||||
import Table from 'components/Table-v2';
|
||||
import Checkbox from 'components/Checkbox';
|
||||
|
||||
const ResponseExampleFormUrlEncodedParams = ({ item, collection, exampleUid, editMode = false }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
|
||||
const params = useMemo(() => {
|
||||
return item.draft
|
||||
? get(item, 'draft.examples', []).find((e) => e.uid === exampleUid)?.request?.body?.formUrlEncoded || []
|
||||
: get(item, 'examples', []).find((e) => e.uid === exampleUid)?.request?.body?.formUrlEncoded || [];
|
||||
}, [item, exampleUid]);
|
||||
|
||||
const addParam = () => {
|
||||
const newParam = {
|
||||
name: '',
|
||||
value: '',
|
||||
enabled: true
|
||||
};
|
||||
|
||||
const updatedParams = [...params, newParam];
|
||||
|
||||
dispatch(updateResponseExampleFormUrlEncodedParams({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
exampleUid: exampleUid,
|
||||
params: updatedParams
|
||||
}));
|
||||
};
|
||||
|
||||
const handleParamChange = (e, _param, type) => {
|
||||
if (!editMode) return;
|
||||
|
||||
const param = cloneDeep(_param);
|
||||
switch (type) {
|
||||
case 'name': {
|
||||
param.name = e.target.value;
|
||||
break;
|
||||
}
|
||||
case 'value': {
|
||||
param.value = e.target.value;
|
||||
break;
|
||||
}
|
||||
case 'enabled': {
|
||||
param.enabled = e.target.checked;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const updatedParams = params.map((p) => p.uid === param.uid ? param : p);
|
||||
|
||||
dispatch(updateResponseExampleFormUrlEncodedParams({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
exampleUid: exampleUid,
|
||||
params: updatedParams
|
||||
}));
|
||||
};
|
||||
|
||||
const handleRemoveParams = (param) => {
|
||||
const updatedParams = params.filter((p) => p.uid !== param.uid);
|
||||
|
||||
dispatch(updateResponseExampleFormUrlEncodedParams({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
exampleUid: exampleUid,
|
||||
params: updatedParams
|
||||
}));
|
||||
};
|
||||
|
||||
const handleParamDrag = ({ updateReorderedItem }) => {
|
||||
const updatedParams = updateReorderedItem(params);
|
||||
|
||||
dispatch(updateResponseExampleFormUrlEncodedParams({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
exampleUid: exampleUid,
|
||||
params: updatedParams
|
||||
}));
|
||||
};
|
||||
|
||||
if (params.length === 0 && !editMode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full mt-4">
|
||||
<Table
|
||||
headers={[
|
||||
{ name: 'Key', accessor: 'key', width: '40%' },
|
||||
{ name: 'Value', accessor: 'value', width: '60%' }
|
||||
]}
|
||||
>
|
||||
<ReorderTable updateReorderedItem={handleParamDrag}>
|
||||
{params && params.length
|
||||
? params.map((param, index) => {
|
||||
return (
|
||||
<tr key={param.uid} data-uid={param.uid}>
|
||||
<td className="flex relative">
|
||||
<div className="flex items-center justify-center mr-3">
|
||||
<Checkbox
|
||||
checked={param.enabled === true}
|
||||
disabled={!editMode}
|
||||
onChange={(e) => handleParamChange(e, param, 'enabled')}
|
||||
dataTestId={`urlencoded-param-checkbox-${index}`}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
value={param.name}
|
||||
className="mousetrap"
|
||||
onChange={editMode ? (e) => handleParamChange(e, param, 'name') : () => {}}
|
||||
disabled={!editMode}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex items-center justify-center pl-4">
|
||||
<MultiLineEditor
|
||||
value={param.value}
|
||||
theme={storedTheme}
|
||||
onSave={() => {}}
|
||||
onChange={editMode ? (newValue) =>
|
||||
handleParamChange({
|
||||
target: {
|
||||
value: newValue
|
||||
}
|
||||
},
|
||||
param,
|
||||
'value') : () => {}}
|
||||
allowNewlines={true}
|
||||
onRun={() => {}}
|
||||
collection={collection}
|
||||
item={item}
|
||||
/>
|
||||
<button
|
||||
tabIndex="-1"
|
||||
onClick={() => handleRemoveParams(param)}
|
||||
className={`delete-button ${editMode ? 'edit-mode' : ''}`}
|
||||
disabled={!editMode}
|
||||
>
|
||||
<IconTrash strokeWidth={1.5} size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
</ReorderTable>
|
||||
</Table>
|
||||
|
||||
{editMode && (
|
||||
<div className="flex justify-between mt-2">
|
||||
<button
|
||||
className="btn-action text-link pr-2 py-3 select-none"
|
||||
onClick={addParam}
|
||||
>
|
||||
+ Add Param
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResponseExampleFormUrlEncodedParams;
|
||||
@@ -0,0 +1,60 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.title {
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
.btn-action {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
transition: opacity 0.2s ease;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
tr {
|
||||
position: relative;
|
||||
|
||||
&:hover .delete-button {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
.delete-button {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: all 0.2s ease;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
margin-left: 8px;
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => props.theme.colors.text.red};
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,213 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { IconTrash } from '@tabler/icons';
|
||||
import get from 'lodash/get';
|
||||
import { addResponseExampleRequestHeader, updateResponseExampleRequestHeader, deleteResponseExampleRequestHeader, moveResponseExampleRequestHeader, setResponseExampleRequestHeaders } from 'providers/ReduxStore/slices/collections';
|
||||
import Table from 'components/Table-v2';
|
||||
import ReorderTable from 'components/ReorderTable';
|
||||
import SingleLineEditor from 'components/SingleLineEditor';
|
||||
import BulkEditor from 'components/BulkEditor';
|
||||
import Checkbox from 'components/Checkbox';
|
||||
import { headers as StandardHTTPHeaders } from 'know-your-http-well';
|
||||
import { MimeTypes } from 'utils/codemirror/autocompleteConstants';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header);
|
||||
|
||||
const ResponseExampleHeaders = ({ editMode, item, collection, exampleUid }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
const [isBulkEditMode, setIsBulkEditMode] = useState(false);
|
||||
|
||||
const headers = useMemo(() => {
|
||||
return item.draft
|
||||
? get(item, 'draft.examples', []).find((e) => e.uid === exampleUid)?.request?.headers || []
|
||||
: get(item, 'examples', []).find((e) => e.uid === exampleUid)?.request?.headers || [];
|
||||
}, [item, exampleUid]);
|
||||
|
||||
const handleAddHeader = () => {
|
||||
if (editMode) {
|
||||
dispatch(addResponseExampleRequestHeader({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
exampleUid: exampleUid
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const handleHeaderValueChange = (e, header, type) => {
|
||||
if (editMode) {
|
||||
const updatedHeader = { ...header };
|
||||
switch (type) {
|
||||
case 'name': {
|
||||
updatedHeader.name = e.target.value;
|
||||
break;
|
||||
}
|
||||
case 'value': {
|
||||
updatedHeader.value = e.target.value;
|
||||
break;
|
||||
}
|
||||
case 'enabled': {
|
||||
updatedHeader.enabled = e.target.checked;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
dispatch(updateResponseExampleRequestHeader({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
exampleUid: exampleUid,
|
||||
header: updatedHeader
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveHeader = (header) => {
|
||||
if (editMode) {
|
||||
dispatch(deleteResponseExampleRequestHeader({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
exampleUid: exampleUid,
|
||||
headerUid: header.uid
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const handleHeaderDrag = ({ updateReorderedItem }) => {
|
||||
if (editMode) {
|
||||
dispatch(moveResponseExampleRequestHeader({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
exampleUid: exampleUid,
|
||||
updateReorderedItem
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const toggleBulkEditMode = () => {
|
||||
setIsBulkEditMode(!isBulkEditMode);
|
||||
};
|
||||
|
||||
const handleBulkHeadersChange = (newHeaders) => {
|
||||
if (editMode) {
|
||||
dispatch(setResponseExampleRequestHeaders({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
exampleUid: exampleUid,
|
||||
headers: newHeaders
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
if (isBulkEditMode && editMode) {
|
||||
return (
|
||||
<StyledWrapper className="w-full mt-3">
|
||||
<BulkEditor
|
||||
params={headers}
|
||||
onChange={handleBulkHeadersChange}
|
||||
onToggle={toggleBulkEditMode}
|
||||
/>
|
||||
</StyledWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
if (headers.length === 0 && !editMode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full mt-4">
|
||||
<div className="mb-1 title text-xs font-bold">Headers</div>
|
||||
<Table
|
||||
headers={[
|
||||
{ name: 'Key', accessor: 'key', width: '40%' },
|
||||
{ name: 'Value', accessor: 'value', width: '60%' }
|
||||
]}
|
||||
>
|
||||
<ReorderTable updateReorderedItem={handleHeaderDrag}>
|
||||
{headers && headers.length
|
||||
? headers.map((header, index) => (
|
||||
<tr key={header.uid} data-uid={header.uid}>
|
||||
<td className="flex relative">
|
||||
<div className="flex items-center justify-center mr-3">
|
||||
<Checkbox
|
||||
checked={header.enabled === true}
|
||||
disabled={!editMode}
|
||||
onChange={(e) => handleHeaderValueChange(e, header, 'enabled')}
|
||||
dataTestId={`header-checkbox-${index}`}
|
||||
/>
|
||||
</div>
|
||||
<SingleLineEditor
|
||||
value={header.name || ''}
|
||||
readOnly={!editMode}
|
||||
theme={storedTheme}
|
||||
onSave={() => {}}
|
||||
onChange={(newValue) =>
|
||||
handleHeaderValueChange({
|
||||
target: {
|
||||
value: newValue
|
||||
}
|
||||
},
|
||||
header,
|
||||
'name')}
|
||||
autocomplete={headerAutoCompleteList}
|
||||
onRun={() => {}}
|
||||
collection={collection}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex items-center justify-center pl-4">
|
||||
<SingleLineEditor
|
||||
value={header.value || ''}
|
||||
readOnly={!editMode}
|
||||
theme={storedTheme}
|
||||
onSave={() => {}}
|
||||
onChange={(newValue) =>
|
||||
handleHeaderValueChange({
|
||||
target: {
|
||||
value: newValue
|
||||
}
|
||||
},
|
||||
header,
|
||||
'value')}
|
||||
onRun={() => {}}
|
||||
autocomplete={MimeTypes}
|
||||
allowNewlines={true}
|
||||
collection={collection}
|
||||
item={item}
|
||||
/>
|
||||
{editMode && (
|
||||
<button tabIndex="-1" onClick={() => handleRemoveHeader(header)} className="delete-button">
|
||||
<IconTrash strokeWidth={1.5} size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
: null}
|
||||
</ReorderTable>
|
||||
</Table>
|
||||
|
||||
{editMode && (
|
||||
<div className="flex justify-between mt-2">
|
||||
<button
|
||||
className="btn-action text-link pr-2 py-3 select-none"
|
||||
onClick={handleAddHeader}
|
||||
>
|
||||
+ Add Header
|
||||
</button>
|
||||
<button
|
||||
className="btn-action text-link select-none"
|
||||
onClick={toggleBulkEditMode}
|
||||
>
|
||||
Bulk Edit
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResponseExampleHeaders;
|
||||
@@ -0,0 +1,100 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-weight: 600;
|
||||
table-layout: fixed;
|
||||
|
||||
thead,
|
||||
td {
|
||||
border: 1px solid ${(props) => props.theme.table.border};
|
||||
}
|
||||
|
||||
thead {
|
||||
color: ${(props) => props.theme.table.thead.color};
|
||||
font-size: 0.8125rem;
|
||||
user-select: none;
|
||||
}
|
||||
td {
|
||||
padding: 6px 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-add-param {
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
input[type='text'] {
|
||||
width: 100%;
|
||||
border: solid 1px transparent;
|
||||
outline: none !important;
|
||||
color: ${(props) => props.theme.table.input.color};
|
||||
background: transparent;
|
||||
|
||||
&:focus {
|
||||
outline: none !important;
|
||||
border: solid 1px transparent;
|
||||
}
|
||||
}
|
||||
|
||||
input[type='checkbox'] {
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
top: 1px;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
&.edit-mode {
|
||||
background-color: ${(props) => props.theme.colors.text.yellow}20;
|
||||
border-color: ${(props) => props.theme.colors.text.yellow};
|
||||
color: ${(props) => props.theme.colors.text.yellow};
|
||||
}
|
||||
|
||||
&.view-mode {
|
||||
background-color: transparent;
|
||||
border-color: ${(props) => props.theme.colors.text.muted};
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
tr {
|
||||
position: relative;
|
||||
|
||||
&:hover .delete-button.edit-mode {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
.delete-button {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
margin-left: 8px;
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => props.theme.colors.text.red};
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default Wrapper;
|
||||
@@ -0,0 +1,263 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import { IconTrash } from '@tabler/icons';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { updateResponseExampleMultipartFormParams } from 'providers/ReduxStore/slices/collections';
|
||||
import mime from 'mime-types';
|
||||
import path from 'utils/common/path';
|
||||
import MultiLineEditor from 'components/MultiLineEditor';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import FilePickerEditor from 'components/FilePickerEditor';
|
||||
import Table from 'components/Table-v2';
|
||||
import ReorderTable from 'components/ReorderTable/index';
|
||||
import Checkbox from 'components/Checkbox';
|
||||
|
||||
const ResponseExampleMultipartFormParams = ({ item, collection, exampleUid, editMode = false }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
|
||||
const params = useMemo(() => {
|
||||
return item.draft
|
||||
? get(item, 'draft.examples', []).find((e) => e.uid === exampleUid)?.request?.body?.multipartForm || []
|
||||
: get(item, 'examples', []).find((e) => e.uid === exampleUid)?.request?.body?.multipartForm || [];
|
||||
}, [item, exampleUid]);
|
||||
|
||||
const addParam = () => {
|
||||
const newParam = {
|
||||
name: '',
|
||||
value: '',
|
||||
contentType: '',
|
||||
enabled: true,
|
||||
type: 'text'
|
||||
};
|
||||
|
||||
const updatedParams = [...params, newParam];
|
||||
|
||||
dispatch(updateResponseExampleMultipartFormParams({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
exampleUid: exampleUid,
|
||||
params: updatedParams
|
||||
}));
|
||||
};
|
||||
|
||||
const addFile = () => {
|
||||
const newParam = {
|
||||
name: '',
|
||||
value: [],
|
||||
contentType: '',
|
||||
enabled: true,
|
||||
type: 'file'
|
||||
};
|
||||
|
||||
const updatedParams = [...params, newParam];
|
||||
|
||||
dispatch(updateResponseExampleMultipartFormParams({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
exampleUid: exampleUid,
|
||||
params: updatedParams
|
||||
}));
|
||||
};
|
||||
|
||||
const handleParamChange = (e, _param, type) => {
|
||||
if (!editMode) return;
|
||||
|
||||
const param = cloneDeep(_param);
|
||||
switch (type) {
|
||||
case 'name': {
|
||||
param.name = e.target.value;
|
||||
break;
|
||||
}
|
||||
case 'value': {
|
||||
param.value = e.target.value;
|
||||
if (param.type === 'file' && e.target.value) {
|
||||
const contentType = mime.contentType(path.extname(e.target.value));
|
||||
param.contentType = contentType || '';
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'contentType': {
|
||||
param.contentType = e.target.value;
|
||||
break;
|
||||
}
|
||||
case 'enabled': {
|
||||
param.enabled = e.target.checked;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const updatedParams = params.map((p) => p.uid === param.uid ? param : p);
|
||||
|
||||
dispatch(updateResponseExampleMultipartFormParams({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
exampleUid: exampleUid,
|
||||
params: updatedParams
|
||||
}));
|
||||
};
|
||||
|
||||
const handleRemoveParams = (param) => {
|
||||
if (!editMode) return;
|
||||
|
||||
const updatedParams = params.filter((p) => p.uid !== param.uid);
|
||||
|
||||
dispatch(updateResponseExampleMultipartFormParams({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
exampleUid: exampleUid,
|
||||
params: updatedParams
|
||||
}));
|
||||
};
|
||||
|
||||
const handleParamDrag = ({ updateReorderedItem }) => {
|
||||
if (!editMode) return;
|
||||
|
||||
const reorderedParams = updateReorderedItem.map((uid) => {
|
||||
return params.find((p) => p.uid === uid);
|
||||
});
|
||||
|
||||
dispatch(updateResponseExampleMultipartFormParams({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
exampleUid: exampleUid,
|
||||
params: reorderedParams
|
||||
}));
|
||||
};
|
||||
|
||||
if (params.length === 0 && !editMode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full mt-4">
|
||||
<Table
|
||||
headers={[
|
||||
{ name: 'Key', accessor: 'key', width: '30%' },
|
||||
{ name: 'Value', accessor: 'value', width: '40%' },
|
||||
{ name: 'Content-Type', accessor: 'content-type', width: '30%' }
|
||||
]}
|
||||
>
|
||||
<ReorderTable updateReorderedItem={handleParamDrag}>
|
||||
{params && params.length
|
||||
? params.map((param, index) => {
|
||||
return (
|
||||
<tr key={param.uid} className="w-full" data-uid={param.uid}>
|
||||
<td className="flex relative">
|
||||
<div className="flex items-center justify-center mr-3">
|
||||
<Checkbox
|
||||
checked={param.enabled === true}
|
||||
disabled={!editMode}
|
||||
onChange={(e) => handleParamChange(e, param, 'enabled')}
|
||||
dataTestId={`multipart-form-param-checkbox-${index}`}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
value={param.name}
|
||||
className="mousetrap"
|
||||
onChange={(e) => handleParamChange(e, param, 'name')}
|
||||
disabled={!editMode}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex items-center justify-center pl-4">
|
||||
{param.type === 'file' ? (
|
||||
<FilePickerEditor
|
||||
value={param.value}
|
||||
onChange={(newValue) =>
|
||||
handleParamChange({
|
||||
target: {
|
||||
value: newValue
|
||||
}
|
||||
},
|
||||
param,
|
||||
'value')}
|
||||
collection={collection}
|
||||
readOnly={!editMode}
|
||||
/>
|
||||
) : (
|
||||
<MultiLineEditor
|
||||
onSave={() => {}}
|
||||
theme={storedTheme}
|
||||
value={param.value}
|
||||
onChange={(newValue) =>
|
||||
handleParamChange({
|
||||
target: {
|
||||
value: newValue
|
||||
}
|
||||
},
|
||||
param,
|
||||
'value')}
|
||||
onRun={() => {}}
|
||||
allowNewlines={true}
|
||||
collection={collection}
|
||||
item={item}
|
||||
readOnly={!editMode}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex items-center justify-center pl-4">
|
||||
<MultiLineEditor
|
||||
onSave={() => {}}
|
||||
theme={storedTheme}
|
||||
placeholder="Auto"
|
||||
value={param.contentType}
|
||||
onChange={(newValue) =>
|
||||
handleParamChange({
|
||||
target: {
|
||||
value: newValue
|
||||
}
|
||||
},
|
||||
param,
|
||||
'contentType')}
|
||||
onRun={() => {}}
|
||||
collection={collection}
|
||||
readOnly={!editMode}
|
||||
/>
|
||||
<button
|
||||
tabIndex="-1"
|
||||
onClick={() => handleRemoveParams(param)}
|
||||
className={`delete-button ${editMode ? 'edit-mode' : ''}`}
|
||||
disabled={!editMode}
|
||||
>
|
||||
<IconTrash strokeWidth={1.5} size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
</ReorderTable>
|
||||
</Table>
|
||||
|
||||
{editMode && (
|
||||
<div className="flex justify-between mt-2">
|
||||
<button
|
||||
className="btn-action text-link pr-2 py-3 select-none"
|
||||
onClick={addParam}
|
||||
>
|
||||
+ Add Param
|
||||
</button>
|
||||
<button
|
||||
className="btn-action text-link pr-2 py-3 select-none"
|
||||
onClick={addFile}
|
||||
>
|
||||
+ Add File
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResponseExampleMultipartFormParams;
|
||||
@@ -0,0 +1,89 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.title {
|
||||
font-weight: 700;
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
.btn-action {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
transition: opacity 0.2s ease;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
|
||||
thead {
|
||||
td {
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid ${(props) => props.theme.table.border};
|
||||
}
|
||||
}
|
||||
|
||||
tbody {
|
||||
tr {
|
||||
border-bottom: 1px solid ${(props) => props.theme.table.border};
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.plainGrid.hoverBg};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
tr {
|
||||
position: relative;
|
||||
|
||||
&:hover .delete-button {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
.delete-button {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: all 0.2s ease;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
margin-left: 8px;
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => props.theme.colors.text.red};
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
}
|
||||
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,272 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { IconTrash } from '@tabler/icons';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import get from 'lodash/get';
|
||||
import { addResponseExampleParam, updateResponseExampleParam, deleteResponseExampleParam, moveResponseExampleParam, setResponseExampleParams } from 'providers/ReduxStore/slices/collections';
|
||||
import Table from 'components/Table-v2';
|
||||
import ReorderTable from 'components/ReorderTable';
|
||||
import SingleLineEditor from 'components/SingleLineEditor';
|
||||
import BulkEditor from 'components/BulkEditor';
|
||||
import Checkbox from 'components/Checkbox';
|
||||
import InfoTip from 'components/InfoTip';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const ResponseExampleParams = ({ editMode, item, collection, exampleUid }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
const [isBulkEditMode, setIsBulkEditMode] = useState(false);
|
||||
|
||||
const params = useMemo(() => {
|
||||
return item.draft
|
||||
? get(item, 'draft.examples', []).find((e) => e.uid === exampleUid)?.request?.params || []
|
||||
: get(item, 'examples', []).find((e) => e.uid === exampleUid)?.request?.params || [];
|
||||
}, [item, exampleUid]);
|
||||
|
||||
const queryParams = params.filter((param) => param.type === 'query');
|
||||
const pathParams = params.filter((param) => param.type === 'path');
|
||||
|
||||
const handleAddQueryParam = () => {
|
||||
if (!editMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(addResponseExampleParam({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
exampleUid: exampleUid
|
||||
}));
|
||||
};
|
||||
|
||||
const handleQueryParamChange = (e, data, key) => {
|
||||
if (!editMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedParam = { ...data };
|
||||
switch (key) {
|
||||
case 'name': {
|
||||
updatedParam.name = e.target.value;
|
||||
break;
|
||||
}
|
||||
case 'value': {
|
||||
updatedParam.value = e.target.value;
|
||||
break;
|
||||
}
|
||||
case 'enabled': {
|
||||
updatedParam.enabled = e.target.checked;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
dispatch(updateResponseExampleParam({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
exampleUid: exampleUid,
|
||||
param: updatedParam
|
||||
}));
|
||||
};
|
||||
|
||||
const handleRemoveQueryParam = (param) => {
|
||||
if (!editMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(deleteResponseExampleParam({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
exampleUid: exampleUid,
|
||||
paramUid: param.uid
|
||||
}));
|
||||
};
|
||||
|
||||
const handleQueryParamDrag = ({ updateReorderedItem }) => {
|
||||
if (!editMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(moveResponseExampleParam({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
exampleUid: exampleUid,
|
||||
updateReorderedItem
|
||||
}));
|
||||
};
|
||||
|
||||
const toggleBulkEditMode = () => {
|
||||
setIsBulkEditMode(!isBulkEditMode);
|
||||
};
|
||||
|
||||
const handleBulkParamsChange = (newParams) => {
|
||||
if (!editMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(setResponseExampleParams({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
exampleUid: exampleUid,
|
||||
params: newParams
|
||||
}));
|
||||
};
|
||||
|
||||
const handlePathParamChange = (e, data) => {
|
||||
if (!editMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedParam = { ...data };
|
||||
updatedParam.value = e.target.value;
|
||||
|
||||
dispatch(updateResponseExampleParam({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
exampleUid: exampleUid,
|
||||
param: updatedParam
|
||||
}));
|
||||
};
|
||||
|
||||
if (isBulkEditMode && editMode) {
|
||||
return (
|
||||
<StyledWrapper className="w-full mt-3">
|
||||
<BulkEditor
|
||||
params={queryParams}
|
||||
onChange={handleBulkParamsChange}
|
||||
onToggle={toggleBulkEditMode}
|
||||
/>
|
||||
</StyledWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
if (queryParams.length === 0 && pathParams.length === 0 && !editMode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full mt-4">
|
||||
<div className="mb-1 title text-xs font-bold">Query parameters</div>
|
||||
<Table
|
||||
headers={[
|
||||
{ name: 'Name', accessor: 'name', width: '40%' },
|
||||
{ name: 'Value', accessor: 'value', width: '60%' }
|
||||
]}
|
||||
>
|
||||
<ReorderTable updateReorderedItem={handleQueryParamDrag}>
|
||||
{queryParams && queryParams.length
|
||||
? queryParams.map((param, index) => (
|
||||
<tr key={param.uid} data-uid={param.uid}>
|
||||
<td className="flex relative">
|
||||
<div className="flex items-center justify-center mr-3">
|
||||
<Checkbox
|
||||
checked={param.enabled !== false}
|
||||
disabled={!editMode}
|
||||
onChange={(e) => handleQueryParamChange(e, param, 'enabled')}
|
||||
dataTestId={`query-param-checkbox-${index}`}
|
||||
/>
|
||||
</div>
|
||||
<SingleLineEditor
|
||||
value={param.name || ''}
|
||||
theme={storedTheme}
|
||||
onSave={() => {}}
|
||||
onChange={(newValue) => handleQueryParamChange({ target: { value: newValue } }, param, 'name')}
|
||||
onRun={() => {}}
|
||||
collection={collection}
|
||||
variablesAutocomplete={true}
|
||||
readOnly={!editMode}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex items-center justify-center pl-4">
|
||||
<SingleLineEditor
|
||||
value={param.value || ''}
|
||||
theme={storedTheme}
|
||||
onSave={() => {}}
|
||||
onChange={(newValue) => handleQueryParamChange({ target: { value: newValue } }, param, 'value')}
|
||||
onRun={() => {}}
|
||||
collection={collection}
|
||||
variablesAutocomplete={true}
|
||||
readOnly={!editMode}
|
||||
/>
|
||||
{editMode && (
|
||||
<button tabIndex="-1" onClick={() => handleRemoveQueryParam(param)} className="delete-button">
|
||||
<IconTrash strokeWidth={1.5} size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
: null}
|
||||
</ReorderTable>
|
||||
</Table>
|
||||
|
||||
{editMode && (
|
||||
<div className="flex justify-between mt-2">
|
||||
<button
|
||||
className="btn-action text-link pr-2 py-3 select-none"
|
||||
onClick={handleAddQueryParam}
|
||||
>
|
||||
+ Add Param
|
||||
</button>
|
||||
<button
|
||||
className="btn-action text-link select-none"
|
||||
onClick={toggleBulkEditMode}
|
||||
>
|
||||
Bulk Edit
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{pathParams && pathParams.length > 0 && (
|
||||
<>
|
||||
<div className="mb-1 title text-xs font-bold flex items-stretch mt-4">
|
||||
<span>Path parameters</span>
|
||||
<InfoTip infotipId="path-param-InfoTip">
|
||||
<div>
|
||||
Path variables are automatically added whenever the
|
||||
<code className="font-mono mx-2">:name</code>
|
||||
template is used in the URL. <br /> For example:
|
||||
<code className="font-mono mx-2">
|
||||
https://example.com/v1/users/<span>:id</span>
|
||||
</code>
|
||||
</div>
|
||||
</InfoTip>
|
||||
</div>
|
||||
<Table
|
||||
headers={[
|
||||
{ name: 'Name', accessor: 'name', width: '40%' },
|
||||
{ name: 'Value', accessor: 'value', width: '60%' }
|
||||
]}
|
||||
>
|
||||
{pathParams && pathParams.length
|
||||
? pathParams.map((path, index) => {
|
||||
return (
|
||||
<tr key={index} data-uid={path.uid}>
|
||||
<td>
|
||||
{path.name}
|
||||
</td>
|
||||
<td>
|
||||
<SingleLineEditor
|
||||
value={path.value}
|
||||
theme={storedTheme}
|
||||
onSave={() => {}}
|
||||
onChange={(newValue) => handlePathParamChange({ target: { value: newValue } }, path)}
|
||||
onRun={() => {}}
|
||||
collection={collection}
|
||||
variablesAutocomplete={true}
|
||||
readOnly={!editMode}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
</Table>
|
||||
{pathParams.length === 0 && <div className="title pr-2 py-3 mt-2 text-xs">No path parameters defined</div>}
|
||||
</>
|
||||
)}
|
||||
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResponseExampleParams;
|
||||
@@ -0,0 +1,53 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.url-bar-container {
|
||||
border: 1px solid ${(props) => props.theme.examples.urlBar.border};
|
||||
}
|
||||
|
||||
.method {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.method-get {
|
||||
background-color: ${(props) => props.theme.request.methods.get};
|
||||
}
|
||||
|
||||
.method-post {
|
||||
background-color: ${(props) => props.theme.request.methods.post};
|
||||
}
|
||||
|
||||
.method-put {
|
||||
background-color: ${(props) => props.theme.request.methods.put};
|
||||
}
|
||||
|
||||
.method-delete {
|
||||
background-color: ${(props) => props.theme.request.methods.delete};
|
||||
}
|
||||
|
||||
.method-patch {
|
||||
background-color: ${(props) => props.theme.request.methods.patch};
|
||||
}
|
||||
|
||||
.method-options {
|
||||
background-color: ${(props) => props.theme.request.methods.options};
|
||||
}
|
||||
|
||||
.method-head {
|
||||
background-color: ${(props) => props.theme.request.methods.head};
|
||||
}
|
||||
|
||||
.method-trace {
|
||||
background-color: ${(props) => props.theme.request.methods.options};
|
||||
}
|
||||
|
||||
.method-connect {
|
||||
background-color: ${(props) => props.theme.request.methods.options};
|
||||
}
|
||||
|
||||
.method-custom {
|
||||
background-color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,81 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { updateResponseExampleRequestUrl } from 'providers/ReduxStore/slices/collections';
|
||||
import SingleLineEditor from 'components/SingleLineEditor';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import get from 'lodash/get';
|
||||
|
||||
const ResponseExampleUrlBar = ({ item, collection, editMode, onSave, exampleUid }) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const exampleData = useMemo(() => {
|
||||
return item.draft ? get(item, 'draft.examples', []).find((e) => e.uid === exampleUid) : get(item, 'examples', []).find((e) => e.uid === exampleUid);
|
||||
}, [item, exampleUid]);
|
||||
const method = get(exampleData, 'request.method');
|
||||
const url = get(exampleData, 'request.url');
|
||||
|
||||
const onChange = (value) => {
|
||||
if (!editMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(updateResponseExampleRequestUrl({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
exampleUid: exampleUid,
|
||||
request: { url: value }
|
||||
}));
|
||||
};
|
||||
|
||||
const getMethodClass = () => {
|
||||
switch (method?.toUpperCase()) {
|
||||
case 'GET':
|
||||
return 'method-get';
|
||||
case 'POST':
|
||||
return 'method-post';
|
||||
case 'PUT':
|
||||
return 'method-put';
|
||||
case 'DELETE':
|
||||
return 'method-delete';
|
||||
case 'PATCH':
|
||||
return 'method-patch';
|
||||
case 'OPTIONS':
|
||||
return 'method-options';
|
||||
case 'HEAD':
|
||||
return 'method-head';
|
||||
case 'OPTIONS':
|
||||
return 'method-options';
|
||||
case 'HEAD':
|
||||
return 'method-head';
|
||||
default:
|
||||
return 'method-get';
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper className="flex items-center">
|
||||
<div className="url-bar-container w-full flex p-2 text-xs rounded-md items-center justify-between" data-testid="url-bar-container">
|
||||
<div className={`method flex text-xs items-center justify-center px-2 rounded h-6 flex-shrink-0 mr-2 overflow-hidden whitespace-nowrap font-semibold uppercase ${getMethodClass()}`}>
|
||||
{method || 'GET'}
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="response-example-url"
|
||||
className="response-example-url flex items-center flex-1 h-6"
|
||||
>
|
||||
<SingleLineEditor
|
||||
value={url}
|
||||
onSave={onSave}
|
||||
onChange={onChange}
|
||||
collection={collection}
|
||||
highlightPathParams={true}
|
||||
item={item}
|
||||
readOnly={!editMode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResponseExampleUrlBar;
|
||||
@@ -0,0 +1,32 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
font-size: 0.8125rem;
|
||||
height: 300px;
|
||||
|
||||
.body-mode-selector {
|
||||
background: transparent;
|
||||
border-radius: 3px;
|
||||
|
||||
.dropdown-item {
|
||||
padding: 0.2rem 0.6rem !important;
|
||||
padding-left: 1.5rem !important;
|
||||
}
|
||||
|
||||
.label-item {
|
||||
padding: 0.2rem 0.6rem !important;
|
||||
}
|
||||
|
||||
.selected-body-mode {
|
||||
color: ${(props) => props.theme.colors.text.yellow};
|
||||
}
|
||||
}
|
||||
|
||||
.caret {
|
||||
color: rgb(140, 140, 140);
|
||||
fill: rgb(140 140 140);
|
||||
}
|
||||
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,47 @@
|
||||
import React from 'react';
|
||||
import ResponseExampleUrlBar from './ResponseExampleUrlBar';
|
||||
import ResponseExampleParams from './ResponseExampleParams';
|
||||
import ResponseExampleHeaders from './ResponseExampleHeaders';
|
||||
import ResponseExampleBody from './ResponseExampleBody';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import HeightBoundContainer from 'ui/HeightBoundContainer';
|
||||
|
||||
const ResponseExampleRequestPane = ({ item, collection, editMode, exampleUid, onSave }) => {
|
||||
return (
|
||||
<HeightBoundContainer>
|
||||
<StyledWrapper className="flex flex-col h-full w-full">
|
||||
<ResponseExampleUrlBar
|
||||
item={item}
|
||||
collection={collection}
|
||||
exampleUid={exampleUid}
|
||||
editMode={editMode}
|
||||
onSave={onSave}
|
||||
/>
|
||||
|
||||
<ResponseExampleParams
|
||||
editMode={editMode}
|
||||
item={item}
|
||||
collection={collection}
|
||||
exampleUid={exampleUid}
|
||||
/>
|
||||
|
||||
<ResponseExampleHeaders
|
||||
editMode={editMode}
|
||||
item={item}
|
||||
collection={collection}
|
||||
exampleUid={exampleUid}
|
||||
/>
|
||||
|
||||
<ResponseExampleBody
|
||||
editMode={editMode}
|
||||
item={item}
|
||||
collection={collection}
|
||||
exampleUid={exampleUid}
|
||||
onSave={onSave}
|
||||
/>
|
||||
</StyledWrapper>
|
||||
</HeightBoundContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResponseExampleRequestPane;
|
||||
@@ -0,0 +1,16 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
|
||||
/* CodeEditor container */
|
||||
.code-editor-container {
|
||||
flex: 1;
|
||||
min-height: 300px;
|
||||
height: 300px;
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,94 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { useSelector } from 'react-redux';
|
||||
import get from 'lodash/get';
|
||||
import { updateResponseExampleResponse } from 'providers/ReduxStore/slices/collections';
|
||||
import CodeEditor from 'components/CodeEditor';
|
||||
import { getCodeMirrorModeBasedOnContentType } from 'utils/common/codemirror';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const ResponseExampleResponseContent = ({ editMode, item, collection, exampleUid, onSave }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { displayedTheme } = useTheme();
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
|
||||
const response = useMemo(() => {
|
||||
return item.draft ? get(item, 'draft.examples', []).find((e) => e.uid === exampleUid)?.response || {} : get(item, 'examples', []).find((e) => e.uid === exampleUid)?.response || {};
|
||||
}, [item, exampleUid]);
|
||||
|
||||
const getResponseContent = () => {
|
||||
if (!response) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return response.body.content;
|
||||
};
|
||||
|
||||
const getCodeMirrorMode = () => {
|
||||
if (!response) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (response.body && response.body.type) {
|
||||
const bodyType = response.body.type;
|
||||
if (bodyType === 'json') {
|
||||
return 'application/ld+json';
|
||||
} else if (bodyType === 'xml') {
|
||||
return 'application/xml';
|
||||
} else if (bodyType === 'html') {
|
||||
return 'application/html';
|
||||
} else if (bodyType === 'text') {
|
||||
return 'application/text';
|
||||
}
|
||||
}
|
||||
|
||||
const contentType = response.headers?.find((h) => h.name?.toLowerCase() === 'content-type')?.value?.toLowerCase() || '';
|
||||
|
||||
return getCodeMirrorModeBasedOnContentType(contentType);
|
||||
};
|
||||
|
||||
const onResponseEdit = (value) => {
|
||||
if (editMode && item && collection && exampleUid) {
|
||||
const currentBody = response.body || {};
|
||||
dispatch(updateResponseExampleResponse({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
exampleUid: exampleUid,
|
||||
response: {
|
||||
body: {
|
||||
type: currentBody.type || 'text',
|
||||
content: value
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full px-4">
|
||||
<div className="code-editor-container">
|
||||
<CodeEditor
|
||||
collection={collection}
|
||||
item={item}
|
||||
theme={displayedTheme}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
value={getResponseContent()}
|
||||
onEdit={onResponseEdit}
|
||||
onRun={() => {}}
|
||||
onSave={onSave}
|
||||
mode={getCodeMirrorMode()}
|
||||
enableVariableHighlighting={false}
|
||||
readOnly={!editMode}
|
||||
/>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResponseExampleResponseContent;
|
||||
@@ -0,0 +1,56 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.btn-action {
|
||||
background: none;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
transition: opacity 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
tr {
|
||||
position: relative;
|
||||
|
||||
&:hover .delete-button {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
.delete-button {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: all 0.2s ease;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
margin-left: 8px;
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => props.theme.colors.text.red};
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,240 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { IconTrash } from '@tabler/icons';
|
||||
import get from 'lodash/get';
|
||||
import { addResponseExampleHeader, updateResponseExampleHeader, deleteResponseExampleHeader, moveResponseExampleHeader, setResponseExampleHeaders, updateResponseExampleResponse } from 'providers/ReduxStore/slices/collections';
|
||||
import { getBodyType } from 'utils/responseBodyProcessor';
|
||||
import Table from 'components/Table-v2';
|
||||
import ReorderTable from 'components/ReorderTable';
|
||||
import SingleLineEditor from 'components/SingleLineEditor';
|
||||
import BulkEditor from 'components/BulkEditor';
|
||||
import { headers as StandardHTTPHeaders } from 'know-your-http-well';
|
||||
import { MimeTypes } from 'utils/codemirror/autocompleteConstants';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header);
|
||||
|
||||
const ResponseExampleResponseHeaders = ({ editMode, item, collection, exampleUid }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
const [isBulkEditMode, setIsBulkEditMode] = useState(false);
|
||||
|
||||
const headers = useMemo(() => {
|
||||
return item.draft ? get(item, 'draft.examples', []).find((e) => e.uid === exampleUid)?.response?.headers || [] : get(item, 'examples', []).find((e) => e.uid === exampleUid)?.response?.headers || [];
|
||||
}, [item, exampleUid]);
|
||||
|
||||
const response = useMemo(() => {
|
||||
return item.draft ? get(item, 'draft.examples', []).find((e) => e.uid === exampleUid)?.response || {} : get(item, 'examples', []).find((e) => e.uid === exampleUid)?.response || {};
|
||||
}, [item, exampleUid]);
|
||||
|
||||
const handleAddHeader = () => {
|
||||
if (!editMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(addResponseExampleHeader({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
exampleUid: exampleUid
|
||||
}));
|
||||
};
|
||||
|
||||
const handleHeaderValueChange = (e, header, type) => {
|
||||
if (!editMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedHeader = { ...header };
|
||||
switch (type) {
|
||||
case 'name': {
|
||||
updatedHeader.name = e.target.value;
|
||||
break;
|
||||
}
|
||||
case 'value': {
|
||||
updatedHeader.value = e.target.value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
dispatch(updateResponseExampleHeader({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
exampleUid: exampleUid,
|
||||
header: updatedHeader
|
||||
}));
|
||||
|
||||
// If content-type header is being updated, automatically update the body type
|
||||
if (header.name?.toLowerCase() === 'content-type' && type === 'value') {
|
||||
const newContentType = updatedHeader.value?.toLowerCase() || '';
|
||||
const newBodyType = getBodyType(newContentType);
|
||||
const currentBodyType = response.body?.type || 'text';
|
||||
|
||||
// Only update if the body type has changed
|
||||
if (newBodyType !== currentBodyType) {
|
||||
dispatch(updateResponseExampleResponse({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
exampleUid: exampleUid,
|
||||
response: {
|
||||
body: {
|
||||
type: newBodyType,
|
||||
content: response.body?.content || ''
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveHeader = (header) => {
|
||||
if (!editMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(deleteResponseExampleHeader({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
exampleUid: exampleUid,
|
||||
headerUid: header.uid
|
||||
}));
|
||||
};
|
||||
|
||||
const handleHeaderDrag = ({ updateReorderedItem }) => {
|
||||
if (!editMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(moveResponseExampleHeader({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
exampleUid: exampleUid,
|
||||
updateReorderedItem
|
||||
}));
|
||||
};
|
||||
|
||||
const toggleBulkEditMode = () => {
|
||||
setIsBulkEditMode(!isBulkEditMode);
|
||||
};
|
||||
|
||||
const handleBulkHeadersChange = (newHeaders) => {
|
||||
if (!editMode) {
|
||||
return;
|
||||
}
|
||||
const cleanedHeaders = newHeaders.map((header) => ({
|
||||
uid: header.uid,
|
||||
name: header.name,
|
||||
value: header.value
|
||||
}));
|
||||
|
||||
dispatch(setResponseExampleHeaders({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
exampleUid: exampleUid,
|
||||
headers: cleanedHeaders
|
||||
}));
|
||||
};
|
||||
|
||||
if (isBulkEditMode && editMode) {
|
||||
// Ensure all headers have enabled: true for bulk edit display
|
||||
const headersForBulkEdit = headers.map((header) => ({
|
||||
...header,
|
||||
enabled: true
|
||||
}));
|
||||
return (
|
||||
<StyledWrapper className="w-full overflow-auto">
|
||||
<BulkEditor
|
||||
params={headersForBulkEdit}
|
||||
onChange={handleBulkHeadersChange}
|
||||
onToggle={toggleBulkEditMode}
|
||||
/>
|
||||
</StyledWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full px-4">
|
||||
<Table
|
||||
headers={[
|
||||
{ name: 'Key', accessor: 'key', width: '40%' },
|
||||
{ name: 'Value', accessor: 'value', width: '60%' }
|
||||
]}
|
||||
>
|
||||
<ReorderTable updateReorderedItem={handleHeaderDrag}>
|
||||
{headers && headers.length
|
||||
? headers.map((header) => (
|
||||
<tr key={header.uid} data-uid={header.uid}>
|
||||
<td className="flex relative">
|
||||
<SingleLineEditor
|
||||
value={header.name || ''}
|
||||
theme={storedTheme}
|
||||
onSave={() => {}}
|
||||
onChange={(newValue) =>
|
||||
handleHeaderValueChange({
|
||||
target: {
|
||||
value: newValue
|
||||
}
|
||||
},
|
||||
header,
|
||||
'name')}
|
||||
autocomplete={headerAutoCompleteList}
|
||||
onRun={() => {}}
|
||||
collection={collection}
|
||||
readOnly={!editMode}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex items-center justify-center pl-4">
|
||||
<SingleLineEditor
|
||||
value={header.value || ''}
|
||||
theme={storedTheme}
|
||||
onSave={() => {}}
|
||||
onChange={(newValue) =>
|
||||
handleHeaderValueChange({
|
||||
target: {
|
||||
value: newValue
|
||||
}
|
||||
},
|
||||
header,
|
||||
'value')}
|
||||
onRun={() => {}}
|
||||
autocomplete={MimeTypes}
|
||||
allowNewlines={true}
|
||||
collection={collection}
|
||||
item={item}
|
||||
readOnly={!editMode}
|
||||
/>
|
||||
{editMode && (
|
||||
<button tabIndex="-1" onClick={() => handleRemoveHeader(header)} className="delete-button">
|
||||
<IconTrash strokeWidth={1.5} size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
: null}
|
||||
</ReorderTable>
|
||||
</Table>
|
||||
|
||||
{editMode && (
|
||||
<div className="flex justify-between mt-2 flex-shrink-0">
|
||||
<button
|
||||
className="btn-action text-link pr-2 py-3 select-none"
|
||||
onClick={handleAddHeader}
|
||||
>
|
||||
+ Add Header
|
||||
</button>
|
||||
<button
|
||||
className="btn-action text-link select-none"
|
||||
onClick={toggleBulkEditMode}
|
||||
>
|
||||
Bulk Edit
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResponseExampleResponseHeaders;
|
||||
@@ -0,0 +1,83 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
|
||||
.response-status-input {
|
||||
background: ${(props) => props.theme.requestTabPanel.url.bg};
|
||||
border: 1px solid ${(props) => props.theme.modal.input.border};
|
||||
border-radius: 3px;
|
||||
padding: 0.35rem 0.6rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: ${(props) => props.theme.text.primary};
|
||||
min-width: 120px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: ${(props) => props.theme.colors.primary};
|
||||
box-shadow: 0 0 0 2px ${(props) => props.theme.colors.primary}20;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: ${(props) => props.theme.text.muted};
|
||||
}
|
||||
|
||||
&.text-ok {
|
||||
color: ${(props) => props.theme.colors.success};
|
||||
}
|
||||
|
||||
&.text-warning {
|
||||
color: ${(props) => props.theme.colors.warning};
|
||||
}
|
||||
|
||||
&.text-error {
|
||||
color: ${(props) => props.theme.colors.error};
|
||||
}
|
||||
}
|
||||
|
||||
.status-suggestions {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: ${(props) => props.theme.dropdown.bg};
|
||||
border: 1px solid ${(props) => props.theme.modal.input.border};
|
||||
border-top: none;
|
||||
border-radius: 0 0 3px 3px;
|
||||
box-shadow: ${(props) => props.theme.dropdown.shadow};
|
||||
z-index: 1000;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
|
||||
.suggestion-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.35rem 0.6rem;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
font-size: 0.8125rem;
|
||||
color: ${(props) => props.theme.dropdown.primaryText};
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: ${(props) => props.theme.dropdown.hoverBg};
|
||||
}
|
||||
|
||||
.status {
|
||||
font-weight: 600;
|
||||
color: inherit;
|
||||
margin-right: 0.5rem;
|
||||
min-width: 40px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,208 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { updateResponseExampleStatusCode, updateResponseExampleStatusText } from 'providers/ReduxStore/slices/collections';
|
||||
import statusCodePhraseMap from 'components/ResponsePane/StatusCode/get-status-code-phrase';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const ResponseExampleStatusInput = ({ item, collection, exampleUid, status, statusText }) => {
|
||||
const dispatch = useDispatch();
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
const [filteredSuggestions, setFilteredSuggestions] = useState([]);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const inputRef = useRef(null);
|
||||
const suggestionsRef = useRef(null);
|
||||
|
||||
// Initialize inputValue from Redux state on mount or when prop changes
|
||||
useEffect(() => {
|
||||
const displayValue = () => {
|
||||
if (status && statusText) {
|
||||
return `${status} ${statusText}`;
|
||||
} else if (status) {
|
||||
return status;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
setInputValue(displayValue());
|
||||
}, [status, statusText]);
|
||||
|
||||
// Create suggestions from status code map
|
||||
const suggestions = Object.entries(statusCodePhraseMap).map(([code, phrase]) => ({
|
||||
code: parseInt(code),
|
||||
phrase,
|
||||
display: `${code} ${phrase}`
|
||||
}));
|
||||
|
||||
const handleInputChange = (e) => {
|
||||
const value = e.target.value;
|
||||
|
||||
// Update local state to allow typing freely (including spaces)
|
||||
setInputValue(value);
|
||||
|
||||
if (value.trim()) {
|
||||
// Filter suggestions based on input
|
||||
const filtered = suggestions.filter((suggestion) =>
|
||||
suggestion.display.toLowerCase().includes(value.toLowerCase())
|
||||
|| suggestion.code.toString().includes(value)
|
||||
|| suggestion.phrase.toLowerCase().includes(value.toLowerCase()));
|
||||
setFilteredSuggestions(filtered);
|
||||
setShowSuggestions(true);
|
||||
} else {
|
||||
setShowSuggestions(false);
|
||||
setFilteredSuggestions([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
// Handle Cmd+S to save status to Redux
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
|
||||
e.preventDefault();
|
||||
parseAndSaveStatus(inputValue);
|
||||
}
|
||||
|
||||
if (!showSuggestions) return;
|
||||
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
setShowSuggestions(false);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const selectSuggestion = (suggestion) => {
|
||||
setShowSuggestions(false);
|
||||
|
||||
// Update local input value
|
||||
const newValue = `${suggestion.code} ${suggestion.phrase}`;
|
||||
setInputValue(newValue);
|
||||
|
||||
// Save the status and statusText
|
||||
dispatch(updateResponseExampleStatusCode({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
exampleUid: exampleUid,
|
||||
statusCode: String(suggestion.code)
|
||||
}));
|
||||
|
||||
dispatch(updateResponseExampleStatusText({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
exampleUid: exampleUid,
|
||||
statusText: suggestion.phrase
|
||||
}));
|
||||
};
|
||||
|
||||
const parseAndSaveStatus = (value) => {
|
||||
// Find the first space
|
||||
const firstSpaceIndex = value.indexOf(' ');
|
||||
|
||||
let statusCode, statusText;
|
||||
|
||||
if (firstSpaceIndex === -1) {
|
||||
// No space found, treat entire value as status code
|
||||
statusCode = value;
|
||||
statusText = '';
|
||||
} else {
|
||||
// Split on first space only, preserving all other spaces
|
||||
statusCode = value.substring(0, firstSpaceIndex);
|
||||
statusText = value.substring(firstSpaceIndex + 1);
|
||||
}
|
||||
|
||||
// Save both as strings - no validation needed
|
||||
dispatch(updateResponseExampleStatusCode({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
exampleUid: exampleUid,
|
||||
statusCode: statusCode
|
||||
}));
|
||||
|
||||
dispatch(updateResponseExampleStatusText({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
exampleUid: exampleUid,
|
||||
statusText: statusText
|
||||
}));
|
||||
|
||||
setShowSuggestions(false);
|
||||
};
|
||||
|
||||
const handleBlur = (e) => {
|
||||
// Check if the blur is caused by clicking on a suggestion
|
||||
const relatedTarget = e.relatedTarget;
|
||||
if (relatedTarget && relatedTarget.closest('.status-suggestions')) {
|
||||
return; // Don't close suggestions if clicking on them
|
||||
}
|
||||
|
||||
// Save the status to Redux
|
||||
parseAndSaveStatus(inputValue);
|
||||
|
||||
// Small delay to allow click events on suggestions
|
||||
setTimeout(() => {
|
||||
setShowSuggestions(false);
|
||||
}, 150);
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
if (inputValue.trim()) {
|
||||
const filtered = suggestions.filter((suggestion) =>
|
||||
suggestion.display.toLowerCase().includes(inputValue.toLowerCase())
|
||||
|| suggestion.code.toString().includes(inputValue)
|
||||
|| suggestion.phrase.toLowerCase().includes(inputValue.toLowerCase()));
|
||||
setFilteredSuggestions(filtered);
|
||||
setShowSuggestions(true);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusClass = (status) => {
|
||||
const numStatus = parseInt(status);
|
||||
if (!isNaN(numStatus)) {
|
||||
if (numStatus >= 200 && numStatus < 300) return 'text-ok';
|
||||
if (numStatus >= 300 && numStatus < 400) return 'text-warning';
|
||||
if (numStatus >= 400) return 'text-error';
|
||||
}
|
||||
return 'text-ok';
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper className="relative">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleBlur}
|
||||
onFocus={handleFocus}
|
||||
placeholder="e.g., 200 OK, 404 Unknown, 999 Custom Error"
|
||||
className={`response-status-input ${getStatusClass(status)}`}
|
||||
data-testid="response-status-input"
|
||||
/>
|
||||
|
||||
{showSuggestions && filteredSuggestions.length > 0 && (
|
||||
<div
|
||||
ref={suggestionsRef}
|
||||
className="status-suggestions"
|
||||
data-testid="status-suggestions"
|
||||
onMouseDown={(e) => e.preventDefault()} // Prevent input blur when clicking on suggestions
|
||||
>
|
||||
{filteredSuggestions.map((suggestion) => (
|
||||
<div
|
||||
key={suggestion.code}
|
||||
className="suggestion-item"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
selectSuggestion(suggestion);
|
||||
}}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
data-testid={`suggestion-${suggestion.code}`}
|
||||
>
|
||||
<span className="status">{`${suggestion.code} ${suggestion.phrase}`}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResponseExampleStatusInput;
|
||||
@@ -0,0 +1,39 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
div.tabs {
|
||||
div.tab {
|
||||
padding: 6px 0px;
|
||||
border: none;
|
||||
border-bottom: solid 2px transparent;
|
||||
margin-right: 1.25rem;
|
||||
color: var(--color-tab-inactive);
|
||||
cursor: pointer;
|
||||
|
||||
&:focus,
|
||||
&:active,
|
||||
&:focus-within,
|
||||
&:focus-visible,
|
||||
&:target {
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: ${(props) => props.theme.tabs.active.color} !important;
|
||||
border-bottom: solid 2px ${(props) => props.theme.tabs.active.border} !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.some-tests-failed {
|
||||
color: ${(props) => props.theme.colors.text.danger} !important;
|
||||
}
|
||||
|
||||
.all-tests-passed {
|
||||
color: ${(props) => props.theme.colors.text.green} !important;
|
||||
}
|
||||
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,102 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import Tab from 'components/Tab';
|
||||
import ResponseLayoutToggle from 'components/ResponsePane/ResponseLayoutToggle';
|
||||
import StatusCode from 'components/ResponsePane/StatusCode';
|
||||
import ResponseExampleResponseContent from './ResponseExampleResponseContent';
|
||||
import ResponseExampleResponseHeaders from './ResponseExampleResponseHeaders';
|
||||
import ResponseExampleStatusInput from './ResponseExampleStatusInput';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import HeightBoundContainer from 'ui/HeightBoundContainer';
|
||||
|
||||
const ResponseExampleResponsePane = ({ item, collection, editMode, exampleUid, onSave }) => {
|
||||
const [activeTab, setActiveTab] = useState('response');
|
||||
|
||||
const exampleData = useMemo(() => {
|
||||
return item.draft ? get(item, 'draft.examples', []).find((e) => e.uid === exampleUid) || {} : get(item, 'examples', []).find((e) => e.uid === exampleUid) || {};
|
||||
}, [item, exampleUid]);
|
||||
|
||||
const getTabPanel = (tab) => {
|
||||
switch (tab) {
|
||||
case 'response': {
|
||||
return (
|
||||
<ResponseExampleResponseContent
|
||||
editMode={editMode}
|
||||
item={item}
|
||||
collection={collection}
|
||||
exampleUid={exampleUid}
|
||||
onSave={onSave}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case 'headers': {
|
||||
return (
|
||||
<ResponseExampleResponseHeaders
|
||||
editMode={editMode}
|
||||
item={item}
|
||||
collection={collection}
|
||||
exampleUid={exampleUid}
|
||||
onSave={onSave}
|
||||
/>
|
||||
);
|
||||
}
|
||||
default: {
|
||||
return <div>404 | Not found</div>;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const tabConfig = [
|
||||
{
|
||||
name: 'response',
|
||||
label: 'Response'
|
||||
},
|
||||
{
|
||||
name: 'headers',
|
||||
label: 'Headers',
|
||||
count: (exampleData?.response?.headers || []).length
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<StyledWrapper className="flex flex-col h-full relative">
|
||||
<div className="flex flex-wrap items-center tabs mb-4 px-4" role="tablist">
|
||||
{tabConfig.map((tab) => (
|
||||
<Tab
|
||||
key={tab.name}
|
||||
name={tab.name}
|
||||
label={tab.label}
|
||||
isActive={activeTab === tab.name}
|
||||
onClick={setActiveTab}
|
||||
count={tab.count}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="flex flex-grow justify-end items-center">
|
||||
<ResponseLayoutToggle />
|
||||
{editMode ? (
|
||||
<ResponseExampleStatusInput
|
||||
item={item}
|
||||
collection={collection}
|
||||
exampleUid={exampleUid}
|
||||
status={exampleData?.response?.status}
|
||||
statusText={exampleData?.response?.statusText}
|
||||
/>
|
||||
) : (
|
||||
exampleData?.response?.status && (
|
||||
<StatusCode status={exampleData.response.status} statusText={exampleData.response.statusText} />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="flex w-full flex-1 relative">
|
||||
<HeightBoundContainer>
|
||||
{getTabPanel(activeTab)}
|
||||
</HeightBoundContainer>
|
||||
</section>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResponseExampleResponsePane;
|
||||
@@ -0,0 +1,84 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
background-color: ${(props) => props.theme.bg};
|
||||
border-bottom: 1px solid ${(props) => props.theme.examples.border};
|
||||
|
||||
.response-example-title {
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
.response-example-description {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
.primary-btn {
|
||||
background-color: ${(props) => props.theme.examples.buttonColor};
|
||||
border: 1px solid ${(props) => props.theme.examples.buttonColor};
|
||||
color: white;
|
||||
|
||||
svg {
|
||||
color: ${(props) => props.theme.text} !important;
|
||||
}
|
||||
}
|
||||
|
||||
.secondary-btn {
|
||||
background-color: transparent;
|
||||
color: ${(props) => props.theme.text};
|
||||
border: 1px solid ${(props) => props.theme.examples.border};
|
||||
|
||||
svg {
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
}
|
||||
|
||||
.example-input-label {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: ${(props) => props.theme.text};
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.example-input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid ${(props) => props.theme.examples.border};
|
||||
border-radius: 6px;
|
||||
background-color: transparent;
|
||||
color: ${(props) => props.theme.text};
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
transition: all 0.2s ease;
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
|
||||
&::placeholder {
|
||||
color: ${(props) => props.theme.input.placeholder.color};
|
||||
opacity: ${(props) => props.theme.input.placeholder.opacity};
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: ${(props) => props.theme.input.focusBorder};
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background-color: transparent;
|
||||
color: ${(props) => props.theme.text};
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.example-input-description {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.6;
|
||||
resize: none;
|
||||
min-height: 80px;
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,204 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import IconEdit from 'components/Icons/IconEdit';
|
||||
import { IconCode, IconDeviceFloppy } from '@tabler/icons';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import TruncatedText from 'components/TruncatedText';
|
||||
import { updateResponseExampleName, updateResponseExampleDescription } from 'providers/ReduxStore/slices/collections';
|
||||
import get from 'lodash/get';
|
||||
|
||||
const ResponseExampleTopBar = ({
|
||||
item,
|
||||
collection,
|
||||
exampleUid,
|
||||
editMode,
|
||||
onEditToggle,
|
||||
onSave,
|
||||
onCancel,
|
||||
onGenerateCode
|
||||
}) => {
|
||||
const { theme } = useTheme();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const example = useMemo(() => {
|
||||
return item.draft ? get(item, 'draft.examples', []).find((e) => e.uid === exampleUid) : get(item, 'examples', []).find((e) => e.uid === exampleUid);
|
||||
}, [item.draft, item.examples, item, exampleUid]);
|
||||
|
||||
const handleGenerateCode = () => {
|
||||
if (onGenerateCode) {
|
||||
onGenerateCode({
|
||||
...example,
|
||||
isExample: true,
|
||||
exampleUid: exampleUid
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleNameChange = (e) => {
|
||||
// Validate required fields before dispatching
|
||||
if (!item?.uid) {
|
||||
console.error('item.uid is missing');
|
||||
return;
|
||||
}
|
||||
if (!collection?.uid) {
|
||||
console.error('collection.uid is missing');
|
||||
return;
|
||||
}
|
||||
if (!exampleUid) {
|
||||
console.error('exampleUid is missing');
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(updateResponseExampleName({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
exampleUid: exampleUid,
|
||||
name: e.target.value
|
||||
}));
|
||||
};
|
||||
|
||||
const handleDescriptionChange = (e) => {
|
||||
// Validate required fields before dispatching
|
||||
if (!item?.uid) {
|
||||
console.error('item.uid is missing');
|
||||
return;
|
||||
}
|
||||
if (!collection?.uid) {
|
||||
console.error('collection.uid is missing');
|
||||
return;
|
||||
}
|
||||
if (!exampleUid) {
|
||||
console.error('exampleUid is missing');
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(updateResponseExampleDescription({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
exampleUid: exampleUid,
|
||||
description: e.target.value
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
// Call the parent save handler
|
||||
if (onSave) {
|
||||
onSave();
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
if (onCancel) {
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
|
||||
if (!example || !exampleUid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (editMode) {
|
||||
return (
|
||||
<StyledWrapper className="p-4">
|
||||
<div className="max-w-full">
|
||||
<div className="flex items-start justify-between gap-6 md:flex-row flex-col">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
value={example?.name || ''}
|
||||
onChange={handleNameChange}
|
||||
className="example-input example-input-name"
|
||||
placeholder="Enter example name"
|
||||
autoFocus
|
||||
data-testid="response-example-name-input"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<textarea
|
||||
value={example?.description || ''}
|
||||
onChange={handleDescriptionChange}
|
||||
className="example-input example-input-description"
|
||||
placeholder="Enter example description"
|
||||
rows={3}
|
||||
data-testid="response-example-description-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 flex-shrink-0 md:w-auto w-full md:justify-end">
|
||||
<button
|
||||
className="secondary-btn flex items-center gap-1.5 px-4 py-2 rounded-md text-xs font-medium cursor-pointer border whitespace-nowrap"
|
||||
onClick={handleCancel}
|
||||
data-testid="response-example-cancel-btn"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="primary-btn flex items-center gap-1.5 px-4 py-2 rounded-md text-xs font-medium cursor-pointer border whitespace-nowrap"
|
||||
onClick={handleSave}
|
||||
data-testid="response-example-save-btn"
|
||||
>
|
||||
<IconDeviceFloppy size={16} color={theme.examples.buttonText} />
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
// Default view mode
|
||||
return (
|
||||
<StyledWrapper className="p-4">
|
||||
<div className="max-w-full">
|
||||
<div className="flex items-start justify-between gap-6 md:flex-row flex-col">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="response-example-title font-semibold mb-2 leading-tight" data-testid="response-example-title">
|
||||
<span className="opacity-60">{item.name}</span>
|
||||
{' / '}
|
||||
<span>{example.name}</span>
|
||||
</h2>
|
||||
{example.description && example.description.trim().length > 0 && (
|
||||
<TruncatedText
|
||||
text={example.description}
|
||||
maxLines={2}
|
||||
className="response-example-description-container"
|
||||
textClassName="response-example-description text-sm leading-relaxed max-w-fit"
|
||||
buttonClassName="text-blue-600 hover:text-blue-800 font-medium"
|
||||
viewMoreText="View More"
|
||||
viewLessText="View Less"
|
||||
dataTestId="response-example-description"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 flex-shrink-0 md:w-auto w-full md:justify-end">
|
||||
<button
|
||||
className="secondary-btn flex items-center gap-1.5 p-2 rounded-md text-xs font-medium cursor-pointer border whitespace-nowrap"
|
||||
onClick={handleGenerateCode}
|
||||
title="Generate Code"
|
||||
data-testid="response-example-generate-code-btn"
|
||||
>
|
||||
<IconCode size={16} color={theme.examples.buttonIconColor} />
|
||||
</button>
|
||||
<button
|
||||
className="secondary-btn flex items-center gap-1.5 px-4 py-2 rounded-md text-xs font-medium cursor-pointer border whitespace-nowrap"
|
||||
onClick={onEditToggle}
|
||||
data-testid="response-example-edit-btn"
|
||||
>
|
||||
<IconEdit size={16} color={theme.examples.buttonIconColor} />
|
||||
Edit Example
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResponseExampleTopBar;
|
||||
@@ -0,0 +1,67 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
&.dragging {
|
||||
cursor: col-resize;
|
||||
|
||||
&.vertical-layout {
|
||||
cursor: row-resize;
|
||||
}
|
||||
}
|
||||
|
||||
div.dragbar-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 10px;
|
||||
min-width: 10px;
|
||||
padding: 0;
|
||||
cursor: col-resize;
|
||||
background: transparent;
|
||||
position: relative;
|
||||
|
||||
div.dragbar-handle {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
width: 1px;
|
||||
border-left: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.border};
|
||||
}
|
||||
|
||||
&:hover div.dragbar-handle {
|
||||
border-left: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.activeBorder};
|
||||
}
|
||||
}
|
||||
|
||||
&.vertical-layout {
|
||||
.request-pane {
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.response-pane {
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
div.dragbar-wrapper {
|
||||
width: 100%;
|
||||
height: 10px;
|
||||
cursor: row-resize;
|
||||
padding: 0 1rem;
|
||||
position: relative;
|
||||
|
||||
div.dragbar-handle {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
border-left: none;
|
||||
border-top: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.border};
|
||||
}
|
||||
|
||||
&:hover div.dragbar-handle {
|
||||
border-left: none;
|
||||
border-top: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.activeBorder};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
224
packages/bruno-app/src/components/ResponseExample/index.js
Normal file
224
packages/bruno-app/src/components/ResponseExample/index.js
Normal file
@@ -0,0 +1,224 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { updateRequestPaneTabWidth } from 'providers/ReduxStore/slices/tabs';
|
||||
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { cancelResponseExampleEdit } from 'providers/ReduxStore/slices/collections';
|
||||
import ResponseExampleTopBar from './ResponseExampleTopBar';
|
||||
import ResponseExampleRequestPane from './ResponseExampleRequestPane';
|
||||
import ResponseExampleResponsePane from './ResponseExampleResponsePane';
|
||||
import GenerateCodeItem from 'components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const MIN_LEFT_PANE_WIDTH = 300;
|
||||
const MIN_RIGHT_PANE_WIDTH = 350;
|
||||
const MIN_TOP_PANE_HEIGHT = 150;
|
||||
const MIN_BOTTOM_PANE_HEIGHT = 150;
|
||||
|
||||
const ResponseExample = ({ item, collection, example }) => {
|
||||
const dispatch = useDispatch();
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const screenWidth = useSelector((state) => state.app.screenWidth);
|
||||
const leftSidebarWidth = useSelector((state) => state.app.leftSidebarWidth);
|
||||
const isVerticalLayout = preferences?.layout?.responsePaneOrientation === 'vertical';
|
||||
|
||||
const [leftPaneWidth, setLeftPaneWidth] = useState((screenWidth - leftSidebarWidth) / 2.2);
|
||||
const [topPaneHeight, setTopPaneHeight] = useState(MIN_TOP_PANE_HEIGHT);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [showGenerateCodeModal, setShowGenerateCodeModal] = useState(false);
|
||||
const dragOffset = useRef({ x: 0, y: 0 });
|
||||
const mainSectionRef = useRef(null);
|
||||
|
||||
const handleMouseMove = (e) => {
|
||||
if (dragging && mainSectionRef.current) {
|
||||
e.preventDefault();
|
||||
const mainRect = mainSectionRef.current.getBoundingClientRect();
|
||||
|
||||
if (isVerticalLayout) {
|
||||
const newHeight = e.clientY - mainRect.top - dragOffset.current.y;
|
||||
if (newHeight < MIN_TOP_PANE_HEIGHT || newHeight > mainRect.height - MIN_BOTTOM_PANE_HEIGHT) {
|
||||
return;
|
||||
}
|
||||
setTopPaneHeight(newHeight);
|
||||
} else {
|
||||
const newWidth = e.clientX - mainRect.left - dragOffset.current.x;
|
||||
if (newWidth < MIN_LEFT_PANE_WIDTH || newWidth > mainRect.width - MIN_RIGHT_PANE_WIDTH) {
|
||||
return;
|
||||
}
|
||||
setLeftPaneWidth(newWidth);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = (e) => {
|
||||
if (dragging && mainSectionRef.current) {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
if (!isVerticalLayout) {
|
||||
const mainRect = mainSectionRef.current.getBoundingClientRect();
|
||||
dispatch(updateRequestPaneTabWidth({
|
||||
uid: item.uid,
|
||||
requestPaneWidth: e.clientX - mainRect.left
|
||||
}));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragbarMouseDown = (e) => {
|
||||
e.preventDefault();
|
||||
setDragging(true);
|
||||
|
||||
if (isVerticalLayout) {
|
||||
const dragBar = e.currentTarget;
|
||||
const dragBarRect = dragBar.getBoundingClientRect();
|
||||
dragOffset.current.y = e.clientY - dragBarRect.top;
|
||||
} else {
|
||||
const dragBar = e.currentTarget;
|
||||
const dragBarRect = dragBar.getBoundingClientRect();
|
||||
dragOffset.current.x = e.clientX - dragBarRect.left;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
};
|
||||
}, [dragging]);
|
||||
|
||||
const handleEditToggle = () => {
|
||||
setEditMode(!editMode);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (item && collection) {
|
||||
dispatch(saveRequest(item.uid, collection.uid));
|
||||
setEditMode(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
if (item && collection && example?.uid) {
|
||||
dispatch(cancelResponseExampleEdit({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
exampleUid: example.uid
|
||||
}));
|
||||
}
|
||||
setEditMode(false);
|
||||
};
|
||||
|
||||
const handleGenerateCode = (exampleData) => {
|
||||
setShowGenerateCodeModal(true);
|
||||
};
|
||||
|
||||
const handleCloseGenerateCodeModal = () => {
|
||||
setShowGenerateCodeModal(false);
|
||||
};
|
||||
|
||||
const handleTryExample = (example) => {
|
||||
// TODO: Implement try example functionality
|
||||
};
|
||||
|
||||
// Update width when screen width or sidebar width changes
|
||||
useEffect(() => {
|
||||
if (mainSectionRef.current) {
|
||||
const mainRect = mainSectionRef.current.getBoundingClientRect();
|
||||
if (isVerticalLayout) {
|
||||
// In vertical mode, set leftPaneWidth to full container width
|
||||
setLeftPaneWidth(mainRect.width);
|
||||
} else {
|
||||
// In horizontal mode, set to roughly half width
|
||||
setLeftPaneWidth((screenWidth - leftSidebarWidth) / 2.2);
|
||||
}
|
||||
}
|
||||
}, [isVerticalLayout, screenWidth, leftSidebarWidth]);
|
||||
|
||||
// Keyboard shortcut support for Ctrl/Cmd+S
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||
e.preventDefault();
|
||||
if (editMode && item && collection) {
|
||||
handleSave();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [editMode, item, collection]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledWrapper className={`flex flex-col flex-grow relative ${dragging ? 'dragging' : ''} ${isVerticalLayout ? 'vertical-layout' : ''}`}>
|
||||
<ResponseExampleTopBar
|
||||
item={item}
|
||||
collection={collection}
|
||||
exampleUid={example.uid}
|
||||
editMode={editMode}
|
||||
onEditToggle={handleEditToggle}
|
||||
onSave={handleSave}
|
||||
onCancel={handleCancel}
|
||||
onGenerateCode={handleGenerateCode}
|
||||
onTryExample={handleTryExample}
|
||||
/>
|
||||
<section ref={mainSectionRef} className={`main wrapper flex mt-4 ${isVerticalLayout ? 'flex-col' : ''} flex-grow pb-4 relative overflow-auto scrollbar-hover`}>
|
||||
<section className="request-pane">
|
||||
<div
|
||||
className="px-4 h-full"
|
||||
style={isVerticalLayout ? {
|
||||
height: `${Math.max(topPaneHeight, MIN_TOP_PANE_HEIGHT)}px`,
|
||||
minHeight: `${MIN_TOP_PANE_HEIGHT}px`,
|
||||
width: '100%'
|
||||
} : {
|
||||
width: `${Math.max(leftPaneWidth, MIN_LEFT_PANE_WIDTH)}px`
|
||||
}}
|
||||
>
|
||||
<ResponseExampleRequestPane
|
||||
item={item}
|
||||
collection={collection}
|
||||
example={example}
|
||||
editMode={editMode}
|
||||
exampleUid={example?.uid}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="dragbar-wrapper" onMouseDown={handleDragbarMouseDown}>
|
||||
<div className="dragbar-handle" />
|
||||
</div>
|
||||
|
||||
<section className="response-pane flex-grow overflow-x-auto">
|
||||
<ResponseExampleResponsePane
|
||||
item={item}
|
||||
collection={collection}
|
||||
example={example}
|
||||
editMode={editMode}
|
||||
exampleUid={example?.uid}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
</section>
|
||||
</section>
|
||||
</StyledWrapper>
|
||||
|
||||
{showGenerateCodeModal && (
|
||||
<GenerateCodeItem
|
||||
collectionUid={collection.uid}
|
||||
item={item}
|
||||
onClose={handleCloseGenerateCodeModal}
|
||||
isExample={true}
|
||||
exampleUid={example.uid}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResponseExample;
|
||||
@@ -18,8 +18,8 @@ const GrpcStatusCode = ({ status, text }) => {
|
||||
|
||||
return (
|
||||
<StyledWrapper className={getTabClassname(status)}>
|
||||
{Number.isInteger(status) ? <div className="mr-1">{status}</div> : null}
|
||||
{statusText && <div>{statusText}</div>}
|
||||
{Number.isInteger(status) ? <div className="mr-1" data-testid="grpc-response-status-code">{status}</div> : null}
|
||||
{statusText && <div data-testid="grpc-response-status-text">{statusText}</div>}
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { debounce } from 'lodash';
|
||||
import QueryResultFilter from './QueryResultFilter';
|
||||
import { JSONPath } from 'jsonpath-plus';
|
||||
import React from 'react';
|
||||
import classnames from 'classnames';
|
||||
import iconv from 'iconv-lite';
|
||||
import { getContentType, safeStringifyJSON, safeParseXML } from 'utils/common';
|
||||
import { getContentType, formatResponse } from 'utils/common';
|
||||
import { getCodeMirrorModeBasedOnContentType } from 'utils/common/codemirror';
|
||||
import QueryResultPreview from './QueryResultPreview';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
@@ -13,54 +11,6 @@ import { useTheme } from 'providers/Theme/index';
|
||||
import { getEncoding, uuid } from 'utils/common/index';
|
||||
import LargeResponseWarning from '../LargeResponseWarning';
|
||||
|
||||
const formatResponse = (data, dataBuffer, encoding, mode, filter) => {
|
||||
if (data === undefined || !dataBuffer || !mode) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// TODO: We need a better way to get the raw response-data here instead
|
||||
// of using this dataBuffer param.
|
||||
// Also, we only need the raw response-data and content-type to show the preview.
|
||||
const rawData = iconv.decode(
|
||||
Buffer.from(dataBuffer, "base64"),
|
||||
iconv.encodingExists(encoding) ? encoding : "utf-8"
|
||||
);
|
||||
|
||||
if (mode.includes('json')) {
|
||||
try {
|
||||
JSON.parse(rawData);
|
||||
} catch (error) {
|
||||
// If the response content-type is JSON and it fails parsing, its an invalid JSON.
|
||||
// In that case, just show the response as it is in the preview.
|
||||
return rawData;
|
||||
}
|
||||
|
||||
if (filter) {
|
||||
try {
|
||||
data = JSONPath({ path: filter, json: data });
|
||||
} catch (e) {
|
||||
console.warn('Could not apply JSONPath filter:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
return safeStringifyJSON(data, true);
|
||||
}
|
||||
|
||||
if (mode.includes('xml')) {
|
||||
let parsed = safeParseXML(data, { collapseContent: true });
|
||||
if (typeof parsed === 'string') {
|
||||
return parsed;
|
||||
}
|
||||
return safeStringifyJSON(parsed, true);
|
||||
}
|
||||
|
||||
if (typeof data === 'string') {
|
||||
return data;
|
||||
}
|
||||
|
||||
return safeStringifyJSON(data, true);
|
||||
};
|
||||
|
||||
const formatErrorMessage = (error) => {
|
||||
if (!error) return 'Something went wrong';
|
||||
|
||||
@@ -106,7 +56,7 @@ const QueryResult = ({ item, collection, data, dataBuffer, disableRunEventListen
|
||||
if (isLargeResponse && !showLargeResponse) {
|
||||
return '';
|
||||
}
|
||||
return formatResponse(data, dataBuffer, responseEncoding, mode, filter);
|
||||
return formatResponse(data, dataBuffer, mode, filter);
|
||||
},
|
||||
[data, dataBuffer, responseEncoding, mode, filter, isLargeResponse, showLargeResponse]
|
||||
);
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,130 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { IconBookmark } from '@tabler/icons';
|
||||
import { addResponseExample } from 'providers/ReduxStore/slices/collections';
|
||||
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { insertTaskIntoQueue } from 'providers/ReduxStore/slices/app';
|
||||
import { uuid } from 'utils/common';
|
||||
import toast from 'react-hot-toast';
|
||||
import CreateExampleModal from 'components/ResponseExample/CreateExampleModal';
|
||||
import { getBodyType } from 'utils/responseBodyProcessor';
|
||||
import { getInitialExampleName } from 'utils/collections/index';
|
||||
import classnames from 'classnames';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const ResponseBookmark = ({ item, collection, responseSize }) => {
|
||||
const dispatch = useDispatch();
|
||||
const [showSaveResponseExampleModal, setShowSaveResponseExampleModal] = useState(false);
|
||||
const response = item.response || {};
|
||||
|
||||
const isResponseTooLarge = responseSize >= 5 * 1024 * 1024; // 5 MB
|
||||
|
||||
// Only show for HTTP requests
|
||||
if (item.type !== 'http-request') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleSaveClick = () => {
|
||||
if (!response || response.error) {
|
||||
toast.error('No valid response to save as example');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isResponseTooLarge) {
|
||||
toast.error('Response size exceeds 5MB limit. Cannot save as example.');
|
||||
return;
|
||||
}
|
||||
|
||||
setShowSaveResponseExampleModal(true);
|
||||
};
|
||||
|
||||
const saveAsExample = async (name, description = '') => {
|
||||
// Convert headers object to array format expected by schema
|
||||
const headersArray = response.headers && typeof response.headers === 'object'
|
||||
? Object.entries(response.headers).map(([name, value]) => ({
|
||||
name,
|
||||
value,
|
||||
enabled: true
|
||||
}))
|
||||
: [];
|
||||
|
||||
const contentTypeHeader = headersArray.find((h) => h.name?.toLowerCase() === 'content-type');
|
||||
const contentType = contentTypeHeader?.value?.toLowerCase() || '';
|
||||
|
||||
const bodyType = getBodyType(contentType);
|
||||
const content = response.data;
|
||||
|
||||
const exampleData = {
|
||||
name: name,
|
||||
status: response.status || 200,
|
||||
headers: headersArray,
|
||||
body: {
|
||||
type: bodyType,
|
||||
content: content
|
||||
},
|
||||
description: description
|
||||
};
|
||||
|
||||
// Calculate the index where the example will be saved
|
||||
// This will be the length of the examples array after adding the new one
|
||||
const existingExamples = item.draft?.examples || item.examples || [];
|
||||
const exampleIndex = existingExamples.length;
|
||||
const exampleUid = uuid();
|
||||
|
||||
dispatch(addResponseExample({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
example: {
|
||||
...exampleData,
|
||||
uid: exampleUid
|
||||
}
|
||||
}));
|
||||
|
||||
// Save the request
|
||||
await dispatch(saveRequest(item.uid, collection.uid));
|
||||
|
||||
// Task middleware will track this and open the example in a new tab once the file is reloaded
|
||||
dispatch(insertTaskIntoQueue({
|
||||
uid: exampleUid,
|
||||
type: 'OPEN_EXAMPLE',
|
||||
collectionUid: collection.uid,
|
||||
itemUid: item.uid,
|
||||
exampleIndex: exampleIndex
|
||||
}));
|
||||
|
||||
setShowSaveResponseExampleModal(false);
|
||||
toast.success(`Example "${name}" created successfully`);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledWrapper className="ml-2 flex items-center">
|
||||
<button
|
||||
onClick={handleSaveClick}
|
||||
disabled={isResponseTooLarge}
|
||||
title={
|
||||
isResponseTooLarge
|
||||
? 'Response size exceeds 5MB limit. Cannot save as example.'
|
||||
: 'Save current response as example'
|
||||
}
|
||||
className={classnames('p-1', {
|
||||
'opacity-50 cursor-not-allowed': isResponseTooLarge
|
||||
})}
|
||||
data-testid="response-bookmark-btn"
|
||||
>
|
||||
<IconBookmark size={16} strokeWidth={1.5} />
|
||||
</button>
|
||||
</StyledWrapper>
|
||||
|
||||
<CreateExampleModal
|
||||
isOpen={showSaveResponseExampleModal}
|
||||
onClose={() => setShowSaveResponseExampleModal(false)}
|
||||
onSave={saveAsExample}
|
||||
title="Save Response as Example"
|
||||
initialName={getInitialExampleName(item)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResponseBookmark;
|
||||
@@ -4,7 +4,7 @@ import statusCodePhraseMap from './get-status-code-phrase';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
// Todo: text-error class is not getting pulled in for 500 errors
|
||||
const StatusCode = ({ status }) => {
|
||||
const StatusCode = ({ status, statusText }) => {
|
||||
const getTabClassname = (status) => {
|
||||
return classnames('ml-2', {
|
||||
'text-ok': status >= 100 && status < 200,
|
||||
@@ -17,7 +17,7 @@ const StatusCode = ({ status }) => {
|
||||
|
||||
return (
|
||||
<StyledWrapper className={`response-status-code ${getTabClassname(status)}`} data-testid="response-status-code">
|
||||
{status} {statusCodePhraseMap[status]}
|
||||
{status} {statusText || statusCodePhraseMap[status]}
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -49,7 +49,7 @@ const EventTypeColors = {
|
||||
cancel: "border-amber-500/20"
|
||||
};
|
||||
|
||||
const GrpcTimelineItem = ({ timestamp, request, response, eventType, eventData, item, collection, width }) => {
|
||||
const GrpcTimelineItem = ({ timestamp, request, response, eventType, eventData, item }) => {
|
||||
const [isCollapsed, setIsCollapsed] = useState(true);
|
||||
const toggleCollapse = () => setIsCollapsed(prev => !prev);
|
||||
|
||||
@@ -83,7 +83,7 @@ const GrpcTimelineItem = ({ timestamp, request, response, eventType, eventData,
|
||||
{Object.entries(effectiveRequest.headers).map(([key, value], idx) => (
|
||||
<div key={idx} className="contents">
|
||||
<div className="text-xs font-medium overflow-hidden text-ellipsis">{key}:</div>
|
||||
<div className="text-xs overflow-hidden text-ellipsis">{value}</div>
|
||||
<div className="text-xs overflow-hidden text-ellipsis">{typeof value === 'string' ? value : '[Buffer Buffer]'}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -148,9 +148,9 @@ const GrpcTimelineItem = ({ timestamp, request, response, eventType, eventData,
|
||||
return (
|
||||
<div className="mt-2 bg-green-50 dark:bg-green-900/10 rounded p-2">
|
||||
<div className="font-semibold mb-1 text-green-700 dark:text-green-400">
|
||||
Response Message #{(response.responses.length || 0)}
|
||||
Response Message #{(response?.responses?.length) || 0}
|
||||
</div>
|
||||
{response.responses && response.responses.length > 0 ? (
|
||||
{response?.responses && response.responses.length > 0 ? (
|
||||
<pre className="text-xs bg-white dark:bg-gray-800 p-2 rounded overflow-auto max-h-[200px]">
|
||||
{JSON.stringify(response.responses[response.responses.length - 1], null, 2)}
|
||||
</pre>
|
||||
@@ -221,7 +221,7 @@ const GrpcTimelineItem = ({ timestamp, request, response, eventType, eventData,
|
||||
<div className="mt-2 bg-gray-50 dark:bg-gray-700/30 rounded p-2">
|
||||
<div className="font-semibold mb-1">Stream Ended</div>
|
||||
<div className="text-sm">
|
||||
Total messages: {response.responses.length || 0}
|
||||
Total messages: {(response?.responses?.length) || 0}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import find from 'lodash/find';
|
||||
import classnames from 'classnames';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
@@ -18,6 +18,7 @@ import ScriptErrorIcon from './ScriptErrorIcon';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import ResponseSave from 'src/components/ResponsePane/ResponseSave';
|
||||
import ResponseClear from 'src/components/ResponsePane/ResponseClear';
|
||||
import ResponseBookmark from 'src/components/ResponsePane/ResponseBookmark';
|
||||
import SkippedRequest from './SkippedRequest';
|
||||
import ClearTimeline from './ClearTimeline/index';
|
||||
import ResponseLayoutToggle from './ResponseLayoutToggle';
|
||||
@@ -50,7 +51,22 @@ const ResponsePane = ({ item, collection }) => {
|
||||
};
|
||||
|
||||
const response = item.response || {};
|
||||
const responseSize = response.size || 0;
|
||||
|
||||
const responseSize = useMemo(() => {
|
||||
if (typeof response.size === 'number') {
|
||||
return response.size;
|
||||
}
|
||||
|
||||
if (!response.dataBuffer) return 0;
|
||||
|
||||
try {
|
||||
// dataBuffer is base64 encoded, so we need to calculate the actual size
|
||||
const buffer = Buffer.from(response.dataBuffer, 'base64');
|
||||
return buffer.length;
|
||||
} catch (error) {
|
||||
return 0;
|
||||
}
|
||||
}, [response.size, response.dataBuffer]);
|
||||
|
||||
const getTabPanel = (tab) => {
|
||||
switch (tab) {
|
||||
@@ -167,6 +183,7 @@ const ResponsePane = ({ item, collection }) => {
|
||||
<>
|
||||
<ResponseClear item={item} collection={collection} />
|
||||
<ResponseSave item={item} />
|
||||
<ResponseBookmark item={item} collection={collection} responseSize={responseSize} />
|
||||
<StatusCode status={response.status} />
|
||||
<ResponseTime duration={response.duration} />
|
||||
<ResponseSize size={responseSize} />
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user