mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-05 02:18:32 +00:00
Compare commits
48 Commits
bugfix/inc
...
release/v2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -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.2.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-json-format/-/fast-json-format-0.2.0.tgz",
|
||||
"integrity": "sha512-HdcxHsca6fqk7vt7Ak4a8JTWZQt3yEPwXk8hBPKCg8PLgX7DmXEbwgv7vLLiinAQxTcSFNyz5OiWr8m4r/0tKA==",
|
||||
"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.2.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.2.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(
|
||||
|
||||
@@ -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} />
|
||||
|
||||
56
packages/bruno-app/src/components/SearchInput/index.js
Normal file
56
packages/bruno-app/src/components/SearchInput/index.js
Normal file
@@ -0,0 +1,56 @@
|
||||
import React from 'react';
|
||||
import { IconSearch, IconX } from '@tabler/icons';
|
||||
|
||||
const SearchInput = ({
|
||||
searchText,
|
||||
setSearchText,
|
||||
placeholder = 'Search',
|
||||
className = '',
|
||||
onChange,
|
||||
...props
|
||||
}) => {
|
||||
const handleChange = (e) => {
|
||||
setSearchText(e.target.value);
|
||||
if (onChange) {
|
||||
onChange(e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`relative px-2 ${className}`}>
|
||||
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
|
||||
<span className="text-gray-500 sm:text-sm">
|
||||
<IconSearch size={16} strokeWidth={1.5} />
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="search"
|
||||
placeholder={placeholder}
|
||||
id="search-input"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
className="block w-full pl-7 py-2 sm:text-sm rounded-md"
|
||||
value={searchText}
|
||||
onChange={handleChange}
|
||||
{...props}
|
||||
/>
|
||||
{searchText !== '' && (
|
||||
<div className="absolute inset-y-0 right-0 pr-4 flex items-center">
|
||||
<span
|
||||
className="close-icon"
|
||||
onClick={() => {
|
||||
setSearchText('');
|
||||
}}
|
||||
>
|
||||
<IconX size={16} strokeWidth={1.5} className="cursor-pointer" />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchInput;
|
||||
@@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
import Modal from 'components/Modal';
|
||||
import Portal from 'components/Portal';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { deleteResponseExample } from 'providers/ReduxStore/slices/collections';
|
||||
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
|
||||
const DeleteResponseExampleModal = ({ onClose, example, item, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const onConfirm = () => {
|
||||
dispatch(deleteResponseExample({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
exampleUid: example.uid
|
||||
}));
|
||||
dispatch(saveRequest(item.uid, collection.uid));
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Modal
|
||||
size="sm"
|
||||
title="Delete Example"
|
||||
confirmText="Delete"
|
||||
handleConfirm={onConfirm}
|
||||
handleCancel={onClose}
|
||||
confirmButtonClass="btn-danger"
|
||||
>
|
||||
Are you sure you want to delete the example <span className="font-semibold">{example.name}</span>?
|
||||
</Modal>
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeleteResponseExampleModal;
|
||||
@@ -0,0 +1,57 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
position: relative;
|
||||
|
||||
.menu-icon {
|
||||
color: ${(props) => props.theme.sidebar.dropdownIcon.color};
|
||||
|
||||
.dropdown {
|
||||
div[aria-expanded='true'] {
|
||||
visibility: visible;
|
||||
}
|
||||
div[aria-expanded='false'] {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.indent-block {
|
||||
border-right: ${(props) => props.theme.sidebar.collection.item.indentBorder};
|
||||
}
|
||||
|
||||
.collection-item-name {
|
||||
height: 1.875rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
position: relative;
|
||||
|
||||
span.item-name {
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&.item-hovered {
|
||||
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
|
||||
.menu-icon {
|
||||
.dropdown {
|
||||
div[aria-expanded='false'] {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.item-focused-in-tab {
|
||||
background: ${(props) => props.theme.sidebar.collection.item.bg};
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.sidebar.collection.item.bg} !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,245 @@
|
||||
import React, { useState, useRef, forwardRef, useEffect } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { addTab, makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
|
||||
import {
|
||||
updateResponseExample,
|
||||
cloneResponseExample
|
||||
} 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 { IconDots } from '@tabler/icons';
|
||||
import ExampleIcon from 'components/Icons/ExampleIcon';
|
||||
import range from 'lodash/range';
|
||||
import classnames from 'classnames';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import Modal from 'components/Modal';
|
||||
import DeleteResponseExampleModal from './DeleteResponseExampleModal';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const ExampleItem = ({ example, item, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
// Check if this example is the active tab
|
||||
const activeTabUid = useSelector((state) => state.tabs?.activeTabUid);
|
||||
const isExampleActive = activeTabUid === example.uid;
|
||||
const [editName, setEditName] = useState(example.name);
|
||||
const [showRenameModal, setShowRenameModal] = useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const dropdownTippyRef = useRef(null);
|
||||
|
||||
// Calculate indentation: item depth + 1 for examples
|
||||
const indents = range((item.depth || 0) + 1);
|
||||
|
||||
const handleExampleClick = () => {
|
||||
dispatch(addTab({
|
||||
uid: example.uid, // Use example.uid as the tab uid
|
||||
exampleUid: example.uid,
|
||||
collectionUid: collection.uid,
|
||||
type: 'response-example',
|
||||
itemUid: item.uid
|
||||
}));
|
||||
};
|
||||
|
||||
const handleDoubleClick = () => {
|
||||
dispatch(makeTabPermanent({ uid: example.uid }));
|
||||
};
|
||||
|
||||
const handleRename = () => {
|
||||
setEditName(example.name); // Set current name when opening modal
|
||||
setShowRenameModal(true);
|
||||
if (dropdownTippyRef.current) {
|
||||
dropdownTippyRef.current.hide();
|
||||
}
|
||||
};
|
||||
|
||||
// Update editName when example changes
|
||||
useEffect(() => {
|
||||
setEditName(example.name);
|
||||
}, [example.name]);
|
||||
|
||||
const handleClone = async () => {
|
||||
// Calculate the index where the cloned example will be saved
|
||||
// It will be at the end of the examples array
|
||||
const existingExamples = item.draft?.examples || item.examples || [];
|
||||
const clonedExampleIndex = existingExamples.length;
|
||||
const clonedExampleUid = uuid();
|
||||
|
||||
dispatch(cloneResponseExample({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
exampleUid: example.uid,
|
||||
clonedUid: clonedExampleUid
|
||||
}));
|
||||
|
||||
// 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: clonedExampleUid,
|
||||
type: 'OPEN_EXAMPLE',
|
||||
collectionUid: collection.uid,
|
||||
itemUid: item.uid,
|
||||
exampleIndex: clonedExampleIndex
|
||||
}));
|
||||
|
||||
if (dropdownTippyRef.current) {
|
||||
dropdownTippyRef.current.hide();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
setShowDeleteModal(true);
|
||||
if (dropdownTippyRef.current) {
|
||||
dropdownTippyRef.current.hide();
|
||||
}
|
||||
};
|
||||
|
||||
const handleRightClick = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Show the dropdown menu programmatically
|
||||
if (dropdownTippyRef.current) {
|
||||
dropdownTippyRef.current.show();
|
||||
}
|
||||
};
|
||||
|
||||
const handleRenameConfirm = (newName) => {
|
||||
// Find the example index in the original examples array
|
||||
dispatch(updateResponseExample({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
exampleUid: example.uid,
|
||||
example: {
|
||||
name: newName
|
||||
}
|
||||
}));
|
||||
dispatch(saveRequest(item.uid, collection.uid));
|
||||
setShowRenameModal(false);
|
||||
};
|
||||
|
||||
const onDropdownCreate = (instance) => {
|
||||
dropdownTippyRef.current = instance;
|
||||
};
|
||||
|
||||
const MenuIcon = forwardRef((props, ref) => {
|
||||
return (
|
||||
<div ref={ref} data-testid="response-example-menu-icon">
|
||||
<IconDots size={22} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const itemRowClassName = classnames('flex collection-item-name relative items-center', {
|
||||
'item-focused-in-tab': isExampleActive
|
||||
});
|
||||
|
||||
return (
|
||||
<StyledWrapper
|
||||
className={itemRowClassName}
|
||||
onClick={handleExampleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onContextMenu={handleRightClick}
|
||||
>
|
||||
{indents && indents.length
|
||||
? indents.map((i) => (
|
||||
<div
|
||||
className="indent-block"
|
||||
key={i}
|
||||
style={{ width: 16, minWidth: 16, height: '100%' }}
|
||||
onContextMenu={handleRightClick}
|
||||
>
|
||||
{/* Indent */}
|
||||
</div>
|
||||
))
|
||||
: null}
|
||||
<div
|
||||
className="flex flex-grow items-center h-full overflow-hidden"
|
||||
style={{ paddingLeft: 8 }}
|
||||
onContextMenu={handleRightClick}
|
||||
>
|
||||
<div style={{ width: 16, minWidth: 16 }}></div>
|
||||
<ExampleIcon size={16} color="currentColor" className="mr-2 text-gray-400 flex-shrink-0" />
|
||||
<span className="item-name truncate text-gray-700 dark:text-gray-300 ">{example.name}</span>
|
||||
</div>
|
||||
<div className="menu-icon pr-2">
|
||||
<Dropdown onCreate={onDropdownCreate} icon={<MenuIcon />} placement="bottom-start">
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={(e) => {
|
||||
dropdownTippyRef.current.hide();
|
||||
handleRename();
|
||||
}}
|
||||
data-testid="response-example-rename-option"
|
||||
>
|
||||
Rename
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={(e) => {
|
||||
dropdownTippyRef.current.hide();
|
||||
handleClone();
|
||||
}}
|
||||
data-testid="response-example-clone-option"
|
||||
>
|
||||
Clone
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item text-red-600"
|
||||
onClick={(e) => {
|
||||
dropdownTippyRef.current.hide();
|
||||
handleDelete();
|
||||
}}
|
||||
data-testid="response-example-delete-option"
|
||||
>
|
||||
Delete
|
||||
</div>
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
{showRenameModal && (
|
||||
<Modal
|
||||
size="sm"
|
||||
title="Rename Example"
|
||||
handleCancel={() => {
|
||||
setShowRenameModal(false);
|
||||
setEditName(example.name); // Reset to original name on cancel
|
||||
}}
|
||||
handleConfirm={() => handleRenameConfirm(editName)}
|
||||
confirmText="Rename"
|
||||
cancelText="Cancel"
|
||||
confirmDisabled={!editName.trim()}
|
||||
>
|
||||
<div>
|
||||
<label htmlFor="renameExampleName" className="block font-semibold">
|
||||
Example Name
|
||||
</label>
|
||||
<input
|
||||
data-testid="rename-example-name-input"
|
||||
id="renameExampleName"
|
||||
type="text"
|
||||
className="textbox mt-2"
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
placeholder="Enter example name..."
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{showDeleteModal && (
|
||||
<DeleteResponseExampleModal
|
||||
onClose={() => setShowDeleteModal(false)}
|
||||
example={example}
|
||||
item={item}
|
||||
collection={collection}
|
||||
/>
|
||||
)}
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExampleItem;
|
||||
@@ -14,7 +14,15 @@ import { useSelector } from 'react-redux';
|
||||
import { getAllVariables, getGlobalEnvironmentVariables } from 'utils/collections/index';
|
||||
import { resolveInheritedAuth } from './utils/auth-utils';
|
||||
|
||||
const GenerateCodeItem = ({ collectionUid, item, onClose }) => {
|
||||
const TEMPLATE_VAR_PATTERN = /\{\{([^}]+)\}\}/g;
|
||||
|
||||
const validateURLWithVars = (url) => {
|
||||
const isValid = isValidUrl(url);
|
||||
const hasMissingInterpolations = TEMPLATE_VAR_PATTERN.test(url);
|
||||
return isValid && !hasMissingInterpolations;
|
||||
};
|
||||
|
||||
const GenerateCodeItem = ({ collectionUid, item, onClose, isExample = false, exampleUid = null }) => {
|
||||
const languages = getLanguages();
|
||||
const collection = useSelector(state => state.collections.collections?.find(c => c.uid === collectionUid));
|
||||
const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments);
|
||||
@@ -34,22 +42,60 @@ const GenerateCodeItem = ({ collectionUid, item, onClose }) => {
|
||||
}, {});
|
||||
}
|
||||
|
||||
const requestUrl =
|
||||
get(item, 'draft.request.url') !== undefined ? get(item, 'draft.request.url') : get(item, 'request.url');
|
||||
// Function to handle normal request data
|
||||
const getNormalRequestData = () => {
|
||||
const requestUrl = get(item, 'draft.request.url') !== undefined ? get(item, 'draft.request.url') : get(item, 'request.url');
|
||||
const requestParams = get(item, 'draft.request.params') !== undefined ? get(item, 'draft.request.params') : get(item, 'request.params');
|
||||
|
||||
return {
|
||||
url: requestUrl,
|
||||
params: requestParams,
|
||||
request: get(item, 'draft.request') !== undefined ? get(item, 'draft.request') : get(item, 'request')
|
||||
};
|
||||
};
|
||||
|
||||
// Function to handle request example data
|
||||
const getExampleRequestData = () => {
|
||||
if (!isExample || !exampleUid) {
|
||||
return getNormalRequestData();
|
||||
}
|
||||
|
||||
// Find the specific example - check both draft and non-draft examples
|
||||
const examples = item.draft ? get(item, 'draft.examples', []) : get(item, 'examples', []);
|
||||
const example = examples.find((e) => e.uid === exampleUid);
|
||||
|
||||
if (!example) {
|
||||
return getNormalRequestData();
|
||||
}
|
||||
|
||||
// Use example request data
|
||||
const requestUrl = get(example, 'request.url');
|
||||
const requestParams = get(example, 'request.params');
|
||||
const requestData = get(example, 'request');
|
||||
|
||||
return {
|
||||
url: requestUrl,
|
||||
params: requestParams,
|
||||
request: requestData
|
||||
};
|
||||
};
|
||||
|
||||
// Get the appropriate request data based on mode
|
||||
const requestData = isExample ? getExampleRequestData() : getNormalRequestData();
|
||||
|
||||
const variables = useMemo(() => {
|
||||
return getAllVariables({ ...collection, globalEnvironmentVariables }, item);
|
||||
}, [collection, globalEnvironmentVariables, item]);
|
||||
|
||||
const interpolatedUrl = interpolateUrl({
|
||||
url: requestUrl,
|
||||
url: requestData.url,
|
||||
variables
|
||||
});
|
||||
|
||||
// interpolate the path params
|
||||
const finalUrl = interpolateUrlPathParams(
|
||||
interpolatedUrl,
|
||||
get(item, 'draft.request.params') !== undefined ? get(item, 'draft.request.params') : get(item, 'request.params')
|
||||
requestData.params
|
||||
);
|
||||
|
||||
// Get the full language object based on current preferences
|
||||
@@ -64,23 +110,30 @@ const GenerateCodeItem = ({ collectionUid, item, onClose }) => {
|
||||
// Resolve auth inheritance
|
||||
const resolvedRequest = resolveInheritedAuth(item, collection);
|
||||
|
||||
// Create the final item for code generation
|
||||
const finalItem = {
|
||||
...item,
|
||||
request: {
|
||||
...resolvedRequest,
|
||||
...requestData.request,
|
||||
url: finalUrl
|
||||
}
|
||||
};
|
||||
|
||||
// Update modal title based on mode
|
||||
const modalTitle = isExample ? `Generate Code - ${get(item, 'draft.examples', []).find((e) => e.uid === exampleUid)?.name || 'Example'}` : 'Generate Code';
|
||||
|
||||
return (
|
||||
<Modal size="lg" title="Generate Code" handleCancel={onClose} hideFooter={true}>
|
||||
<Modal size="lg" title={modalTitle} handleCancel={onClose} hideFooter={true}>
|
||||
<StyledWrapper>
|
||||
<div className="code-generator">
|
||||
<CodeViewToolbar />
|
||||
|
||||
<div className="editor-container">
|
||||
{isValidUrl(finalUrl) ? (
|
||||
{validateURLWithVars(finalUrl) ? (
|
||||
<CodeView
|
||||
language={selectedLanguage}
|
||||
item={{
|
||||
...item,
|
||||
request: {
|
||||
...resolvedRequest,
|
||||
url: finalUrl
|
||||
}
|
||||
}}
|
||||
item={finalItem}
|
||||
/>
|
||||
) : (
|
||||
<div className="error-message">
|
||||
|
||||
@@ -7,8 +7,11 @@ import { useDrag, useDrop } from 'react-dnd';
|
||||
import { IconChevronRight, IconDots } from '@tabler/icons';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { addTab, focusTab, makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
|
||||
import { handleCollectionItemDrop, sendRequest, showInFolder } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { toggleCollectionItem } from 'providers/ReduxStore/slices/collections';
|
||||
import { handleCollectionItemDrop, sendRequest, showInFolder, pasteItem, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { toggleCollectionItem, addResponseExample } from 'providers/ReduxStore/slices/collections';
|
||||
import { insertTaskIntoQueue } from 'providers/ReduxStore/slices/app';
|
||||
import { uuid } from 'utils/common';
|
||||
import { copyRequest } from 'providers/ReduxStore/slices/app';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import NewRequest from 'components/Sidebar/NewRequest';
|
||||
import NewFolder from 'components/Sidebar/NewFolder';
|
||||
@@ -26,11 +29,13 @@ import StyledWrapper from './StyledWrapper';
|
||||
import NetworkError from 'components/ResponsePane/NetworkError/index';
|
||||
import CollectionItemInfo from './CollectionItemInfo/index';
|
||||
import CollectionItemIcon from './CollectionItemIcon';
|
||||
import ExampleItem from './ExampleItem';
|
||||
import { scrollToTheActiveTab } from 'utils/tabs';
|
||||
import { isTabForItemActive as isTabForItemActiveSelector, isTabForItemPresent as isTabForItemPresentSelector } from 'src/selectors/tab';
|
||||
import { isEqual } from 'lodash';
|
||||
import { calculateDraggedItemNewPathname } from 'utils/collections/index';
|
||||
import { calculateDraggedItemNewPathname, getInitialExampleName } from 'utils/collections/index';
|
||||
import { sortByNameThenSequence } from 'utils/common/index';
|
||||
import CreateExampleModal from 'components/ResponseExample/CreateExampleModal';
|
||||
|
||||
const CollectionItem = ({ item, collectionUid, collectionPathname, searchText }) => {
|
||||
const _isTabForItemActiveSelector = isTabForItemActiveSelector({ itemUid: item.uid });
|
||||
@@ -40,6 +45,8 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
|
||||
const isTabForItemPresent = useSelector(_isTabForItemPresentSelector, isEqual);
|
||||
|
||||
const isSidebarDragging = useSelector((state) => state.app.isDragging);
|
||||
const collection = useSelector((state) => state.collections.collections?.find((c) => c.uid === collectionUid));
|
||||
const { hasCopiedItems } = useSelector((state) => state.app.clipboard);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// We use a single ref for drag and drop.
|
||||
@@ -48,15 +55,20 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
|
||||
const [renameItemModalOpen, setRenameItemModalOpen] = useState(false);
|
||||
const [cloneItemModalOpen, setCloneItemModalOpen] = useState(false);
|
||||
const [deleteItemModalOpen, setDeleteItemModalOpen] = useState(false);
|
||||
const [createExampleModalOpen, setCreateExampleModalOpen] = useState(false);
|
||||
const [generateCodeItemModalOpen, setGenerateCodeItemModalOpen] = useState(false);
|
||||
const [newRequestModalOpen, setNewRequestModalOpen] = useState(false);
|
||||
const [newFolderModalOpen, setNewFolderModalOpen] = useState(false);
|
||||
const [runCollectionModalOpen, setRunCollectionModalOpen] = useState(false);
|
||||
const [itemInfoModalOpen, setItemInfoModalOpen] = useState(false);
|
||||
const [examplesExpanded, setExamplesExpanded] = useState(false);
|
||||
const hasSearchText = searchText && searchText?.trim()?.length;
|
||||
const itemIsCollapsed = hasSearchText ? false : item.collapsed;
|
||||
const isFolder = isItemAFolder(item);
|
||||
|
||||
// Check if request has examples (only for HTTP requests)
|
||||
const hasExamples = isItemARequest(item) && item.type === 'http-request' && item.examples && item.examples.length > 0;
|
||||
|
||||
const [dropType, setDropType] = useState(null); // 'adjacent' or 'inside'
|
||||
|
||||
const [{ isDragging }, drag, dragPreview] = useDrag({
|
||||
@@ -154,6 +166,10 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
|
||||
'rotate-90': !itemIsCollapsed
|
||||
});
|
||||
|
||||
const examplesIconClassName = classnames({
|
||||
'rotate-90': examplesExpanded
|
||||
});
|
||||
|
||||
const itemRowClassName = classnames('flex collection-item-name relative items-center', {
|
||||
'item-focused-in-tab': isTabForItemActive,
|
||||
'item-hovered': isOver && canDrop,
|
||||
@@ -228,6 +244,18 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const handleExamplesCollapse = (e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setExamplesExpanded(!examplesExpanded);
|
||||
};
|
||||
|
||||
// prevent the parent's double-click handler from firing
|
||||
const handleExamplesDoubleClick = (e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const handleRightClick = (event) => {
|
||||
const _menuDropdown = dropdownTippyRef.current;
|
||||
if (_menuDropdown) {
|
||||
@@ -274,6 +302,50 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
|
||||
});
|
||||
};
|
||||
|
||||
const handleCreateExample = async (name, description = '') => {
|
||||
// Create example with default values
|
||||
const exampleData = {
|
||||
name: name,
|
||||
description: description,
|
||||
status: '200',
|
||||
statusText: 'OK',
|
||||
headers: [],
|
||||
body: {
|
||||
type: 'text',
|
||||
content: ''
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate the index where the example will be saved
|
||||
const existingExamples = item.draft?.examples || item.examples || [];
|
||||
const exampleIndex = existingExamples.length;
|
||||
const exampleUid = uuid();
|
||||
|
||||
dispatch(addResponseExample({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collectionUid,
|
||||
example: {
|
||||
...exampleData,
|
||||
uid: exampleUid
|
||||
}
|
||||
}));
|
||||
|
||||
// Save the request
|
||||
await dispatch(saveRequest(item.uid, collectionUid));
|
||||
|
||||
// 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: collectionUid,
|
||||
itemUid: item.uid,
|
||||
exampleIndex: exampleIndex
|
||||
}));
|
||||
|
||||
toast.success(`Example "${name}" created successfully`);
|
||||
setCreateExampleModalOpen(false);
|
||||
};
|
||||
|
||||
const folderItems = sortByNameThenSequence(filter(item.items, (i) => isItemAFolder(i)));
|
||||
const requestItems = sortItemsBySequence(filter(item.items, (i) => isItemARequest(i)));
|
||||
|
||||
@@ -306,6 +378,23 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyRequest = () => {
|
||||
dropdownTippyRef.current.hide();
|
||||
dispatch(copyRequest(item));
|
||||
toast.success('Request copied to clipboard');
|
||||
};
|
||||
|
||||
const handlePasteRequest = () => {
|
||||
dropdownTippyRef.current.hide();
|
||||
dispatch(pasteItem(collectionUid, item.uid))
|
||||
.then(() => {
|
||||
toast.success('Request pasted successfully');
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err ? err.message : 'An error occurred while pasting the request');
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper className={className}>
|
||||
{renameItemModalOpen && (
|
||||
@@ -332,6 +421,13 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
|
||||
{itemInfoModalOpen && (
|
||||
<CollectionItemInfo item={item} onClose={() => setItemInfoModalOpen(false)} />
|
||||
)}
|
||||
<CreateExampleModal
|
||||
isOpen={createExampleModalOpen}
|
||||
onClose={() => setCreateExampleModalOpen(false)}
|
||||
onSave={handleCreateExample}
|
||||
title="Create Response Example"
|
||||
initialName={getInitialExampleName(item)}
|
||||
/>
|
||||
<div
|
||||
className={itemRowClassName}
|
||||
ref={(node) => {
|
||||
@@ -370,6 +466,17 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
|
||||
style={{ color: 'rgb(160 160 160)' }}
|
||||
onClick={handleFolderCollapse}
|
||||
onDoubleClick={handleFolderDoubleClick}
|
||||
data-testid="folder-chevron"
|
||||
/>
|
||||
) : hasExamples ? (
|
||||
<IconChevronRight
|
||||
size={16}
|
||||
strokeWidth={2}
|
||||
className={examplesIconClassName}
|
||||
style={{ color: 'rgb(160 160 160)' }}
|
||||
onClick={handleExamplesCollapse}
|
||||
onDoubleClick={handleExamplesDoubleClick}
|
||||
data-testid="request-item-chevron"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -431,6 +538,22 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
|
||||
>
|
||||
Clone
|
||||
</div>
|
||||
{!isFolder && (
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={handleCopyRequest}
|
||||
>
|
||||
Copy
|
||||
</div>
|
||||
)}
|
||||
{isFolder && hasCopiedItems && (
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={handlePasteRequest}
|
||||
>
|
||||
Paste
|
||||
</div>
|
||||
)}
|
||||
{!isFolder && (
|
||||
<div
|
||||
className="dropdown-item"
|
||||
@@ -453,6 +576,17 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
|
||||
Generate Code
|
||||
</div>
|
||||
)}
|
||||
{!isFolder && isItemARequest(item) && item.type === 'http-request' && (
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={(e) => {
|
||||
dropdownTippyRef.current.hide();
|
||||
setCreateExampleModalOpen(true);
|
||||
}}
|
||||
>
|
||||
Create Example
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={(e) => {
|
||||
@@ -509,6 +643,23 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
|
||||
: null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Show examples when expanded (only for HTTP requests) */}
|
||||
{isItemARequest(item) && item.type === 'http-request' && examplesExpanded && hasExamples && (
|
||||
<div>
|
||||
{(item.examples || []).map((example, index) => {
|
||||
return (
|
||||
<ExampleItem
|
||||
key={example.uid || index}
|
||||
example={example}
|
||||
item={item}
|
||||
index={index}
|
||||
collection={collection}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,10 +7,11 @@ import { useDrop, useDrag } from 'react-dnd';
|
||||
import { IconChevronRight, IconDots, IconLoader2 } from '@tabler/icons';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import { toggleCollection, collapseFullCollection } from 'providers/ReduxStore/slices/collections';
|
||||
import { mountCollection, moveCollectionAndPersist, handleCollectionItemDrop } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { mountCollection, moveCollectionAndPersist, handleCollectionItemDrop, pasteItem } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { hideHomePage } from 'providers/ReduxStore/slices/app';
|
||||
import { addTab, makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
|
||||
import toast from 'react-hot-toast';
|
||||
import NewRequest from 'components/Sidebar/NewRequest';
|
||||
import NewFolder from 'components/Sidebar/NewFolder';
|
||||
import CollectionItem from './CollectionItem';
|
||||
@@ -41,7 +42,7 @@ const Collection = ({ collection, searchText }) => {
|
||||
const collectionRef = useRef(null);
|
||||
|
||||
const isCollectionFocused = useSelector(isTabForItemActive({ itemUid: collection.uid }));
|
||||
|
||||
const { hasCopiedItems } = useSelector((state) => state.app.clipboard);
|
||||
const menuDropdownTippyRef = useRef();
|
||||
const onMenuDropdownCreate = (ref) => (menuDropdownTippyRef.current = ref);
|
||||
const MenuIcon = forwardRef((_props, ref) => {
|
||||
@@ -146,6 +147,17 @@ const Collection = ({ collection, searchText }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const handlePasteRequest = () => {
|
||||
menuDropdownTippyRef.current.hide();
|
||||
dispatch(pasteItem(collection.uid, null))
|
||||
.then(() => {
|
||||
toast.success('Request pasted successfully');
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err ? err.message : 'An error occurred while pasting the request');
|
||||
});
|
||||
};
|
||||
|
||||
const isCollectionItem = (itemType) => {
|
||||
return itemType === 'collection-item';
|
||||
};
|
||||
@@ -286,6 +298,14 @@ const Collection = ({ collection, searchText }) => {
|
||||
>
|
||||
Clone
|
||||
</div>
|
||||
{hasCopiedItems && (
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={handlePasteRequest}
|
||||
>
|
||||
Paste
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={(_e) => {
|
||||
|
||||
@@ -5,14 +5,21 @@ import Modal from 'components/Modal';
|
||||
import jsyaml from 'js-yaml';
|
||||
import { postmanToBruno, isPostmanCollection } from 'utils/importers/postman-collection';
|
||||
import { convertInsomniaToBruno, isInsomniaCollection } from 'utils/importers/insomnia-collection';
|
||||
import { isOpenApiSpec, convertOpenapiToBruno } from 'utils/importers/openapi-collection';
|
||||
import { convertOpenapiToBruno, isOpenApiSpec } from 'utils/importers/openapi-collection';
|
||||
import { isWSDLCollection } from 'utils/importers/wsdl-collection';
|
||||
import { processBrunoCollection } from 'utils/importers/bruno-collection';
|
||||
import { wsdlToBruno } from '@usebruno/converters';
|
||||
import ImportSettings from 'components/Sidebar/ImportSettings';
|
||||
import FullscreenLoader from './FullscreenLoader/index';
|
||||
|
||||
const convertFileToObject = async (file) => {
|
||||
const text = await file.text();
|
||||
|
||||
// Handle WSDL files - return as plain text
|
||||
if (file.name.endsWith('.wsdl') || file.type === 'text/xml' || file.type === 'application/xml') {
|
||||
return text;
|
||||
}
|
||||
|
||||
try {
|
||||
if (file.type === 'application/json' || file.name.endsWith('.json')) {
|
||||
return JSON.parse(text);
|
||||
@@ -79,8 +86,9 @@ const ImportCollection = ({ onClose, handleSubmit }) => {
|
||||
}
|
||||
|
||||
let collection;
|
||||
|
||||
if (isPostmanCollection(data)) {
|
||||
if (isWSDLCollection(data)) {
|
||||
collection = await wsdlToBruno(data);
|
||||
} else if (isPostmanCollection(data)) {
|
||||
collection = await postmanToBruno(data);
|
||||
} else if (isInsomniaCollection(data)) {
|
||||
collection = convertInsomniaToBruno(data);
|
||||
@@ -120,7 +128,17 @@ const ImportCollection = ({ onClose, handleSubmit }) => {
|
||||
return <FullscreenLoader isLoading={isLoading} />;
|
||||
}
|
||||
|
||||
const acceptedFileTypes = ['.json', '.yaml', '.yml', 'application/json', 'application/yaml', 'application/x-yaml'];
|
||||
const acceptedFileTypes = [
|
||||
'.json',
|
||||
'.yaml',
|
||||
'.yml',
|
||||
'.wsdl',
|
||||
'application/json',
|
||||
'application/yaml',
|
||||
'application/x-yaml',
|
||||
'text/xml',
|
||||
'application/xml'
|
||||
];
|
||||
|
||||
if (showImportSettings) {
|
||||
return (
|
||||
@@ -170,7 +188,7 @@ const ImportCollection = ({ onClose, handleSubmit }) => {
|
||||
</button>
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Supports Bruno, Postman, Insomnia, and OpenAPI v3 formats
|
||||
Supports Bruno, Postman, Insomnia, OpenAPI v3, and WSDL formats
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -90,7 +90,7 @@ const NewFolder = ({ collectionUid, item, onClose }) => {
|
||||
Folder Name
|
||||
</label>
|
||||
<input
|
||||
id="collection-name"
|
||||
id="folder-name"
|
||||
type="text"
|
||||
name="folderName"
|
||||
ref={inputRef}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user