mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-03 17:38:36 +00:00
Compare commits
61 Commits
workspaces
...
bugfix/def
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90a4f8271e | ||
|
|
0c62ead394 | ||
|
|
b82b3088c1 | ||
|
|
72dccdb8e3 | ||
|
|
013c128c78 | ||
|
|
9053c47789 | ||
|
|
f3fd8a1ce0 | ||
|
|
575f37124c | ||
|
|
50a72a16bc | ||
|
|
98513c65f0 | ||
|
|
b61d2212f6 | ||
|
|
1ed957978a | ||
|
|
c00cbf6cb2 | ||
|
|
632f8705e5 | ||
|
|
f8548225e1 | ||
|
|
7fe6b47aa0 | ||
|
|
43f24ad0f1 | ||
|
|
a798b32f25 | ||
|
|
4d1c3f9e52 | ||
|
|
879d2271b7 | ||
|
|
cf4c896431 | ||
|
|
f6363389d0 | ||
|
|
03e8f2d67d | ||
|
|
8e855e53bf | ||
|
|
599636d56b | ||
|
|
9b9534c1eb | ||
|
|
0197ae37c8 | ||
|
|
cf969dfcd6 | ||
|
|
a66be21523 | ||
|
|
4016754d71 | ||
|
|
f3aebf6374 | ||
|
|
f87460b00e | ||
|
|
354e8d7496 | ||
|
|
dc107f8b96 | ||
|
|
cd0f1e45ba | ||
|
|
33022843f2 | ||
|
|
facdf3264a | ||
|
|
4ffb447c53 | ||
|
|
3e5ae613f5 | ||
|
|
42bef4ae1e | ||
|
|
e93e545b81 | ||
|
|
4a8d787f31 | ||
|
|
f5211f6a08 | ||
|
|
57222d2500 | ||
|
|
f479e0d325 | ||
|
|
5302addda0 | ||
|
|
80b017f224 | ||
|
|
b18d582004 | ||
|
|
109394c65b | ||
|
|
c355153f26 | ||
|
|
b87a02beb3 | ||
|
|
4624ffb116 | ||
|
|
a9ce97fb1b | ||
|
|
72ce6cadeb | ||
|
|
c4ff2918a2 | ||
|
|
9972eb3de6 | ||
|
|
ebe0203415 | ||
|
|
b3ef91fe8e | ||
|
|
f7ea1f8dbb | ||
|
|
cf19035b0b | ||
|
|
d9a3f74cb7 |
@@ -6,7 +6,7 @@
|
||||
|
||||
- Use 2 spaces for indentation. No tabs, just spaces – keeps everything neat and uniform.
|
||||
|
||||
- Stick to single quotes for strings. Double quotes are cool elsewhere, but here we go single.
|
||||
- Stick to single quotes for strings. For JSX/TSX attributes, use double quotes (e.g., <svg xmlns="..." viewBox="...">) to follow React conventions.
|
||||
|
||||
- Always add semicolons at the end of statements. It's like putting a period at the end of a sentence – clarity matters.
|
||||
|
||||
|
||||
@@ -18,7 +18,9 @@ module.exports = runESMImports().then(() => defineConfig([
|
||||
'**/dist/**/*',
|
||||
'**/*.bru',
|
||||
'packages/bruno-js/src/sandbox/bundle-browser-rollup.js',
|
||||
'packages/bruno-app/public/static/**/*'
|
||||
'packages/bruno-app/public/static/**/*',
|
||||
'packages/bruno-app/.next/**/*',
|
||||
'packages/bruno-electron/web/**/*'
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
1977
package-lock.json
generated
1977
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -45,6 +45,8 @@
|
||||
"idb": "^7.0.0",
|
||||
"immer": "^9.0.15",
|
||||
"jsesc": "^3.0.2",
|
||||
"js-yaml": "^4.1.0",
|
||||
"xml2js": "^0.6.2",
|
||||
"jshint": "^2.13.6",
|
||||
"json5": "^2.2.3",
|
||||
"jsonc-parser": "^3.2.1",
|
||||
@@ -83,6 +85,7 @@
|
||||
"shell-quote": "^1.8.3",
|
||||
"strip-json-comments": "^5.0.1",
|
||||
"styled-components": "^5.3.3",
|
||||
"swagger-ui-react": "5.17.12",
|
||||
"system": "^2.0.1",
|
||||
"url": "^0.11.3",
|
||||
"xml-formatter": "^3.5.0",
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
const yamlPlugin = (cm) => {
|
||||
cm.defineMode('yaml', function () {
|
||||
var cons = ['true', 'false', 'on', 'off', 'yes', 'no'];
|
||||
var keywordRegex = new RegExp('\\b((' + cons.join(')|(') + '))$', 'i');
|
||||
|
||||
return {
|
||||
token: function (stream, state) {
|
||||
var ch = stream.peek();
|
||||
var esc = state.escaped;
|
||||
state.escaped = false;
|
||||
/* comments */
|
||||
if (ch == '#' && (stream.pos == 0 || /\s/.test(stream.string.charAt(stream.pos - 1)))) {
|
||||
stream.skipToEnd();
|
||||
return 'comment';
|
||||
}
|
||||
|
||||
if (stream.match(/^('([^']|\\.)*'?|"([^"]|\\.)*"?)/)) return 'string';
|
||||
|
||||
if (state.literal && stream.indentation() > state.keyCol) {
|
||||
stream.skipToEnd();
|
||||
return 'string';
|
||||
} else if (state.literal) {
|
||||
state.literal = false;
|
||||
}
|
||||
if (stream.sol()) {
|
||||
state.keyCol = 0;
|
||||
state.pair = false;
|
||||
state.pairStart = false;
|
||||
/* document start */
|
||||
if (stream.match('---')) {
|
||||
return 'def';
|
||||
}
|
||||
/* document end */
|
||||
if (stream.match('...')) {
|
||||
return 'def';
|
||||
}
|
||||
/* array list item */
|
||||
if (stream.match(/\s*-\s+/)) {
|
||||
return 'meta';
|
||||
}
|
||||
}
|
||||
/* inline pairs/lists */
|
||||
if (stream.match(/^(\{|\}|\[|\])/)) {
|
||||
if (ch == '{') state.inlinePairs++;
|
||||
else if (ch == '}') state.inlinePairs--;
|
||||
else if (ch == '[') state.inlineList++;
|
||||
else state.inlineList--;
|
||||
return 'meta';
|
||||
}
|
||||
|
||||
/* list separator */
|
||||
if (state.inlineList > 0 && !esc && ch == ',') {
|
||||
stream.next();
|
||||
return 'meta';
|
||||
}
|
||||
/* pairs separator */
|
||||
if (state.inlinePairs > 0 && !esc && ch == ',') {
|
||||
state.keyCol = 0;
|
||||
state.pair = false;
|
||||
state.pairStart = false;
|
||||
stream.next();
|
||||
return 'meta';
|
||||
}
|
||||
|
||||
/* start of value of a pair */
|
||||
if (state.pairStart) {
|
||||
/* block literals */
|
||||
if (stream.match(/^\s*(\||\>)\s*/)) {
|
||||
state.literal = true;
|
||||
return 'meta';
|
||||
}
|
||||
/* references */
|
||||
if (stream.match(/^\s*(\&|\*)[a-z0-9\._-]+\b/i)) {
|
||||
return 'variable-2';
|
||||
}
|
||||
/* numbers */
|
||||
if (state.inlinePairs == 0 && stream.match(/^\s*-?[0-9\.\,]+\s?$/)) {
|
||||
return 'number';
|
||||
}
|
||||
if (state.inlinePairs > 0 && stream.match(/^\s*-?[0-9\.\,]+\s?(?=(,|}))/)) {
|
||||
return 'number';
|
||||
}
|
||||
/* keywords */
|
||||
if (stream.match(keywordRegex)) {
|
||||
return 'keyword';
|
||||
}
|
||||
}
|
||||
|
||||
/* pairs (associative arrays) -> key */
|
||||
if (
|
||||
!state.pair
|
||||
&& stream.match(/^\s*(?:[,\[\]{}&*!|>'"%@`][^\s'":]|[^\s,\[\]{}#&*!|>'"%@`])[^#:]*(?=:($|\s))/)
|
||||
) {
|
||||
state.pair = true;
|
||||
state.keyCol = stream.indentation();
|
||||
return 'atom';
|
||||
}
|
||||
if (state.pair && stream.match(/^:\s*/)) {
|
||||
state.pairStart = true;
|
||||
return 'meta';
|
||||
}
|
||||
|
||||
/* nothing found, continue */
|
||||
state.pairStart = false;
|
||||
state.escaped = ch == '\\';
|
||||
stream.next();
|
||||
return null;
|
||||
},
|
||||
startState: function () {
|
||||
return {
|
||||
pair: false,
|
||||
pairStart: false,
|
||||
keyCol: 0,
|
||||
inlinePairs: 0,
|
||||
inlineList: 0,
|
||||
literal: false,
|
||||
escaped: false
|
||||
};
|
||||
},
|
||||
lineComment: '#',
|
||||
fold: 'indent'
|
||||
};
|
||||
});
|
||||
|
||||
cm.defineMIME('text/x-yaml', 'yaml');
|
||||
cm.defineMIME('text/yaml', 'yaml');
|
||||
};
|
||||
|
||||
export default yamlPlugin;
|
||||
@@ -0,0 +1,65 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
div.CodeMirror {
|
||||
height: calc(100vh - 4rem);
|
||||
background: ${(props) => props.theme.codemirror.bg};
|
||||
border: solid 1px ${(props) => props.theme.codemirror.border};
|
||||
font-family: ${(props) => (props.font ? props.font : 'default')};
|
||||
line-break: anywhere;
|
||||
}
|
||||
|
||||
.CodeMirror-dialog {
|
||||
overflow: visible;
|
||||
input {
|
||||
background: transparent;
|
||||
border: 1px solid #d3d6db;
|
||||
outline: none;
|
||||
border-radius: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.CodeMirror-overlayscroll-horizontal div,
|
||||
.CodeMirror-overlayscroll-vertical div {
|
||||
background: #d2d7db;
|
||||
}
|
||||
|
||||
textarea.cm-editor {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
// Todo: dark mode temporary fix
|
||||
// Clean this
|
||||
.CodeMirror.cm-s-monokai {
|
||||
.CodeMirror-overlayscroll-horizontal div,
|
||||
.CodeMirror-overlayscroll-vertical div {
|
||||
background: #444444;
|
||||
}
|
||||
}
|
||||
|
||||
.cm-s-monokai span.cm-property,
|
||||
.cm-s-monokai span.cm-attribute {
|
||||
color: #9cdcfe !important;
|
||||
}
|
||||
|
||||
.cm-s-monokai span.cm-string {
|
||||
color: #ce9178 !important;
|
||||
}
|
||||
|
||||
.cm-s-monokai span.cm-number {
|
||||
color: #b5cea8 !important;
|
||||
}
|
||||
|
||||
.cm-s-monokai span.cm-atom {
|
||||
color: #569cd6 !important;
|
||||
}
|
||||
|
||||
.cm-variable-valid {
|
||||
color: green;
|
||||
}
|
||||
.cm-variable-invalid {
|
||||
color: red;
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Copyright (c) 2021 GraphQL Contributors.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import yamlPlugin from './Plugins/Yaml/index';
|
||||
|
||||
let CodeMirror;
|
||||
const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
|
||||
|
||||
if (!SERVER_RENDERED) {
|
||||
CodeMirror = require('codemirror');
|
||||
}
|
||||
|
||||
export default class CodeEditor extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.cachedValue = props.value || '';
|
||||
this.variables = {};
|
||||
this.lintOptions = {
|
||||
esversion: 11,
|
||||
expr: true,
|
||||
asi: true
|
||||
};
|
||||
}
|
||||
|
||||
componentWillMount() {
|
||||
switch (this.props.mode) {
|
||||
case 'yaml':
|
||||
// YAML linting and hightlighting plugin
|
||||
yamlPlugin(CodeMirror);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const editor = (this.editor = CodeMirror(this._node, {
|
||||
value: this.props.value || '',
|
||||
lineNumbers: true,
|
||||
lineWrapping: true,
|
||||
tabSize: 2,
|
||||
mode: this.props.mode || 'application/text',
|
||||
keyMap: 'sublime',
|
||||
autoCloseBrackets: true,
|
||||
matchBrackets: true,
|
||||
showCursorWhenSelecting: true,
|
||||
foldGutter: true,
|
||||
gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter', 'CodeMirror-lint-markers'],
|
||||
lint: this.lintOptions,
|
||||
readOnly: this.props.readOnly,
|
||||
scrollbarStyle: 'overlay',
|
||||
theme: this.props.theme === 'dark' ? 'monokai' : 'default',
|
||||
extraKeys: {
|
||||
'Cmd-S': () => {
|
||||
if (this.props.onSave) {
|
||||
this.props.onSave();
|
||||
}
|
||||
},
|
||||
'Ctrl-S': () => {
|
||||
if (this.props.onSave) {
|
||||
this.props.onSave();
|
||||
}
|
||||
},
|
||||
'Cmd-F': 'findPersistent',
|
||||
'Ctrl-F': 'findPersistent',
|
||||
'Cmd-H': 'replace',
|
||||
'Ctrl-H': 'replace',
|
||||
'Tab': function (cm) {
|
||||
cm.getSelection().includes('\n') || editor.getLine(cm.getCursor().line) == cm.getSelection()
|
||||
? cm.execCommand('indentMore')
|
||||
: cm.replaceSelection(' ', 'end');
|
||||
},
|
||||
'Shift-Tab': 'indentLess',
|
||||
'Ctrl-Space': 'autocomplete',
|
||||
'Cmd-Space': 'autocomplete',
|
||||
'Ctrl-Y': 'foldAll',
|
||||
'Cmd-Y': 'foldAll',
|
||||
'Ctrl-I': 'unfoldAll',
|
||||
'Cmd-I': 'unfoldAll'
|
||||
}
|
||||
}));
|
||||
if (editor) {
|
||||
editor.setOption('lint', this.props.mode && editor.getValue().trim().length > 0 ? this.lintOptions : false);
|
||||
editor.on('change', this._onEdit);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
this.ignoreChangeEvent = true;
|
||||
if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue && this.editor) {
|
||||
this.cachedValue = this.props.value;
|
||||
this.editor.setValue(this.props.value);
|
||||
}
|
||||
if (this.props.theme !== prevProps.theme && this.editor) {
|
||||
this.editor.setOption('theme', this.props.theme === 'dark' ? 'monokai' : 'default');
|
||||
}
|
||||
this.ignoreChangeEvent = false;
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.editor) {
|
||||
this.editor.off('change', this._onEdit);
|
||||
this.editor = null;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.editor) {
|
||||
this.editor.refresh();
|
||||
}
|
||||
return (
|
||||
<StyledWrapper
|
||||
className="h-full w-full graphiql-container"
|
||||
aria-label="Code Editor"
|
||||
font={this.props.font}
|
||||
ref={(node) => {
|
||||
this._node = node;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
_onEdit = () => {
|
||||
if (!this.ignoreChangeEvent && this.editor) {
|
||||
this.editor.setOption('lint', this.editor.getValue().trim().length > 0 ? this.lintOptions : false);
|
||||
this.cachedValue = this.editor.getValue();
|
||||
if (this.props.onEdit) {
|
||||
this.props.onEdit(this.cachedValue);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import get from 'lodash/get';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import CodeEditor from './CodeEditor/index';
|
||||
import { IconDeviceFloppy } from '@tabler/icons';
|
||||
import { saveApiSpecToFile } from 'providers/ReduxStore/slices/apiSpec';
|
||||
import { useState } from 'react';
|
||||
|
||||
const FileEditor = ({ apiSpec }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { displayedTheme, theme } = useTheme();
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
|
||||
const [content, setContent] = useState(apiSpec?.raw);
|
||||
|
||||
const onEdit = (value) => {
|
||||
setContent(value);
|
||||
};
|
||||
|
||||
const onSave = () => {
|
||||
dispatch(saveApiSpecToFile({ uid: apiSpec?.uid, content }));
|
||||
};
|
||||
|
||||
const hasChanges = Boolean(content != apiSpec?.raw);
|
||||
|
||||
const editorMode = 'yaml';
|
||||
|
||||
return (
|
||||
<div className="flex flex-grow relative">
|
||||
<CodeEditor
|
||||
theme={displayedTheme}
|
||||
value={content}
|
||||
onEdit={onEdit}
|
||||
onSave={onSave}
|
||||
mode={editorMode}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
/>
|
||||
<IconDeviceFloppy
|
||||
onClick={onSave}
|
||||
color={hasChanges ? theme.colors.text.yellow : theme.requestTabs.icon.color}
|
||||
strokeWidth={1.5}
|
||||
size={22}
|
||||
className={`absolute right-0 top-0 m-4 ${
|
||||
hasChanges ? 'cursor-pointer oapcity-100' : 'cursor-default opacity-50'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileEditor;
|
||||
@@ -0,0 +1,19 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.swagger-root {
|
||||
height: calc(100vh - 4rem);
|
||||
border: solid 1px ${(props) => props.theme.codemirror.border};
|
||||
|
||||
&.dark {
|
||||
.swagger-ui {
|
||||
filter: invert(88%) hue-rotate(180deg);
|
||||
}
|
||||
.swagger-ui .microlight {
|
||||
filter: invert(100%) hue-rotate(180deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,19 @@
|
||||
import SwaggerUI from 'swagger-ui-react';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
|
||||
const Swagger = ({ string }) => {
|
||||
const { displayedTheme } = useTheme();
|
||||
|
||||
console.log('string', string);
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className={`swagger-root w-full overflow-y-scroll ${displayedTheme}`}>
|
||||
<SwaggerUI spec={string} />
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default Swagger;
|
||||
@@ -0,0 +1,22 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.menu-icon {
|
||||
cursor: pointer;
|
||||
color: ${(props) => props.theme.sidebar.dropdownIcon.color};
|
||||
}
|
||||
|
||||
div.dropdown-item.menu-item {
|
||||
color: ${(props) => props.theme.colors.danger};
|
||||
&:hover {
|
||||
background-color: ${(props) => props.theme.colors.bg.danger};
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.react-tooltip {
|
||||
z-index: 10;
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
97
packages/bruno-app/src/components/ApiSpecPanel/index.js
Normal file
97
packages/bruno-app/src/components/ApiSpecPanel/index.js
Normal file
@@ -0,0 +1,97 @@
|
||||
import React, { forwardRef, useRef } from 'react';
|
||||
import find from 'lodash/find';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { IconFileCode, IconDots } from '@tabler/icons';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import FileEditor from './FileEditor';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import { openApiSpec } from 'providers/ReduxStore/slices/apiSpec';
|
||||
import { useState } from 'react';
|
||||
import CreateApiSpec from 'components/Sidebar/ApiSpecs/CreateApiSpec';
|
||||
import { Suspense } from 'react';
|
||||
import Swagger from './Renderers/Swagger';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const ApiSpecPanel = () => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const [createApiSpecModalOpen, setCreateApiSpecModalOpen] = useState(false);
|
||||
|
||||
const { apiSpecs, activeApiSpecUid } = useSelector((state) => state.apiSpec);
|
||||
|
||||
const dropdownTippyRef = useRef();
|
||||
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
|
||||
|
||||
let apiSpec = find(apiSpecs, (c) => c.uid === activeApiSpecUid);
|
||||
const { filename, pathname, raw, uid } = apiSpec || {};
|
||||
if (!uid) {
|
||||
return <div className="p-4 opacity-50">API Spec not found!</div>;
|
||||
}
|
||||
|
||||
const MenuIcon = forwardRef((props, ref) => {
|
||||
return (
|
||||
<div ref={ref}>
|
||||
<IconDots size={22} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const handleOpenApiSpec = () => {
|
||||
dispatch(openApiSpec()).catch(
|
||||
(err) => console.log(err) && toast.error('An error occurred while opening the API spec')
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper className="flex flex-col flex-grow relative">
|
||||
{createApiSpecModalOpen ? <CreateApiSpec onClose={() => setCreateApiSpecModalOpen(false)} /> : null}
|
||||
<div className="p-3 mb-2 w-full flex flex-row justify-between grid grid-cols-3">
|
||||
<div className="flex flex-row justify-start gap-x-4 col-span-1">
|
||||
<div className="flex w-fit items-center cursor-pointer">
|
||||
<IconFileCode size={18} strokeWidth={1.5} />
|
||||
<span className="ml-2 mr-4 font-semibold">API Designer</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full col-span-1 flex justify-center" title={pathname}>
|
||||
{filename}
|
||||
</div>
|
||||
<div className="menu-icon pr-2 col-span-1 flex justify-end">
|
||||
<Dropdown onCreate={onDropdownCreate} icon={<MenuIcon />} placement="bottom-start">
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={(e) => {
|
||||
dropdownTippyRef.current.hide();
|
||||
setCreateApiSpecModalOpen(true);
|
||||
}}
|
||||
>
|
||||
Create API Spec
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={(e) => {
|
||||
dropdownTippyRef.current.hide();
|
||||
handleOpenApiSpec();
|
||||
}}
|
||||
>
|
||||
Open API Spec
|
||||
</div>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
<section className="main flex flex-grow px-4 relative">
|
||||
<div className="w-full grid grid-cols-2">
|
||||
<div className="col-span-1">
|
||||
<FileEditor apiSpec={apiSpec} />
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<Suspense fallback="">
|
||||
<Swagger string={raw} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApiSpecPanel;
|
||||
198
packages/bruno-app/src/components/AppTitleBar/StyledWrapper.js
Normal file
198
packages/bruno-app/src/components/AppTitleBar/StyledWrapper.js
Normal file
@@ -0,0 +1,198 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: ${(props) => props.theme.sidebar.bg};
|
||||
border-bottom: 1px solid ${(props) => props.theme.sidebar.collection.item.hoverBg};
|
||||
-webkit-app-region: drag;
|
||||
user-select: none;
|
||||
|
||||
.titlebar-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0 12px;
|
||||
padding-left: 70px; /* Space for macOS window controls */
|
||||
transition: padding-left 0.15s ease;
|
||||
}
|
||||
|
||||
/* When in full screen, no traffic lights so reduce padding */
|
||||
&.fullscreen .titlebar-content {
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
/* Remove drag region from interactive elements */
|
||||
.workspace-name-container,
|
||||
.dropdown-item,
|
||||
.home-button,
|
||||
.dropdown,
|
||||
button {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
/* Left section */
|
||||
.titlebar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
margin-left: 10px;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
/* When in full screen, no traffic lights so remove margin-left */
|
||||
&.fullscreen .titlebar-left {
|
||||
margin-left: 0px;
|
||||
}
|
||||
|
||||
/* Workspace Name Dropdown Trigger */
|
||||
.workspace-name-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
|
||||
}
|
||||
|
||||
.workspace-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: ${(props) => props.theme.sidebar.color};
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 180px;
|
||||
}
|
||||
|
||||
.chevron-icon {
|
||||
flex-shrink: 0;
|
||||
color: ${(props) => props.theme.sidebar.muted};
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
}
|
||||
|
||||
/* Center section - Bruno branding */
|
||||
.titlebar-center {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
pointer-events: none;
|
||||
|
||||
.bruno-text {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: ${(props) => props.theme.text};
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Right section */
|
||||
.titlebar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Workspace Dropdown Styles */
|
||||
.workspace-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 4px 10px !important;
|
||||
margin: 0 !important;
|
||||
|
||||
&.active {
|
||||
.check-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.pin-btn:not(.pinned) {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.workspace-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
color: ${(props) => props.theme.dropdown.color};
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.workspace-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-left: 8px;
|
||||
flex-shrink: 0;
|
||||
pointer-events: none;
|
||||
|
||||
> * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.check-icon {
|
||||
color: ${(props) => props.theme.workspace?.accent || props.theme.colors?.text?.yellow};
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pin-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: ${(props) => props.theme.dropdown.mutedText};
|
||||
transition: background 0.15s ease, color 0.15s ease, opacity 0.15s ease;
|
||||
opacity: 0;
|
||||
|
||||
&.pinned {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.dropdown.hoverBg};
|
||||
color: ${(props) => props.theme.dropdown.mutedText};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Adjust for non-macOS platforms */
|
||||
body:not(.os-mac) & {
|
||||
.titlebar-content {
|
||||
padding-left: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Leave room for Windows caption buttons when the overlay is enabled */
|
||||
body.os-windows & {
|
||||
.titlebar-content {
|
||||
padding-right: 120px;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default Wrapper;
|
||||
252
packages/bruno-app/src/components/AppTitleBar/index.js
Normal file
252
packages/bruno-app/src/components/AppTitleBar/index.js
Normal file
@@ -0,0 +1,252 @@
|
||||
import React from 'react';
|
||||
import { IconCheck, IconChevronDown, IconFolder, IconHome, IconLayoutColumns, IconLayoutRows, IconPin, IconPinned, IconPlus } from '@tabler/icons';
|
||||
import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { savePreferences, showHomePage, toggleSidebarCollapse } from 'providers/ReduxStore/slices/app';
|
||||
import { closeConsole, openConsole } from 'providers/ReduxStore/slices/logs';
|
||||
import { openWorkspaceDialog, switchWorkspace } from 'providers/ReduxStore/slices/workspaces/actions';
|
||||
import { sortWorkspaces, toggleWorkspacePin } from 'utils/workspaces';
|
||||
|
||||
import Bruno from 'components/Bruno';
|
||||
import MenuDropdown from 'ui/MenuDropdown';
|
||||
import ActionIcon from 'ui/ActionIcon';
|
||||
import IconSidebarToggle from 'components/Icons/IconSidebarToggle';
|
||||
import CreateWorkspace from 'components/WorkspaceSidebar/CreateWorkspace';
|
||||
|
||||
import IconBottombarToggle from 'components/Icons/IconBottombarToggle/index';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { toTitleCase } from 'utils/common/index';
|
||||
|
||||
const AppTitleBar = () => {
|
||||
const dispatch = useDispatch();
|
||||
const [isFullScreen, setIsFullScreen] = useState(false);
|
||||
|
||||
// Listen for fullscreen changes
|
||||
useEffect(() => {
|
||||
const { ipcRenderer } = window;
|
||||
if (!ipcRenderer) return;
|
||||
|
||||
const removeEnterFullScreenListener = ipcRenderer.on('main:enter-full-screen', () => {
|
||||
setIsFullScreen(true);
|
||||
});
|
||||
|
||||
const removeLeaveFullScreenListener = ipcRenderer.on('main:leave-full-screen', () => {
|
||||
setIsFullScreen(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
removeEnterFullScreenListener();
|
||||
removeLeaveFullScreenListener();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Get workspace info
|
||||
const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces);
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const sidebarCollapsed = useSelector((state) => state.app.sidebarCollapsed);
|
||||
const isConsoleOpen = useSelector((state) => state.logs.isConsoleOpen);
|
||||
const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid);
|
||||
|
||||
// Sort workspaces according to preferences
|
||||
const sortedWorkspaces = useMemo(() => {
|
||||
return sortWorkspaces(workspaces, preferences);
|
||||
}, [workspaces, preferences]);
|
||||
|
||||
const [createWorkspaceModalOpen, setCreateWorkspaceModalOpen] = useState(false);
|
||||
|
||||
const WorkspaceName = forwardRef((props, ref) => {
|
||||
return (
|
||||
<div ref={ref} className="workspace-name-container" {...props}>
|
||||
<span className="workspace-name">{toTitleCase(activeWorkspace?.name) || 'Default Workspace'}</span>
|
||||
<IconChevronDown size={14} stroke={1.5} className="chevron-icon" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const handleHomeClick = () => {
|
||||
dispatch(showHomePage());
|
||||
};
|
||||
|
||||
const handleWorkspaceSwitch = (workspaceUid) => {
|
||||
dispatch(switchWorkspace(workspaceUid));
|
||||
toast.success(`Switched to ${workspaces.find((w) => w.uid === workspaceUid)?.name}`);
|
||||
};
|
||||
|
||||
const handleOpenWorkspace = async () => {
|
||||
try {
|
||||
await dispatch(openWorkspaceDialog());
|
||||
toast.success('Workspace opened successfully');
|
||||
} catch (error) {
|
||||
toast.error(error.message || 'Failed to open workspace');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateWorkspace = () => {
|
||||
setCreateWorkspaceModalOpen(true);
|
||||
};
|
||||
|
||||
const handlePinWorkspace = useCallback((workspaceUid, e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const newPreferences = toggleWorkspacePin(workspaceUid, preferences);
|
||||
dispatch(savePreferences(newPreferences));
|
||||
}, [dispatch, preferences]);
|
||||
|
||||
const orientation = preferences?.layout?.responsePaneOrientation || 'horizontal';
|
||||
|
||||
const handleToggleSidebar = () => {
|
||||
dispatch(toggleSidebarCollapse());
|
||||
};
|
||||
|
||||
const handleToggleDevtools = () => {
|
||||
if (isConsoleOpen) {
|
||||
dispatch(closeConsole());
|
||||
} else {
|
||||
dispatch(openConsole());
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleVerticalLayout = () => {
|
||||
const newOrientation = orientation === 'horizontal' ? 'vertical' : 'horizontal';
|
||||
const updatedPreferences = {
|
||||
...preferences,
|
||||
layout: {
|
||||
...preferences?.layout || {},
|
||||
responsePaneOrientation: newOrientation
|
||||
}
|
||||
};
|
||||
dispatch(savePreferences(updatedPreferences));
|
||||
};
|
||||
|
||||
// Build workspace menu items
|
||||
const workspaceMenuItems = useMemo(() => {
|
||||
const items = sortedWorkspaces.map((workspace) => {
|
||||
const isActive = workspace.uid === activeWorkspaceUid;
|
||||
const isPinned = preferences?.workspaces?.pinnedWorkspaceUids?.includes(workspace.uid);
|
||||
|
||||
return {
|
||||
id: workspace.uid,
|
||||
label: toTitleCase(workspace.name),
|
||||
onClick: () => handleWorkspaceSwitch(workspace.uid),
|
||||
className: `workspace-item ${isActive ? 'active' : ''}`,
|
||||
rightSection: (
|
||||
<div className="workspace-actions">
|
||||
{workspace.type !== 'default' && (
|
||||
<ActionIcon
|
||||
className={`pin-btn ${isPinned ? 'pinned' : ''}`}
|
||||
onClick={(e) => handlePinWorkspace(workspace.uid, e)}
|
||||
label={isPinned ? 'Unpin workspace' : 'Pin workspace'}
|
||||
size="sm"
|
||||
>
|
||||
{isPinned ? (
|
||||
<IconPinned size={14} stroke={1.5} />
|
||||
) : (
|
||||
<IconPin size={14} stroke={1.5} />
|
||||
)}
|
||||
</ActionIcon>
|
||||
)}
|
||||
{isActive && <IconCheck size={16} stroke={1.5} className="check-icon" />}
|
||||
</div>
|
||||
)
|
||||
};
|
||||
});
|
||||
|
||||
// Add label and action items
|
||||
items.push(
|
||||
{ type: 'label', label: 'Workspaces' },
|
||||
{
|
||||
id: 'create-workspace',
|
||||
leftSection: IconPlus,
|
||||
label: 'Create workspace',
|
||||
onClick: handleCreateWorkspace
|
||||
},
|
||||
{
|
||||
id: 'open-workspace',
|
||||
leftSection: IconFolder,
|
||||
label: 'Open workspace',
|
||||
onClick: handleOpenWorkspace
|
||||
}
|
||||
);
|
||||
|
||||
return items;
|
||||
}, [sortedWorkspaces, activeWorkspaceUid, preferences, handlePinWorkspace]);
|
||||
|
||||
return (
|
||||
<StyledWrapper className={`app-titlebar ${isFullScreen ? 'fullscreen' : ''}`}>
|
||||
{createWorkspaceModalOpen && (
|
||||
<CreateWorkspace onClose={() => setCreateWorkspaceModalOpen(false)} />
|
||||
)}
|
||||
|
||||
<div className="titlebar-content">
|
||||
{/* Left section: Home + Workspace */}
|
||||
<div className="titlebar-left">
|
||||
<ActionIcon
|
||||
onClick={handleHomeClick}
|
||||
label="Home"
|
||||
size="lg"
|
||||
className="home-button"
|
||||
>
|
||||
<IconHome size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
|
||||
{/* Workspace Dropdown */}
|
||||
<MenuDropdown
|
||||
data-testid="workspace-menu"
|
||||
items={workspaceMenuItems}
|
||||
placement="bottom-start"
|
||||
selectedItemId={activeWorkspaceUid}
|
||||
>
|
||||
<WorkspaceName />
|
||||
</MenuDropdown>
|
||||
</div>
|
||||
|
||||
{/* Center section: Bruno logo + text */}
|
||||
<div className="titlebar-center">
|
||||
<Bruno width={18} />
|
||||
<span className="bruno-text">Bruno</span>
|
||||
</div>
|
||||
|
||||
{/* Right section: Action buttons */}
|
||||
<div className="titlebar-right">
|
||||
{/* Toggle sidebar */}
|
||||
<ActionIcon
|
||||
onClick={handleToggleSidebar}
|
||||
label={sidebarCollapsed ? 'Show sidebar' : 'Hide sidebar'}
|
||||
size="lg"
|
||||
data-testid="toggle-sidebar-button"
|
||||
>
|
||||
<IconSidebarToggle collapsed={sidebarCollapsed} size={16} strokeWidth={1.5} />
|
||||
</ActionIcon>
|
||||
|
||||
{/* Toggle devtools */}
|
||||
<ActionIcon
|
||||
onClick={handleToggleDevtools}
|
||||
label={isConsoleOpen ? 'Hide devtools' : 'Show devtools'}
|
||||
size="lg"
|
||||
data-testid="toggle-devtools-button"
|
||||
>
|
||||
<IconBottombarToggle collapsed={!isConsoleOpen} size={16} strokeWidth={1.5} />
|
||||
</ActionIcon>
|
||||
|
||||
{/* Toggle vertical layout */}
|
||||
<ActionIcon
|
||||
onClick={handleToggleVerticalLayout}
|
||||
label={orientation === 'horizontal' ? 'Switch to vertical layout' : 'Switch to horizontal layout'}
|
||||
size="lg"
|
||||
data-testid="toggle-vertical-layout-button"
|
||||
>
|
||||
{orientation === 'horizontal' ? (
|
||||
<IconLayoutColumns size={16} stroke={1.5} />
|
||||
) : (
|
||||
<IconLayoutRows size={16} stroke={1.5} />
|
||||
)}
|
||||
</ActionIcon>
|
||||
</div>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppTitleBar;
|
||||
@@ -373,7 +373,7 @@ const ClientCertSettings = ({ collection }) => {
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-6 flex flex-row gap-2 items-center">
|
||||
<button type="submit" className="submit btn btn-sm btn-secondary">
|
||||
<button type="submit" className="submit btn btn-sm btn-secondary" data-testid="add-client-cert">
|
||||
Add
|
||||
</button>
|
||||
<div className="h-4 border-l border-gray-600"></div>
|
||||
|
||||
@@ -1,78 +1,77 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useCallback } 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 {
|
||||
addCollectionHeader,
|
||||
updateCollectionHeader,
|
||||
deleteCollectionHeader,
|
||||
setCollectionHeaders
|
||||
} from 'providers/ReduxStore/slices/collections';
|
||||
import { setCollectionHeaders } from 'providers/ReduxStore/slices/collections';
|
||||
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import SingleLineEditor from 'components/SingleLineEditor';
|
||||
import EditableTable from 'components/EditableTable';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { headers as StandardHTTPHeaders } from 'know-your-http-well';
|
||||
import { MimeTypes } from 'utils/codemirror/autocompleteConstants';
|
||||
import BulkEditor from 'components/BulkEditor/index';
|
||||
|
||||
const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header);
|
||||
|
||||
const Headers = ({ collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
const headers = collection.draft?.root ? get(collection, 'draft.root.request.headers', []) : get(collection, 'root.request.headers', []);
|
||||
const headers = collection.draft?.root
|
||||
? get(collection, 'draft.root.request.headers', [])
|
||||
: get(collection, 'root.request.headers', []);
|
||||
const [isBulkEditMode, setIsBulkEditMode] = useState(false);
|
||||
|
||||
const toggleBulkEditMode = () => {
|
||||
setIsBulkEditMode(!isBulkEditMode);
|
||||
};
|
||||
|
||||
const handleBulkHeadersChange = (newHeaders) => {
|
||||
dispatch(setCollectionHeaders({ collectionUid: collection.uid, headers: newHeaders }));
|
||||
};
|
||||
|
||||
const addHeader = () => {
|
||||
dispatch(
|
||||
addCollectionHeader({
|
||||
collectionUid: collection.uid
|
||||
})
|
||||
);
|
||||
};
|
||||
const handleHeadersChange = useCallback((updatedHeaders) => {
|
||||
dispatch(setCollectionHeaders({ collectionUid: collection.uid, headers: updatedHeaders }));
|
||||
}, [dispatch, collection.uid]);
|
||||
|
||||
const handleSave = () => dispatch(saveCollectionSettings(collection.uid));
|
||||
const handleHeaderValueChange = (e, _header, type) => {
|
||||
const header = cloneDeep(_header);
|
||||
switch (type) {
|
||||
case 'name': {
|
||||
// Strip newlines from header keys
|
||||
header.name = e.target.value.replace(/[\r\n]/g, '');
|
||||
break;
|
||||
}
|
||||
case 'value': {
|
||||
header.value = e.target.value;
|
||||
break;
|
||||
}
|
||||
case 'enabled': {
|
||||
header.enabled = e.target.checked;
|
||||
break;
|
||||
}
|
||||
}
|
||||
dispatch(
|
||||
updateCollectionHeader({
|
||||
header: header,
|
||||
collectionUid: collection.uid
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const handleRemoveHeader = (header) => {
|
||||
dispatch(
|
||||
deleteCollectionHeader({
|
||||
headerUid: header.uid,
|
||||
collectionUid: collection.uid
|
||||
})
|
||||
);
|
||||
const columns = [
|
||||
{
|
||||
key: 'name',
|
||||
name: 'Name',
|
||||
isKeyField: true,
|
||||
placeholder: 'Name',
|
||||
width: '30%',
|
||||
render: ({ row, value, onChange, isLastEmptyRow }) => (
|
||||
<SingleLineEditor
|
||||
value={value || ''}
|
||||
theme={storedTheme}
|
||||
onSave={handleSave}
|
||||
onChange={(newValue) => onChange(newValue.replace(/[\r\n]/g, ''))}
|
||||
autocomplete={headerAutoCompleteList}
|
||||
collection={collection}
|
||||
placeholder={isLastEmptyRow ? 'Name' : ''}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'value',
|
||||
name: 'Value',
|
||||
placeholder: 'Value',
|
||||
render: ({ row, value, onChange, isLastEmptyRow }) => (
|
||||
<SingleLineEditor
|
||||
value={value || ''}
|
||||
theme={storedTheme}
|
||||
onSave={handleSave}
|
||||
onChange={onChange}
|
||||
collection={collection}
|
||||
autocomplete={MimeTypes}
|
||||
placeholder={isLastEmptyRow ? 'Value' : ''}
|
||||
/>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
const defaultRow = {
|
||||
name: '',
|
||||
value: '',
|
||||
description: ''
|
||||
};
|
||||
|
||||
if (isBulkEditMode) {
|
||||
@@ -83,7 +82,7 @@ const Headers = ({ collection }) => {
|
||||
</div>
|
||||
<BulkEditor
|
||||
params={headers}
|
||||
onChange={handleBulkHeadersChange}
|
||||
onChange={handleHeadersChange}
|
||||
onToggle={toggleBulkEditMode}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
@@ -96,86 +95,17 @@ const Headers = ({ collection }) => {
|
||||
<div className="text-xs mb-4 text-muted">
|
||||
Add request headers that will be sent with every request in this collection.
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<td>Name</td>
|
||||
<td>Value</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{headers && headers.length
|
||||
? headers.map((header) => {
|
||||
return (
|
||||
<tr key={header.uid}>
|
||||
<td>
|
||||
<SingleLineEditor
|
||||
value={header.name}
|
||||
theme={storedTheme}
|
||||
onSave={handleSave}
|
||||
onChange={(newValue) =>
|
||||
handleHeaderValueChange(
|
||||
{
|
||||
target: {
|
||||
value: newValue
|
||||
}
|
||||
},
|
||||
header,
|
||||
'name'
|
||||
)}
|
||||
autocomplete={headerAutoCompleteList}
|
||||
collection={collection}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<SingleLineEditor
|
||||
value={header.value}
|
||||
theme={storedTheme}
|
||||
onSave={handleSave}
|
||||
onChange={(newValue) =>
|
||||
handleHeaderValueChange(
|
||||
{
|
||||
target: {
|
||||
value: newValue
|
||||
}
|
||||
},
|
||||
header,
|
||||
'value'
|
||||
)}
|
||||
collection={collection}
|
||||
autocomplete={MimeTypes}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={header.enabled}
|
||||
tabIndex="-1"
|
||||
className="mr-3 mousetrap"
|
||||
onChange={(e) => handleHeaderValueChange(e, header, 'enabled')}
|
||||
/>
|
||||
<button tabIndex="-1" onClick={() => handleRemoveHeader(header)}>
|
||||
<IconTrash strokeWidth={1.5} size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="flex justify-between mt-2">
|
||||
<button className="btn-add-header text-link pr-2 py-3 select-none" onClick={addHeader}>
|
||||
+ Add Header
|
||||
</button>
|
||||
<EditableTable
|
||||
columns={columns}
|
||||
rows={headers}
|
||||
onChange={handleHeadersChange}
|
||||
defaultRow={defaultRow}
|
||||
/>
|
||||
<div className="flex justify-end mt-2">
|
||||
<button className="text-link select-none" onClick={toggleBulkEditMode}>
|
||||
Bulk Edit
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<button type="submit" className="submit btn btn-sm btn-secondary" onClick={handleSave}>
|
||||
Save
|
||||
@@ -184,4 +114,5 @@ const Headers = ({ collection }) => {
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default Headers;
|
||||
|
||||
@@ -3,15 +3,23 @@ import { getTotalRequestCountInCollection } from 'utils/collections/';
|
||||
import { IconBox, IconFolder, IconWorld, IconApi, IconShare } from '@tabler/icons';
|
||||
import { areItemsLoading, getItemsLoadStats } from 'utils/collections/index';
|
||||
import { useState } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import ShareCollection from 'components/ShareCollection/index';
|
||||
import { updateEnvironmentSettingsModalVisibility, updateGlobalEnvironmentSettingsModalVisibility } from 'providers/ReduxStore/slices/app';
|
||||
|
||||
const Info = ({ collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const totalRequestsInCollection = getTotalRequestCountInCollection(collection);
|
||||
|
||||
const isCollectionLoading = areItemsLoading(collection);
|
||||
const { loading: itemsLoadingCount, total: totalItems } = getItemsLoadStats(collection);
|
||||
const [showShareCollectionModal, toggleShowShareCollectionModal] = useState(false);
|
||||
|
||||
const globalEnvironments = useSelector((state) => state.globalEnvironments.globalEnvironments);
|
||||
|
||||
const collectionEnvironmentCount = collection.environments?.length || 0;
|
||||
const globalEnvironmentCount = globalEnvironments?.length || 0;
|
||||
|
||||
const handleToggleShowShareCollectionModal = (value) => (e) => {
|
||||
toggleShowShareCollectionModal(value);
|
||||
};
|
||||
@@ -39,9 +47,24 @@ const Info = ({ collection }) => {
|
||||
<IconWorld className="w-5 h-5 text-green-500" stroke={1.5} />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<div className="font-medium">Environments</div>
|
||||
<div className="mt-1 text-muted text-xs">
|
||||
{collection.environments?.length || 0} environment{collection.environments?.length !== 1 ? 's' : ''} configured
|
||||
<div className="font-medium text-sm">Environments</div>
|
||||
<div className="mt-1 flex flex-col gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm text-link cursor-pointer hover:underline text-left bg-transparent"
|
||||
onClick={() => {
|
||||
dispatch(updateEnvironmentSettingsModalVisibility(true));
|
||||
}}
|
||||
>
|
||||
{collectionEnvironmentCount} collection environment{collectionEnvironmentCount !== 1 ? 's' : ''}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm text-link cursor-pointer hover:underline text-left bg-transparent"
|
||||
onClick={() => dispatch(updateGlobalEnvironmentSettingsModalVisibility(true))}
|
||||
>
|
||||
{globalEnvironmentCount} global environment{globalEnvironmentCount !== 1 ? 's' : ''}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@ const StyledWrapper = styled.div`
|
||||
padding: 6px 0px;
|
||||
border: none;
|
||||
border-bottom: solid 2px transparent;
|
||||
margin-right: 1.25rem;
|
||||
margin-right: ${(props) => props.theme.tabs.marginRight};
|
||||
color: var(--color-tab-inactive);
|
||||
cursor: pointer;
|
||||
|
||||
@@ -20,6 +20,7 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
|
||||
&.active {
|
||||
font-weight: ${(props) => props.theme.tabs.active.fontWeight} !important;
|
||||
color: ${(props) => props.theme.tabs.active.color} !important;
|
||||
border-bottom: solid 2px ${(props) => props.theme.tabs.active.border} !important;
|
||||
}
|
||||
|
||||
@@ -1,160 +1,81 @@
|
||||
import React from 'react';
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import { IconTrash } from '@tabler/icons';
|
||||
import React, { useCallback } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import MultiLineEditor from 'components/MultiLineEditor';
|
||||
import InfoTip from 'components/InfoTip';
|
||||
import EditableTable from 'components/EditableTable';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import toast from 'react-hot-toast';
|
||||
import { variableNameRegex } from 'utils/common/regex';
|
||||
import {
|
||||
addCollectionVar,
|
||||
deleteCollectionVar,
|
||||
updateCollectionVar
|
||||
} from 'providers/ReduxStore/slices/collections/index';
|
||||
import { setCollectionVars } from 'providers/ReduxStore/slices/collections/index';
|
||||
|
||||
const VarsTable = ({ collection, vars, varType }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
|
||||
const addVar = () => {
|
||||
dispatch(
|
||||
addCollectionVar({
|
||||
collectionUid: collection.uid,
|
||||
type: varType
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const onSave = () => dispatch(saveCollectionSettings(collection.uid));
|
||||
const handleVarChange = (e, v, type) => {
|
||||
const _var = cloneDeep(v);
|
||||
switch (type) {
|
||||
case 'name': {
|
||||
const value = e.target.value;
|
||||
|
||||
if (variableNameRegex.test(value) === false) {
|
||||
toast.error(
|
||||
'Variable contains invalid characters! Variables must only contain alpha-numeric characters, "-", "_", "."'
|
||||
);
|
||||
return;
|
||||
}
|
||||
const handleVarsChange = useCallback((updatedVars) => {
|
||||
dispatch(setCollectionVars({ collectionUid: collection.uid, vars: updatedVars, type: varType }));
|
||||
}, [dispatch, collection.uid, varType]);
|
||||
|
||||
_var.name = value;
|
||||
break;
|
||||
}
|
||||
case 'value': {
|
||||
_var.value = e.target.value;
|
||||
break;
|
||||
}
|
||||
case 'enabled': {
|
||||
_var.enabled = e.target.checked;
|
||||
break;
|
||||
}
|
||||
const getRowError = useCallback((row, index, key) => {
|
||||
if (key !== 'name') return null;
|
||||
if (!row.name || row.name.trim() === '') return null;
|
||||
if (!variableNameRegex.test(row.name)) {
|
||||
return 'Variable contains invalid characters. Must only contain alphanumeric characters, "-", "_", "."';
|
||||
}
|
||||
dispatch(
|
||||
updateCollectionVar({
|
||||
type: varType,
|
||||
var: _var,
|
||||
collectionUid: collection.uid
|
||||
})
|
||||
);
|
||||
};
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
const handleRemoveVar = (_var) => {
|
||||
dispatch(
|
||||
deleteCollectionVar({
|
||||
type: varType,
|
||||
varUid: _var.uid,
|
||||
collectionUid: collection.uid
|
||||
})
|
||||
);
|
||||
const columns = [
|
||||
{
|
||||
key: 'name',
|
||||
name: 'Name',
|
||||
isKeyField: true,
|
||||
placeholder: 'Name',
|
||||
width: '40%'
|
||||
},
|
||||
{
|
||||
key: 'value',
|
||||
name: varType === 'request' ? 'Value' : (
|
||||
<div className="flex items-center">
|
||||
<span>Expr</span>
|
||||
<InfoTip content="You can write any valid JS Template Literal here" infotipId={`collection-${varType}-var`} />
|
||||
</div>
|
||||
),
|
||||
placeholder: varType === 'request' ? 'Value' : 'Expr',
|
||||
render: ({ row, value, onChange, isLastEmptyRow }) => (
|
||||
<MultiLineEditor
|
||||
value={value || ''}
|
||||
theme={storedTheme}
|
||||
onSave={onSave}
|
||||
onChange={onChange}
|
||||
collection={collection}
|
||||
placeholder={isLastEmptyRow ? (varType === 'request' ? 'Value' : 'Expr') : ''}
|
||||
/>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
const defaultRow = {
|
||||
name: '',
|
||||
value: '',
|
||||
...(varType === 'response' ? { local: false } : {})
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<td>Name</td>
|
||||
{varType === 'request' ? (
|
||||
<td>
|
||||
<div className="flex items-center">
|
||||
<span>Value</span>
|
||||
</div>
|
||||
</td>
|
||||
) : (
|
||||
<td>
|
||||
<div className="flex items-center">
|
||||
<span>Expr</span>
|
||||
<InfoTip content="You can write any valid JS Template Literal here" infotipId="request-var" />
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
<td></td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{vars && vars.length
|
||||
? vars.map((_var) => {
|
||||
return (
|
||||
<tr key={_var.uid}>
|
||||
<td>
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
value={_var.name}
|
||||
className="mousetrap"
|
||||
onChange={(e) => handleVarChange(e, _var, 'name')}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<MultiLineEditor
|
||||
value={_var.value}
|
||||
theme={storedTheme}
|
||||
onSave={onSave}
|
||||
onChange={(newValue) =>
|
||||
handleVarChange(
|
||||
{
|
||||
target: {
|
||||
value: newValue
|
||||
}
|
||||
},
|
||||
_var,
|
||||
'value'
|
||||
)}
|
||||
collection={collection}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={_var.enabled}
|
||||
tabIndex="-1"
|
||||
className="mr-3 mousetrap"
|
||||
onChange={(e) => handleVarChange(e, _var, 'enabled')}
|
||||
/>
|
||||
<button tabIndex="-1" onClick={() => handleRemoveVar(_var)}>
|
||||
<IconTrash strokeWidth={1.5} size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
</tbody>
|
||||
</table>
|
||||
<button className="btn-add-var text-link pr-2 py-3 mt-2 select-none" onClick={addVar}>
|
||||
+ Add
|
||||
</button>
|
||||
<EditableTable
|
||||
columns={columns}
|
||||
rows={vars}
|
||||
onChange={handleVarsChange}
|
||||
defaultRow={defaultRow}
|
||||
getRowError={getRowError}
|
||||
/>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default VarsTable;
|
||||
|
||||
@@ -4,18 +4,23 @@ import VarsTable from './VarsTable';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import DeprecationWarning from 'components/DeprecationWarning';
|
||||
|
||||
const Vars = ({ collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const requestVars = collection.draft?.root ? get(collection, 'draft.root.request.vars.req', []) : get(collection, 'root.request.vars.req', []);
|
||||
const responseVars = collection.draft?.root ? get(collection, 'draft.root.request.vars.res', []) : get(collection, 'root.request.vars.res', []);
|
||||
const handleSave = () => dispatch(saveCollectionSettings(collection.uid));
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full flex flex-col">
|
||||
<div className="flex-1 mt-2">
|
||||
<div className="mb-1 title text-xs">Pre Request</div>
|
||||
<VarsTable collection={collection} vars={requestVars} varType="request" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="mt-1 mb-1 title text-xs">Post Response</div>
|
||||
<VarsTable collection={collection} vars={responseVars} varType="response" />
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<button type="submit" className="submit btn btn-sm btn-secondary" onClick={handleSave}>
|
||||
Save
|
||||
|
||||
@@ -32,17 +32,32 @@ const CollectionSettings = ({ collection }) => {
|
||||
const hasTests = root?.request?.tests;
|
||||
const hasDocs = root?.docs;
|
||||
|
||||
const headers = collection.draft?.root ? get(collection, 'draft.root.request.headers', []) : get(collection, 'root.request.headers', []);
|
||||
const headers = collection.draft?.root
|
||||
? get(collection, 'draft.root.request.headers', [])
|
||||
: get(collection, 'root.request.headers', []);
|
||||
const activeHeadersCount = headers.filter((header) => header.enabled).length;
|
||||
|
||||
const requestVars = collection.draft?.root ? get(collection, 'draft.root.request.vars.req', []) : get(collection, 'root.request.vars.req', []);
|
||||
const activeVarsCount = requestVars.filter((v) => v.enabled).length;
|
||||
const authMode = (collection.draft?.root ? get(collection, 'draft.root.request.auth', {}) : get(collection, 'root.request.auth', {})).mode || 'none';
|
||||
const requestVars = collection.draft?.root
|
||||
? get(collection, 'draft.root.request.vars.req', [])
|
||||
: get(collection, 'root.request.vars.req', []);
|
||||
const responseVars = collection.draft?.root
|
||||
? get(collection, 'draft.root.request.vars.res', [])
|
||||
: get(collection, 'root.request.vars.res', []);
|
||||
const activeVarsCount = requestVars.filter((v) => v.enabled).length + responseVars.filter((v) => v.enabled).length;
|
||||
const authMode
|
||||
= (collection.draft?.root ? get(collection, 'draft.root.request.auth', {}) : get(collection, 'root.request.auth', {}))
|
||||
.mode || 'none';
|
||||
|
||||
const proxyConfig = collection.draft?.brunoConfig ? get(collection, 'draft.brunoConfig.proxy', {}) : get(collection, 'brunoConfig.proxy', {});
|
||||
const proxyConfig = collection.draft?.brunoConfig
|
||||
? get(collection, 'draft.brunoConfig.proxy', {})
|
||||
: get(collection, 'brunoConfig.proxy', {});
|
||||
const proxyEnabled = proxyConfig.hostname ? true : false;
|
||||
const clientCertConfig = collection.draft?.brunoConfig ? get(collection, 'draft.brunoConfig.clientCertificates.certs', []) : get(collection, 'brunoConfig.clientCertificates.certs', []);
|
||||
const protobufConfig = collection.draft?.brunoConfig ? get(collection, 'draft.brunoConfig.protobuf', {}) : get(collection, 'brunoConfig.protobuf', {});
|
||||
const clientCertConfig = collection.draft?.brunoConfig
|
||||
? get(collection, 'draft.brunoConfig.clientCertificates.certs', [])
|
||||
: get(collection, 'brunoConfig.clientCertificates.certs', []);
|
||||
const protobufConfig = collection.draft?.brunoConfig
|
||||
? get(collection, 'draft.brunoConfig.protobuf', {})
|
||||
: get(collection, 'brunoConfig.protobuf', {});
|
||||
|
||||
const getTabPanel = (tab) => {
|
||||
switch (tab) {
|
||||
@@ -68,11 +83,7 @@ const CollectionSettings = ({ collection }) => {
|
||||
return <ProxySettings collection={collection} />;
|
||||
}
|
||||
case 'clientCert': {
|
||||
return (
|
||||
<ClientCertSettings
|
||||
collection={collection}
|
||||
/>
|
||||
);
|
||||
return <ClientCertSettings collection={collection} />;
|
||||
}
|
||||
case 'protobuf': {
|
||||
return <Protobuf collection={collection} />;
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
`;
|
||||
|
||||
export default Wrapper;
|
||||
172
packages/bruno-app/src/components/CreateUntitledRequest/index.js
Normal file
172
packages/bruno-app/src/components/CreateUntitledRequest/index.js
Normal file
@@ -0,0 +1,172 @@
|
||||
import React, { useRef, forwardRef } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import { newHttpRequest, newGrpcRequest, newWsRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { generateUniqueRequestName } from 'utils/collections';
|
||||
import { sanitizeName } from 'utils/common/regex';
|
||||
import toast from 'react-hot-toast';
|
||||
import { IconApi, IconBrandGraphql, IconPlugConnected, IconCode, IconPlus } from '@tabler/icons';
|
||||
|
||||
const CreateUntitledRequest = ({ collectionUid, itemUid = null, onRequestCreated, placement = 'bottom' }) => {
|
||||
const dispatch = useDispatch();
|
||||
const collections = useSelector((state) => state.collections.collections);
|
||||
const collection = collections?.find((c) => c.uid === collectionUid);
|
||||
const dropdownTippyRef = useRef();
|
||||
|
||||
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
|
||||
|
||||
if (!collection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleCreateHttpRequest = async () => {
|
||||
dropdownTippyRef.current?.hide();
|
||||
const uniqueName = await generateUniqueRequestName(collection, 'Untitled', itemUid);
|
||||
const filename = sanitizeName(uniqueName);
|
||||
|
||||
dispatch(
|
||||
newHttpRequest({
|
||||
requestName: uniqueName,
|
||||
filename: filename,
|
||||
requestType: 'http-request',
|
||||
requestUrl: '',
|
||||
requestMethod: 'GET',
|
||||
collectionUid: collection.uid,
|
||||
itemUid: itemUid
|
||||
})
|
||||
)
|
||||
.then(() => {
|
||||
toast.success('New request created!');
|
||||
onRequestCreated?.();
|
||||
})
|
||||
.catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request'));
|
||||
};
|
||||
|
||||
const handleCreateGraphQLRequest = async () => {
|
||||
dropdownTippyRef.current?.hide();
|
||||
const uniqueName = await generateUniqueRequestName(collection, 'Untitled', itemUid);
|
||||
const filename = sanitizeName(uniqueName);
|
||||
|
||||
dispatch(
|
||||
newHttpRequest({
|
||||
requestName: uniqueName,
|
||||
filename: filename,
|
||||
requestType: 'graphql-request',
|
||||
requestUrl: '',
|
||||
requestMethod: 'POST',
|
||||
collectionUid: collection.uid,
|
||||
itemUid: itemUid,
|
||||
body: {
|
||||
mode: 'graphql',
|
||||
graphql: {
|
||||
query: '',
|
||||
variables: ''
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
.then(() => {
|
||||
toast.success('New request created!');
|
||||
onRequestCreated?.();
|
||||
})
|
||||
.catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request'));
|
||||
};
|
||||
|
||||
const handleCreateWebSocketRequest = async () => {
|
||||
dropdownTippyRef.current?.hide();
|
||||
const uniqueName = await generateUniqueRequestName(collection, 'Untitled', itemUid);
|
||||
const filename = sanitizeName(uniqueName);
|
||||
|
||||
dispatch(
|
||||
newWsRequest({
|
||||
requestName: uniqueName,
|
||||
filename: filename,
|
||||
requestUrl: '',
|
||||
requestMethod: 'ws',
|
||||
collectionUid: collection.uid,
|
||||
itemUid: itemUid
|
||||
})
|
||||
)
|
||||
.then(() => {
|
||||
toast.success('New request created!');
|
||||
onRequestCreated?.();
|
||||
})
|
||||
.catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request'));
|
||||
};
|
||||
|
||||
const handleCreateGrpcRequest = async () => {
|
||||
dropdownTippyRef.current?.hide();
|
||||
const uniqueName = await generateUniqueRequestName(collection, 'Untitled', itemUid);
|
||||
const filename = sanitizeName(uniqueName);
|
||||
|
||||
dispatch(
|
||||
newGrpcRequest({
|
||||
requestName: uniqueName,
|
||||
filename: filename,
|
||||
requestUrl: '',
|
||||
collectionUid: collection.uid,
|
||||
itemUid: itemUid
|
||||
})
|
||||
)
|
||||
.then(() => {
|
||||
toast.success('New request created!');
|
||||
onRequestCreated?.();
|
||||
})
|
||||
.catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request'));
|
||||
};
|
||||
|
||||
return (
|
||||
<Dropdown onCreate={onDropdownCreate} icon={<IconPlus size={16} strokeWidth={2} />} placement={placement}>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={(e) => {
|
||||
dropdownTippyRef.current.hide();
|
||||
handleCreateHttpRequest();
|
||||
}}
|
||||
>
|
||||
<span className="dropdown-icon">
|
||||
<IconApi size={16} strokeWidth={2} />
|
||||
</span>
|
||||
HTTP
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={(e) => {
|
||||
dropdownTippyRef.current.hide();
|
||||
handleCreateGraphQLRequest();
|
||||
}}
|
||||
>
|
||||
<span className="dropdown-icon">
|
||||
<IconBrandGraphql size={16} strokeWidth={2} />
|
||||
</span>
|
||||
GraphQL
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={(e) => {
|
||||
dropdownTippyRef.current.hide();
|
||||
handleCreateWebSocketRequest();
|
||||
}}
|
||||
>
|
||||
<span className="dropdown-icon">
|
||||
<IconPlugConnected size={16} strokeWidth={2} />
|
||||
</span>
|
||||
WebSocket
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={(e) => {
|
||||
dropdownTippyRef.current.hide();
|
||||
handleCreateGrpcRequest();
|
||||
}}
|
||||
>
|
||||
<span className="dropdown-icon">
|
||||
<IconCode size={16} strokeWidth={2} />
|
||||
</span>
|
||||
gRPC
|
||||
</div>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateUntitledRequest;
|
||||
@@ -168,7 +168,7 @@ const StyledWrapper = styled.div`
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
|
||||
|
||||
td {
|
||||
padding: 8px 12px;
|
||||
font-weight: 500;
|
||||
@@ -256,10 +256,8 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
|
||||
.response-body-container {
|
||||
border: 1px solid ${(props) => props.theme.console.border};
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background: ${(props) => props.theme.console.headerBg};
|
||||
height: 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -267,13 +265,11 @@ const StyledWrapper = styled.div`
|
||||
.w-full.h-full.relative.flex {
|
||||
height: 100% !important;
|
||||
width: 100% !important;
|
||||
background: ${(props) => props.theme.console.headerBg} !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
}
|
||||
|
||||
div[role="tablist"] {
|
||||
background: ${(props) => props.theme.console.dropdownHeaderBg};
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid ${(props) => props.theme.console.border};
|
||||
display: flex !important;
|
||||
@@ -282,28 +278,17 @@ const StyledWrapper = styled.div`
|
||||
align-items: center !important;
|
||||
min-height: 40px !important;
|
||||
flex-shrink: 0 !important;
|
||||
|
||||
|
||||
> div {
|
||||
color: ${(props) => props.theme.console.buttonColor};
|
||||
font-size: ${(props) => props.theme.font.size.sm} !important;
|
||||
padding: 6px 12px !important;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
border: 1px solid ${(props) => props.theme.console.border};
|
||||
background: ${(props) => props.theme.console.contentBg};
|
||||
white-space: nowrap !important;
|
||||
min-width: auto !important;
|
||||
height: auto !important;
|
||||
line-height: 1.2 !important;
|
||||
font-weight: 500 !important;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.console.buttonHoverBg};
|
||||
color: ${(props) => props.theme.console.buttonHoverColor};
|
||||
border-color: ${(props) => props.theme.console.buttonHoverBg};
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: ${(props) => props.theme.console.checkboxColor};
|
||||
color: white;
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
IconNetwork
|
||||
} from '@tabler/icons';
|
||||
import { clearSelectedRequest } from 'providers/ReduxStore/slices/logs';
|
||||
import QueryResult from 'components/ResponsePane/QueryResult';
|
||||
import QueryResponse from 'components/ResponsePane/QueryResponse/index';
|
||||
import Network from 'components/ResponsePane/Timeline/TimelineItem/Network';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { uuid } from 'utils/common/index';
|
||||
@@ -116,7 +116,7 @@ const ResponseTab = ({ response, request, collection }) => {
|
||||
<h4>Response Body</h4>
|
||||
<div className="response-body-container">
|
||||
{response?.data || response?.dataBuffer ? (
|
||||
<QueryResult
|
||||
<QueryResponse
|
||||
item={{ uid: uuid() }}
|
||||
collection={collection}
|
||||
data={response.data}
|
||||
|
||||
@@ -25,6 +25,16 @@ const Wrapper = styled.div`
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
|
||||
[role="menu"] {
|
||||
outline: none;
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.label-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -59,6 +69,10 @@ const Wrapper = styled.div`
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-label {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.dropdown-icon {
|
||||
flex-shrink: 0;
|
||||
width: 16px;
|
||||
@@ -70,10 +84,31 @@ const Wrapper = styled.div`
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.dropdown-right-section {
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: ${(props) => props.theme.dropdown.hoverBg};
|
||||
}
|
||||
|
||||
&.selected-focused:not(:disabled) {
|
||||
background-color: ${(props) => props.theme.dropdown.hoverBg};
|
||||
}
|
||||
|
||||
&:focus-visible:not(:disabled) {
|
||||
outline: none;
|
||||
background-color: ${(props) => props.theme.dropdown.hoverBg};
|
||||
}
|
||||
|
||||
&:focus:not(:focus-visible) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
|
||||
154
packages/bruno-app/src/components/EditableTable/StyledWrapper.js
Normal file
154
packages/bruno-app/src/components/EditableTable/StyledWrapper.js
Normal file
@@ -0,0 +1,154 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
|
||||
.table-container {
|
||||
overflow-y: auto;
|
||||
border-radius: ${(props) => props.theme.border.radius.base};
|
||||
border: ${(props) => props.theme.workspace.environments.indentBorder};
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
}
|
||||
|
||||
thead {
|
||||
color: ${(props) => props.theme.colors.text} !important;
|
||||
background: ${(props) => props.theme.sidebar.bg};
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
user-select: none;
|
||||
|
||||
border: none !important;
|
||||
|
||||
td {
|
||||
padding: 8px 10px;
|
||||
border-top: none !important;
|
||||
border-left: none !important;
|
||||
border-bottom: ${(props) => props.theme.workspace.environments.indentBorder};
|
||||
border-right: ${(props) => props.theme.workspace.environments.indentBorder};
|
||||
vertical-align: middle;
|
||||
|
||||
&:nth-child(1) {
|
||||
width: 25px !important;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tbody {
|
||||
tr {
|
||||
transition: background 0.1s ease;
|
||||
|
||||
&:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 2px 10px;
|
||||
border-top: none !important;
|
||||
border-left: none !important;
|
||||
border-bottom: ${(props) => props.theme.workspace.environments.indentBorder};
|
||||
border-right: ${(props) => props.theme.workspace.environments.indentBorder};
|
||||
vertical-align: middle;
|
||||
|
||||
&:nth-child(1) {
|
||||
width: 25px;
|
||||
border-right: none;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
line-height: 1;
|
||||
|
||||
input[type='checkbox'] {
|
||||
vertical-align: baseline;
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tooltip-mod {
|
||||
font-size: 11px !important;
|
||||
max-width: 200px !important;
|
||||
}
|
||||
|
||||
input[type='text'] {
|
||||
width: 100%;
|
||||
outline: none !important;
|
||||
background-color: transparent;
|
||||
color: ${(props) => props.theme.text};
|
||||
padding: 0;
|
||||
font-size: 12px;
|
||||
border-radius: 4px;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:focus {
|
||||
outline: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
input[type='checkbox'] {
|
||||
cursor: pointer;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
accent-color: ${(props) => props.theme.workspace.accent};
|
||||
vertical-align: middle;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: color 0.15s ease, background 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => props.theme.colors.text.danger};
|
||||
}
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
.icon-grip,
|
||||
.icon-minus {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
background-color: transparent;
|
||||
color: ${(props) => props.theme.text};
|
||||
border: none;
|
||||
outline: none;
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
|
||||
option {
|
||||
background-color: ${(props) => props.theme.bg};
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
318
packages/bruno-app/src/components/EditableTable/index.js
Normal file
318
packages/bruno-app/src/components/EditableTable/index.js
Normal file
@@ -0,0 +1,318 @@
|
||||
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { IconTrash, IconAlertCircle, IconGripVertical, IconMinusVertical } from '@tabler/icons';
|
||||
import { Tooltip } from 'react-tooltip';
|
||||
import { uuid } from 'utils/common';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const EditableTable = ({
|
||||
columns,
|
||||
rows,
|
||||
onChange,
|
||||
defaultRow,
|
||||
getRowError,
|
||||
showCheckbox = true,
|
||||
showDelete = true,
|
||||
checkboxLabel = '',
|
||||
checkboxKey = 'enabled',
|
||||
reorderable = false,
|
||||
onReorder,
|
||||
showAddRow = true
|
||||
}) => {
|
||||
const tableRef = useRef(null);
|
||||
const emptyRowUidRef = useRef(null);
|
||||
const [hoveredRow, setHoveredRow] = useState(null);
|
||||
const [dragStart, setDragStart] = useState(null);
|
||||
|
||||
const createEmptyRow = useCallback(() => {
|
||||
const newUid = uuid();
|
||||
emptyRowUidRef.current = newUid;
|
||||
return {
|
||||
uid: newUid,
|
||||
[checkboxKey]: true,
|
||||
...defaultRow
|
||||
};
|
||||
}, [defaultRow, checkboxKey]);
|
||||
|
||||
const rowsWithEmpty = useMemo(() => {
|
||||
if (!showAddRow) {
|
||||
return rows;
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
return [createEmptyRow()];
|
||||
}
|
||||
|
||||
const lastRow = rows[rows.length - 1];
|
||||
const keyColumn = columns.find((col) => col.isKeyField);
|
||||
|
||||
if (keyColumn) {
|
||||
const lastRowKeyValue = keyColumn.getValue ? keyColumn.getValue(lastRow) : lastRow[keyColumn.key];
|
||||
const isLastRowEmpty = !lastRowKeyValue || (typeof lastRowKeyValue === 'string' && lastRowKeyValue.trim() === '');
|
||||
|
||||
if (isLastRowEmpty) {
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
|
||||
if (!emptyRowUidRef.current || rows.some((r) => r.uid === emptyRowUidRef.current)) {
|
||||
emptyRowUidRef.current = uuid();
|
||||
}
|
||||
|
||||
return [...rows, {
|
||||
uid: emptyRowUidRef.current,
|
||||
[checkboxKey]: true,
|
||||
...defaultRow
|
||||
}];
|
||||
}, [rows, columns, defaultRow, checkboxKey, createEmptyRow, showAddRow]);
|
||||
|
||||
const isEmptyRow = useCallback((row) => {
|
||||
const keyColumn = columns.find((col) => col.isKeyField);
|
||||
if (!keyColumn) return false;
|
||||
|
||||
const value = keyColumn.getValue ? keyColumn.getValue(row) : row[keyColumn.key];
|
||||
return !value || (typeof value === 'string' && value.trim() === '');
|
||||
}, [columns]);
|
||||
|
||||
const isLastEmptyRow = useCallback((row, index) => {
|
||||
if (!showAddRow) return false;
|
||||
return index === rowsWithEmpty.length - 1 && isEmptyRow(row);
|
||||
}, [rowsWithEmpty.length, isEmptyRow, showAddRow]);
|
||||
|
||||
const handleValueChange = useCallback((rowUid, key, value) => {
|
||||
const rowIndex = rowsWithEmpty.findIndex((r) => r.uid === rowUid);
|
||||
if (rowIndex === -1) return;
|
||||
|
||||
const currentRow = rowsWithEmpty[rowIndex];
|
||||
const isLast = rowIndex === rowsWithEmpty.length - 1;
|
||||
const wasEmpty = isEmptyRow(currentRow);
|
||||
|
||||
const keyColumn = columns.find((col) => col.isKeyField);
|
||||
const isKeyFieldChange = keyColumn && keyColumn.key === key;
|
||||
|
||||
let updatedRows = rowsWithEmpty.map((row) => {
|
||||
if (row.uid === rowUid) {
|
||||
return { ...row, [key]: value };
|
||||
}
|
||||
return row;
|
||||
});
|
||||
|
||||
// Only add a new empty row when the key field is filled
|
||||
if (showAddRow && isLast && wasEmpty && isKeyFieldChange && value && value.trim() !== '') {
|
||||
emptyRowUidRef.current = uuid();
|
||||
updatedRows.push({
|
||||
uid: emptyRowUidRef.current,
|
||||
[checkboxKey]: true,
|
||||
...defaultRow
|
||||
});
|
||||
}
|
||||
|
||||
const hasAnyValue = (row) => {
|
||||
for (const col of columns) {
|
||||
const val = col.getValue ? col.getValue(row) : row[col.key];
|
||||
const defaultVal = defaultRow[col.key];
|
||||
if (val && val !== defaultVal && (typeof val !== 'string' || val.trim() !== '')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const result = updatedRows.filter((row, i) => {
|
||||
if (showAddRow && i === updatedRows.length - 1) {
|
||||
return hasAnyValue(row);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
onChange(result);
|
||||
}, [rowsWithEmpty, columns, onChange, checkboxKey, defaultRow, isEmptyRow, showAddRow]);
|
||||
|
||||
const handleCheckboxChange = useCallback((rowUid, checked) => {
|
||||
handleValueChange(rowUid, checkboxKey, checked);
|
||||
}, [handleValueChange, checkboxKey]);
|
||||
|
||||
const handleRemoveRow = useCallback((rowUid) => {
|
||||
const filteredRows = rows.filter((row) => row.uid !== rowUid);
|
||||
onChange(filteredRows);
|
||||
}, [rows, onChange]);
|
||||
|
||||
const getColumnWidth = useCallback((column) => {
|
||||
if (column.width) return column.width;
|
||||
return 'auto';
|
||||
}, []);
|
||||
|
||||
const handleDragStart = useCallback((e, index) => {
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', index);
|
||||
setDragStart(index);
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback((e, index) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
setHoveredRow(index);
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback((e, toIndex) => {
|
||||
e.preventDefault();
|
||||
const fromIndex = parseInt(e.dataTransfer.getData('text/plain'), 10);
|
||||
if (fromIndex !== toIndex && onReorder) {
|
||||
const reorderableRows = showAddRow ? rowsWithEmpty.slice(0, -1) : rowsWithEmpty;
|
||||
const updatedOrder = [...reorderableRows];
|
||||
const [movedRow] = updatedOrder.splice(fromIndex, 1);
|
||||
updatedOrder.splice(toIndex, 0, movedRow);
|
||||
onReorder({ updateReorderedItem: updatedOrder.map((row) => row.uid) });
|
||||
}
|
||||
setDragStart(null);
|
||||
setHoveredRow(null);
|
||||
}, [onReorder, rowsWithEmpty, showAddRow]);
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
setDragStart(null);
|
||||
setHoveredRow(null);
|
||||
}, []);
|
||||
|
||||
const renderCell = useCallback((column, row, rowIndex) => {
|
||||
const isEmpty = isLastEmptyRow(row, rowIndex);
|
||||
const value = column.getValue ? column.getValue(row) : row[column.key];
|
||||
const error = getRowError?.(row, rowIndex, column.key);
|
||||
|
||||
if (column.render) {
|
||||
return column.render({
|
||||
row,
|
||||
value,
|
||||
rowIndex,
|
||||
isLastEmptyRow: isEmpty,
|
||||
onChange: (newValue) => handleValueChange(row.uid, column.key, newValue),
|
||||
error
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
className="mousetrap"
|
||||
value={value || ''}
|
||||
readOnly={column.readOnly}
|
||||
placeholder={isEmpty ? column.placeholder || column.name : ''}
|
||||
onChange={(e) => handleValueChange(row.uid, column.key, e.target.value)}
|
||||
/>
|
||||
{error && !isEmpty && (
|
||||
<span>
|
||||
<IconAlertCircle
|
||||
data-tooltip-id={`error-${row.uid}-${column.key}`}
|
||||
className="text-red-600 cursor-pointer"
|
||||
size={20}
|
||||
/>
|
||||
<Tooltip
|
||||
className="tooltip-mod"
|
||||
id={`error-${row.uid}-${column.key}`}
|
||||
html={error}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}, [isLastEmptyRow, getRowError, handleValueChange]);
|
||||
|
||||
const reorderableRowCount = showAddRow ? rowsWithEmpty.length - 1 : rowsWithEmpty.length;
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="table-container" ref={tableRef}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
{showCheckbox && (
|
||||
<td className="text-center">{checkboxLabel}</td>
|
||||
)}
|
||||
{columns.map((column) => (
|
||||
<td
|
||||
key={column.key}
|
||||
style={{ width: getColumnWidth(column) }}
|
||||
>
|
||||
{column.name}
|
||||
</td>
|
||||
))}
|
||||
{showDelete && (
|
||||
<td style={{ width: '60px' }}></td>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rowsWithEmpty.map((row, rowIndex) => {
|
||||
const isEmpty = isLastEmptyRow(row, rowIndex);
|
||||
const canDrag = reorderable && !isEmpty && rowIndex < reorderableRowCount;
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={row.uid}
|
||||
draggable={canDrag}
|
||||
onDragStart={canDrag ? (e) => handleDragStart(e, rowIndex) : undefined}
|
||||
onDragOver={canDrag ? (e) => handleDragOver(e, rowIndex) : undefined}
|
||||
onDrop={canDrag ? (e) => handleDrop(e, rowIndex) : undefined}
|
||||
onDragEnd={canDrag ? handleDragEnd : undefined}
|
||||
onMouseEnter={() => setHoveredRow(rowIndex)}
|
||||
onMouseLeave={() => setHoveredRow(null)}
|
||||
>
|
||||
{showCheckbox && (
|
||||
<td className="text-center relative">
|
||||
{reorderable && canDrag && (
|
||||
<div
|
||||
draggable
|
||||
className="drag-handle group absolute z-10 left-[-8px] top-1/2 -translate-y-1/2 p-1 cursor-grab"
|
||||
>
|
||||
{hoveredRow === rowIndex && (
|
||||
<>
|
||||
<IconGripVertical
|
||||
size={14}
|
||||
className="icon-grip hidden group-hover:block"
|
||||
/>
|
||||
<IconMinusVertical
|
||||
size={14}
|
||||
className="icon-minus block group-hover:hidden"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!isEmpty && (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mousetrap"
|
||||
checked={row[checkboxKey] ?? true}
|
||||
onChange={(e) => handleCheckboxChange(row.uid, e.target.checked)}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
{columns.map((column) => (
|
||||
<td key={column.key}>
|
||||
{renderCell(column, row, rowIndex)}
|
||||
</td>
|
||||
))}
|
||||
{showDelete && (
|
||||
<td>
|
||||
{!isEmpty && (
|
||||
<button onClick={() => handleRemoveRow(row.uid)}>
|
||||
<IconTrash strokeWidth={1.5} size={18} />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditableTable;
|
||||
@@ -2,46 +2,55 @@ import styled from 'styled-components';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
.current-environment {
|
||||
border-radius: 0.9375rem;
|
||||
padding: 0.25rem 0.5rem 0.25rem 0.75rem;
|
||||
border-radius: ${(props) => props.theme.border.radius.base};
|
||||
padding: 0.25rem 0.3rem 0.25rem 0.5rem;
|
||||
user-select: none;
|
||||
background-color: transparent;
|
||||
border: 1px solid ${(props) => props.theme.dropdown.selectedColor};
|
||||
background-color: ${(props) => props.theme.app.collection.toolbar.environmentSelector.bg};
|
||||
border: 1px solid ${(props) => props.theme.app.collection.toolbar.environmentSelector.border};
|
||||
line-height: 1rem;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: ${(props) => props.theme.app.collection.toolbar.environmentSelector.hoverBorder};
|
||||
background-color: ${(props) => props.theme.app.collection.toolbar.environmentSelector.hoverBg};
|
||||
}
|
||||
|
||||
.caret {
|
||||
margin-left: 0.25rem;
|
||||
color: rgb(140, 140, 140);
|
||||
fill: rgb(140, 140, 140);
|
||||
color: ${(props) => props.theme.app.collection.toolbar.environmentSelector.caret};
|
||||
fill: ${(props) => props.theme.app.collection.toolbar.environmentSelector.caret};
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.env-icon {
|
||||
margin-right: 0.25rem;
|
||||
color: ${(props) => props.theme.dropdown.selectedColor};
|
||||
color: ${(props) => props.theme.app.collection.toolbar.environmentSelector.icon};
|
||||
}
|
||||
|
||||
.env-text {
|
||||
color: ${(props) => props.theme.dropdown.selectedColor};
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
color: ${(props) => props.theme.app.collection.toolbar.environmentSelector.text};
|
||||
display: block;
|
||||
}
|
||||
|
||||
.env-separator {
|
||||
color: #8c8c8c;
|
||||
margin: 0 0.25rem;
|
||||
opacity: 0.7;
|
||||
color: ${(props) => props.theme.app.collection.toolbar.environmentSelector.separator};
|
||||
margin: 0 0.35rem;
|
||||
}
|
||||
|
||||
.env-text-inactive {
|
||||
color: ${(props) => props.theme.dropdown.color};
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
opacity: 0.7;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
}
|
||||
|
||||
&.no-environments {
|
||||
background-color: ${(props) => props.theme.sidebar.badge.bg};
|
||||
border: 1px solid transparent;
|
||||
color: ${(props) => props.theme.dropdown.secondaryText};
|
||||
color: ${(props) => props.theme.app.collection.toolbar.environmentSelector.noEnvironment.text};
|
||||
background-color: ${(props) => props.theme.app.collection.toolbar.environmentSelector.noEnvironment.bg};
|
||||
border: 1px dashed ${(props) => props.theme.app.collection.toolbar.environmentSelector.noEnvironment.border};
|
||||
|
||||
&:hover {
|
||||
border-color: ${(props) => props.theme.app.collection.toolbar.environmentSelector.noEnvironment.hoverBorder};
|
||||
background-color: ${(props) => props.theme.app.collection.toolbar.environmentSelector.noEnvironment.hoverBg};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import find from 'lodash/find';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import { IconWorld, IconDatabase, IconCaretDown, IconSettings, IconPlus, IconDownload } from '@tabler/icons';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { updateEnvironmentSettingsModalVisibility } from 'providers/ReduxStore/slices/app';
|
||||
import { updateEnvironmentSettingsModalVisibility, updateGlobalEnvironmentSettingsModalVisibility } from 'providers/ReduxStore/slices/app';
|
||||
import { selectEnvironment } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { selectGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
|
||||
import toast from 'react-hot-toast';
|
||||
@@ -20,8 +20,6 @@ const EnvironmentSelector = ({ collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const dropdownTippyRef = useRef();
|
||||
const [activeTab, setActiveTab] = useState('collection');
|
||||
const [showGlobalSettings, setShowGlobalSettings] = useState(false);
|
||||
const [showCollectionSettings, setShowCollectionSettings] = useState(false);
|
||||
const [showCreateGlobalModal, setShowCreateGlobalModal] = useState(false);
|
||||
const [showImportGlobalModal, setShowImportGlobalModal] = useState(false);
|
||||
const [showCreateCollectionModal, setShowCreateCollectionModal] = useState(false);
|
||||
@@ -29,6 +27,8 @@ const EnvironmentSelector = ({ collection }) => {
|
||||
|
||||
const globalEnvironments = useSelector((state) => state.globalEnvironments.globalEnvironments);
|
||||
const activeGlobalEnvironmentUid = useSelector((state) => state.globalEnvironments.activeGlobalEnvironmentUid);
|
||||
const isEnvironmentSettingsModalOpen = useSelector((state) => state.app.isEnvironmentSettingsModalOpen);
|
||||
const isGlobalEnvironmentSettingsModalOpen = useSelector((state) => state.app.isGlobalEnvironmentSettingsModalOpen);
|
||||
const activeGlobalEnvironment = activeGlobalEnvironmentUid
|
||||
? find(globalEnvironments, (e) => e.uid === activeGlobalEnvironmentUid)
|
||||
: null;
|
||||
@@ -79,9 +79,8 @@ const EnvironmentSelector = ({ collection }) => {
|
||||
const handleSettingsClick = () => {
|
||||
if (activeTab === 'collection') {
|
||||
dispatch(updateEnvironmentSettingsModalVisibility(true));
|
||||
setShowCollectionSettings(true);
|
||||
} else {
|
||||
setShowGlobalSettings(true);
|
||||
dispatch(updateGlobalEnvironmentSettingsModalVisibility(true));
|
||||
}
|
||||
dropdownTippyRef.current.hide();
|
||||
};
|
||||
@@ -108,9 +107,8 @@ const EnvironmentSelector = ({ collection }) => {
|
||||
|
||||
// Modal handlers
|
||||
const handleCloseSettings = () => {
|
||||
setShowGlobalSettings(false);
|
||||
setShowCollectionSettings(false);
|
||||
dispatch(updateEnvironmentSettingsModalVisibility(false));
|
||||
dispatch(updateGlobalEnvironmentSettingsModalVisibility(false));
|
||||
};
|
||||
|
||||
// Calculate dropdown width based on the longest environment name.
|
||||
@@ -164,7 +162,7 @@ const EnvironmentSelector = ({ collection }) => {
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="env-text-inactive max-w-36 truncate no-wrap">No environments</span>
|
||||
<span className="env-text-inactive max-w-36 truncate no-wrap">No Environment</span>
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -176,7 +174,7 @@ const EnvironmentSelector = ({ collection }) => {
|
||||
data-testid="environment-selector-trigger"
|
||||
>
|
||||
{displayContent}
|
||||
<IconCaretDown className="caret" size={14} strokeWidth={2} />
|
||||
<IconCaretDown className="caret flex items-center justify-center" size={12} strokeWidth={2} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -220,7 +218,7 @@ const EnvironmentSelector = ({ collection }) => {
|
||||
</div>
|
||||
|
||||
{/* Modals - Rendered outside dropdown to avoid conflicts */}
|
||||
{showGlobalSettings && (
|
||||
{isGlobalEnvironmentSettingsModalOpen && (
|
||||
<GlobalEnvironmentSettings
|
||||
globalEnvironments={globalEnvironments}
|
||||
collection={collection}
|
||||
@@ -229,13 +227,15 @@ const EnvironmentSelector = ({ collection }) => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{showCollectionSettings && <EnvironmentSettings collection={collection} onClose={handleCloseSettings} />}
|
||||
{isEnvironmentSettingsModalOpen && (
|
||||
<EnvironmentSettings collection={collection} onClose={handleCloseSettings} />
|
||||
)}
|
||||
|
||||
{showCreateGlobalModal && (
|
||||
<CreateGlobalEnvironment
|
||||
onClose={() => setShowCreateGlobalModal(false)}
|
||||
onEnvironmentCreated={() => {
|
||||
setShowGlobalSettings(true);
|
||||
dispatch(updateGlobalEnvironmentSettingsModalVisibility(true));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -245,7 +245,7 @@ const EnvironmentSelector = ({ collection }) => {
|
||||
type="global"
|
||||
onClose={() => setShowImportGlobalModal(false)}
|
||||
onEnvironmentCreated={() => {
|
||||
setShowGlobalSettings(true);
|
||||
dispatch(updateGlobalEnvironmentSettingsModalVisibility(true));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -255,7 +255,7 @@ const EnvironmentSelector = ({ collection }) => {
|
||||
collection={collection}
|
||||
onClose={() => setShowCreateCollectionModal(false)}
|
||||
onEnvironmentCreated={() => {
|
||||
setShowCollectionSettings(true);
|
||||
dispatch(updateEnvironmentSettingsModalVisibility(true));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -266,7 +266,7 @@ const EnvironmentSelector = ({ collection }) => {
|
||||
collection={collection}
|
||||
onClose={() => setShowImportCollectionModal(false)}
|
||||
onEnvironmentCreated={() => {
|
||||
setShowCollectionSettings(true);
|
||||
dispatch(updateEnvironmentSettingsModalVisibility(true));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -11,6 +11,7 @@ const Wrapper = styled.div`
|
||||
td {
|
||||
border: 1px solid ${(props) => props.theme.collection.environment.settings.gridBorder};
|
||||
padding: 4px 10px;
|
||||
vertical-align: middle;
|
||||
|
||||
&:nth-child(1),
|
||||
&:nth-child(4) {
|
||||
@@ -58,8 +59,8 @@ const Wrapper = styled.div`
|
||||
|
||||
input[type='checkbox'] {
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
top: 1px;
|
||||
vertical-align: middle;
|
||||
margin: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,76 +1,82 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useCallback } 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 { addFolderHeader, updateFolderHeader, deleteFolderHeader, setFolderHeaders } from 'providers/ReduxStore/slices/collections';
|
||||
import { setFolderHeaders } from 'providers/ReduxStore/slices/collections';
|
||||
import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import SingleLineEditor from 'components/SingleLineEditor';
|
||||
import EditableTable from 'components/EditableTable';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { headers as StandardHTTPHeaders } from 'know-your-http-well';
|
||||
import { MimeTypes } from 'utils/codemirror/autocompleteConstants';
|
||||
import BulkEditor from 'components/BulkEditor/index';
|
||||
|
||||
const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header);
|
||||
|
||||
const Headers = ({ collection, folder }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
const headers = folder.draft ? get(folder, 'draft.request.headers', []) : get(folder, 'root.request.headers', []);
|
||||
const headers = folder.draft
|
||||
? get(folder, 'draft.request.headers', [])
|
||||
: get(folder, 'root.request.headers', []);
|
||||
const [isBulkEditMode, setIsBulkEditMode] = useState(false);
|
||||
|
||||
const toggleBulkEditMode = () => {
|
||||
setIsBulkEditMode(!isBulkEditMode);
|
||||
};
|
||||
|
||||
const handleBulkHeadersChange = (newHeaders) => {
|
||||
dispatch(setFolderHeaders({ collectionUid: collection.uid, folderUid: folder.uid, headers: newHeaders }));
|
||||
};
|
||||
|
||||
const addHeader = () => {
|
||||
dispatch(
|
||||
addFolderHeader({
|
||||
collectionUid: collection.uid,
|
||||
folderUid: folder.uid
|
||||
})
|
||||
);
|
||||
};
|
||||
const handleHeadersChange = useCallback((updatedHeaders) => {
|
||||
dispatch(setFolderHeaders({
|
||||
collectionUid: collection.uid,
|
||||
folderUid: folder.uid,
|
||||
headers: updatedHeaders
|
||||
}));
|
||||
}, [dispatch, collection.uid, folder.uid]);
|
||||
|
||||
const handleSave = () => dispatch(saveFolderRoot(collection.uid, folder.uid));
|
||||
const handleHeaderValueChange = (e, _header, type) => {
|
||||
const header = cloneDeep(_header);
|
||||
switch (type) {
|
||||
case 'name': {
|
||||
// Strip newlines from header keys
|
||||
header.name = e.target.value.replace(/[\r\n]/g, '');
|
||||
break;
|
||||
}
|
||||
case 'value': {
|
||||
header.value = e.target.value;
|
||||
break;
|
||||
}
|
||||
case 'enabled': {
|
||||
header.enabled = e.target.checked;
|
||||
break;
|
||||
}
|
||||
}
|
||||
dispatch(
|
||||
updateFolderHeader({
|
||||
header: header,
|
||||
collectionUid: collection.uid,
|
||||
folderUid: folder.uid
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const handleRemoveHeader = (header) => {
|
||||
dispatch(
|
||||
deleteFolderHeader({
|
||||
headerUid: header.uid,
|
||||
collectionUid: collection.uid,
|
||||
folderUid: folder.uid
|
||||
})
|
||||
);
|
||||
const columns = [
|
||||
{
|
||||
key: 'name',
|
||||
name: 'Name',
|
||||
isKeyField: true,
|
||||
placeholder: 'Name',
|
||||
width: '30%',
|
||||
render: ({ row, value, onChange, isLastEmptyRow }) => (
|
||||
<SingleLineEditor
|
||||
value={value || ''}
|
||||
theme={storedTheme}
|
||||
onSave={handleSave}
|
||||
onChange={(newValue) => onChange(newValue.replace(/[\r\n]/g, ''))}
|
||||
autocomplete={headerAutoCompleteList}
|
||||
collection={collection}
|
||||
placeholder={isLastEmptyRow ? 'Name' : ''}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'value',
|
||||
name: 'Value',
|
||||
placeholder: 'Value',
|
||||
render: ({ row, value, onChange, isLastEmptyRow }) => (
|
||||
<SingleLineEditor
|
||||
value={value || ''}
|
||||
theme={storedTheme}
|
||||
onSave={handleSave}
|
||||
onChange={onChange}
|
||||
collection={collection}
|
||||
item={folder}
|
||||
autocomplete={MimeTypes}
|
||||
placeholder={isLastEmptyRow ? 'Value' : ''}
|
||||
/>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
const defaultRow = {
|
||||
name: '',
|
||||
value: '',
|
||||
description: ''
|
||||
};
|
||||
|
||||
if (isBulkEditMode) {
|
||||
@@ -81,7 +87,7 @@ const Headers = ({ collection, folder }) => {
|
||||
</div>
|
||||
<BulkEditor
|
||||
params={headers}
|
||||
onChange={handleBulkHeadersChange}
|
||||
onChange={handleHeadersChange}
|
||||
onToggle={toggleBulkEditMode}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
@@ -94,87 +100,17 @@ const Headers = ({ collection, folder }) => {
|
||||
<div className="text-xs mb-4 text-muted">
|
||||
Request headers that will be sent with every request inside this folder.
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<td>Name</td>
|
||||
<td>Value</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{headers && headers.length
|
||||
? headers.map((header) => {
|
||||
return (
|
||||
<tr key={header.uid}>
|
||||
<td>
|
||||
<SingleLineEditor
|
||||
value={header.name}
|
||||
theme={storedTheme}
|
||||
onSave={handleSave}
|
||||
onChange={(newValue) =>
|
||||
handleHeaderValueChange(
|
||||
{
|
||||
target: {
|
||||
value: newValue
|
||||
}
|
||||
},
|
||||
header,
|
||||
'name'
|
||||
)}
|
||||
autocomplete={headerAutoCompleteList}
|
||||
collection={collection}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<SingleLineEditor
|
||||
value={header.value}
|
||||
theme={storedTheme}
|
||||
onSave={handleSave}
|
||||
onChange={(newValue) =>
|
||||
handleHeaderValueChange(
|
||||
{
|
||||
target: {
|
||||
value: newValue
|
||||
}
|
||||
},
|
||||
header,
|
||||
'value'
|
||||
)}
|
||||
collection={collection}
|
||||
item={folder}
|
||||
autocomplete={MimeTypes}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={header.enabled}
|
||||
tabIndex="-1"
|
||||
className="mr-3 mousetrap"
|
||||
onChange={(e) => handleHeaderValueChange(e, header, 'enabled')}
|
||||
/>
|
||||
<button tabIndex="-1" onClick={() => handleRemoveHeader(header)}>
|
||||
<IconTrash strokeWidth={1.5} size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="flex justify-between mt-2">
|
||||
<button className="btn-add-header text-link pr-2 py-3 select-none" onClick={addHeader}>
|
||||
+ Add Header
|
||||
</button>
|
||||
<EditableTable
|
||||
columns={columns}
|
||||
rows={headers}
|
||||
onChange={handleHeadersChange}
|
||||
defaultRow={defaultRow}
|
||||
/>
|
||||
<div className="flex justify-end mt-2">
|
||||
<button className="text-link select-none" onClick={toggleBulkEditMode}>
|
||||
Bulk Edit
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<button type="submit" className="submit btn btn-sm btn-secondary" onClick={handleSave}>
|
||||
Save
|
||||
@@ -183,4 +119,5 @@ const Headers = ({ collection, folder }) => {
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default Headers;
|
||||
|
||||
@@ -8,7 +8,7 @@ const StyledWrapper = styled.div`
|
||||
padding: 6px 0px;
|
||||
border: none;
|
||||
border-bottom: solid 2px transparent;
|
||||
margin-right: 1.25rem;
|
||||
margin-right: ${(props) => props.theme.tabs.marginRight};
|
||||
color: var(--color-tab-inactive);
|
||||
cursor: pointer;
|
||||
|
||||
@@ -22,6 +22,7 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
|
||||
&.active {
|
||||
font-weight: ${(props) => props.theme.tabs.active.fontWeight} !important;
|
||||
color: ${(props) => props.theme.tabs.active.color} !important;
|
||||
border-bottom: solid 2px ${(props) => props.theme.tabs.active.border} !important;
|
||||
}
|
||||
|
||||
@@ -1,160 +1,87 @@
|
||||
import React from 'react';
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import { IconTrash } from '@tabler/icons';
|
||||
import React, { useCallback } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import MultiLineEditor from 'components/MultiLineEditor';
|
||||
import InfoTip from 'components/InfoTip';
|
||||
import EditableTable from 'components/EditableTable';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import toast from 'react-hot-toast';
|
||||
import { variableNameRegex } from 'utils/common/regex';
|
||||
import { addFolderVar, deleteFolderVar, updateFolderVar } from 'providers/ReduxStore/slices/collections/index';
|
||||
import { setFolderVars } from 'providers/ReduxStore/slices/collections/index';
|
||||
|
||||
const VarsTable = ({ folder, collection, vars, varType }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
|
||||
const addVar = () => {
|
||||
dispatch(
|
||||
addFolderVar({
|
||||
collectionUid: collection.uid,
|
||||
folderUid: folder.uid,
|
||||
type: varType
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const onSave = () => dispatch(saveFolderRoot(collection.uid, folder.uid));
|
||||
const handleVarChange = (e, v, type) => {
|
||||
const _var = cloneDeep(v);
|
||||
switch (type) {
|
||||
case 'name': {
|
||||
const value = e.target.value;
|
||||
|
||||
if (variableNameRegex.test(value) === false) {
|
||||
toast.error(
|
||||
'Variable contains invalid characters! Variables must only contain alpha-numeric characters, "-", "_", "."'
|
||||
);
|
||||
return;
|
||||
}
|
||||
const handleVarsChange = useCallback((updatedVars) => {
|
||||
dispatch(setFolderVars({
|
||||
collectionUid: collection.uid,
|
||||
folderUid: folder.uid,
|
||||
vars: updatedVars,
|
||||
type: varType
|
||||
}));
|
||||
}, [dispatch, collection.uid, folder.uid, varType]);
|
||||
|
||||
_var.name = value;
|
||||
break;
|
||||
}
|
||||
case 'value': {
|
||||
_var.value = e.target.value;
|
||||
break;
|
||||
}
|
||||
case 'enabled': {
|
||||
_var.enabled = e.target.checked;
|
||||
break;
|
||||
}
|
||||
const getRowError = useCallback((row, index, key) => {
|
||||
if (key !== 'name') return null;
|
||||
if (!row.name || row.name.trim() === '') return null;
|
||||
if (!variableNameRegex.test(row.name)) {
|
||||
return 'Variable contains invalid characters. Must only contain alphanumeric characters, "-", "_", "."';
|
||||
}
|
||||
dispatch(
|
||||
updateFolderVar({
|
||||
type: varType,
|
||||
var: _var,
|
||||
folderUid: folder.uid,
|
||||
collectionUid: collection.uid
|
||||
})
|
||||
);
|
||||
};
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
const handleRemoveVar = (_var) => {
|
||||
dispatch(
|
||||
deleteFolderVar({
|
||||
type: varType,
|
||||
varUid: _var.uid,
|
||||
folderUid: folder.uid,
|
||||
collectionUid: collection.uid
|
||||
})
|
||||
);
|
||||
const columns = [
|
||||
{
|
||||
key: 'name',
|
||||
name: 'Name',
|
||||
isKeyField: true,
|
||||
placeholder: 'Name',
|
||||
width: '40%'
|
||||
},
|
||||
{
|
||||
key: 'value',
|
||||
name: varType === 'request' ? 'Value' : (
|
||||
<div className="flex items-center">
|
||||
<span>Expr</span>
|
||||
<InfoTip content="You can write any valid JS expression here" infotipId={`folder-${varType}-var`} />
|
||||
</div>
|
||||
),
|
||||
placeholder: varType === 'request' ? 'Value' : 'Expr',
|
||||
render: ({ row, value, onChange, isLastEmptyRow }) => (
|
||||
<MultiLineEditor
|
||||
value={value || ''}
|
||||
theme={storedTheme}
|
||||
onSave={onSave}
|
||||
onChange={onChange}
|
||||
collection={collection}
|
||||
item={folder}
|
||||
placeholder={isLastEmptyRow ? (varType === 'request' ? 'Value' : 'Expr') : ''}
|
||||
/>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
const defaultRow = {
|
||||
name: '',
|
||||
value: '',
|
||||
...(varType === 'response' ? { local: false } : {})
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<td>Name</td>
|
||||
{varType === 'request' ? (
|
||||
<td>
|
||||
<div className="flex items-center">
|
||||
<span>Value</span>
|
||||
</div>
|
||||
</td>
|
||||
) : (
|
||||
<td>
|
||||
<div className="flex items-center">
|
||||
<span>Expr</span>
|
||||
<InfoTip content="You can write any valid JS expression here" infotipId="response-var" />
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
<td></td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{vars && vars.length
|
||||
? vars.map((_var) => {
|
||||
return (
|
||||
<tr key={_var.uid}>
|
||||
<td>
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
value={_var.name}
|
||||
className="mousetrap"
|
||||
onChange={(e) => handleVarChange(e, _var, 'name')}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<MultiLineEditor
|
||||
value={_var.value}
|
||||
theme={storedTheme}
|
||||
onSave={onSave}
|
||||
onChange={(newValue) =>
|
||||
handleVarChange(
|
||||
{
|
||||
target: {
|
||||
value: newValue
|
||||
}
|
||||
},
|
||||
_var,
|
||||
'value'
|
||||
)}
|
||||
collection={collection}
|
||||
item={folder}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={_var.enabled}
|
||||
tabIndex="-1"
|
||||
className="mr-3 mousetrap"
|
||||
onChange={(e) => handleVarChange(e, _var, 'enabled')}
|
||||
/>
|
||||
<button tabIndex="-1" onClick={() => handleRemoveVar(_var)}>
|
||||
<IconTrash strokeWidth={1.5} size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
</tbody>
|
||||
</table>
|
||||
<button className="btn-add-var text-link pr-2 py-3 mt-2 select-none" onClick={addVar}>
|
||||
+ Add
|
||||
</button>
|
||||
<EditableTable
|
||||
columns={columns}
|
||||
rows={vars}
|
||||
onChange={handleVarsChange}
|
||||
defaultRow={defaultRow}
|
||||
getRowError={getRowError}
|
||||
/>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default VarsTable;
|
||||
|
||||
@@ -8,13 +8,19 @@ import { useDispatch } from 'react-redux';
|
||||
const Vars = ({ collection, folder }) => {
|
||||
const dispatch = useDispatch();
|
||||
const requestVars = folder.draft ? get(folder, 'draft.request.vars.req', []) : get(folder, 'root.request.vars.req', []);
|
||||
const responseVars = folder.draft ? get(folder, 'draft.request.vars.res', []) : get(folder, 'root.request.vars.res', []);
|
||||
const handleSave = () => dispatch(saveFolderRoot(collection.uid, folder.uid));
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full flex flex-col">
|
||||
<div className="flex-1 mt-2">
|
||||
<div className="mb-1 title text-xs">Pre Request</div>
|
||||
<VarsTable folder={folder} collection={collection} vars={requestVars} varType="request" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="mt-1 mb-1 title text-xs">Post Response</div>
|
||||
<VarsTable folder={folder} collection={collection} vars={responseVars} varType="response" />
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<button type="submit" className="submit btn btn-sm btn-secondary" onClick={handleSave}>
|
||||
Save
|
||||
|
||||
@@ -28,7 +28,8 @@ const FolderSettings = ({ collection, folder }) => {
|
||||
const activeHeadersCount = headers.filter((header) => header.enabled).length;
|
||||
|
||||
const requestVars = folderRoot?.request?.vars?.req || [];
|
||||
const activeVarsCount = requestVars.filter((v) => v.enabled).length;
|
||||
const responseVars = folderRoot?.request?.vars?.res || [];
|
||||
const activeVarsCount = requestVars.filter((v) => v.enabled).length + responseVars.filter((v) => v.enabled).length;
|
||||
|
||||
const auth = get(folderRoot, 'request.auth.mode');
|
||||
const hasAuth = auth && auth !== 'none';
|
||||
|
||||
@@ -11,6 +11,7 @@ const Wrapper = styled.div`
|
||||
td {
|
||||
border: 1px solid ${(props) => props.theme.collection.environment.settings.gridBorder};
|
||||
padding: 4px 10px;
|
||||
vertical-align: middle;
|
||||
|
||||
&:nth-child(1),
|
||||
&:nth-child(4) {
|
||||
@@ -58,8 +59,8 @@ const Wrapper = styled.div`
|
||||
|
||||
input[type='checkbox'] {
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
top: 1px;
|
||||
vertical-align: middle;
|
||||
margin: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
|
||||
const IconBottombarToggle = ({ collapsed = false, size = 16, strokeWidth = 1.5, className = '', ...rest }) => {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={`icon icon-tabler icons-tabler-outline icon-tabler-layout-bottombar ${className}`} {...rest}>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M4 4m0 2a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2z" />
|
||||
<path d="M4 15l16 0" />
|
||||
{!collapsed && (
|
||||
<rect x="4.6" y="15.6" width="14.8" height="2.8" rx="0.8" fill="currentColor" />
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default IconBottombarToggle;
|
||||
@@ -88,7 +88,9 @@ const Modal = ({
|
||||
return closeModal({ type: 'esc' });
|
||||
}
|
||||
case ENTER_KEY_CODE: {
|
||||
if (!shiftKey && !ctrlKey && !altKey && !metaKey && handleConfirm) {
|
||||
// Skip if a submit button is focused - let native button click handle it to avoid double-fire
|
||||
const isSubmitButton = event.target?.type === 'submit';
|
||||
if (!shiftKey && !ctrlKey && !altKey && !metaKey && handleConfirm && !isSubmitButton) {
|
||||
return handleConfirm();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,122 +1,171 @@
|
||||
import React from 'react';
|
||||
import React, { useCallback } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { addAssertion, updateAssertion, deleteAssertion } from 'providers/ReduxStore/slices/collections';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { moveAssertion, setRequestAssertions } from 'providers/ReduxStore/slices/collections';
|
||||
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import AssertionRow from './AssertionRow';
|
||||
import SingleLineEditor from 'components/SingleLineEditor';
|
||||
import AssertionOperator from './AssertionOperator';
|
||||
import EditableTable from 'components/EditableTable';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import Table from 'components/Table/index';
|
||||
import ReorderTable from 'components/ReorderTable/index';
|
||||
import { moveAssertion } from 'providers/ReduxStore/slices/collections/index';
|
||||
|
||||
const unaryOperators = [
|
||||
'isEmpty',
|
||||
'isNotEmpty',
|
||||
'isNull',
|
||||
'isUndefined',
|
||||
'isDefined',
|
||||
'isTruthy',
|
||||
'isFalsy',
|
||||
'isJson',
|
||||
'isNumber',
|
||||
'isString',
|
||||
'isBoolean',
|
||||
'isArray'
|
||||
];
|
||||
|
||||
const parseAssertionOperator = (str = '') => {
|
||||
if (!str || typeof str !== 'string' || !str.length) {
|
||||
return { operator: 'eq', value: str };
|
||||
}
|
||||
|
||||
const operators = [
|
||||
'eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'in', 'notIn',
|
||||
'contains', 'notContains', 'length', 'matches', 'notMatches',
|
||||
'startsWith', 'endsWith', 'between', ...unaryOperators
|
||||
];
|
||||
|
||||
const [operator, ...rest] = str.split(' ');
|
||||
const value = rest.join(' ');
|
||||
|
||||
if (unaryOperators.includes(operator)) {
|
||||
return { operator, value: '' };
|
||||
}
|
||||
|
||||
if (operators.includes(operator)) {
|
||||
return { operator, value };
|
||||
}
|
||||
|
||||
return { operator: 'eq', value: str };
|
||||
};
|
||||
|
||||
const isUnaryOperator = (operator) => unaryOperators.includes(operator);
|
||||
|
||||
const Assertions = ({ item, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
const assertions = item.draft ? get(item, 'draft.request.assertions') : get(item, 'request.assertions');
|
||||
|
||||
const handleAddAssertion = () => {
|
||||
dispatch(
|
||||
addAssertion({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
|
||||
const handleRun = () => dispatch(sendRequest(item, collection.uid));
|
||||
const handleAssertionChange = (e, _assertion, type) => {
|
||||
const assertion = cloneDeep(_assertion);
|
||||
switch (type) {
|
||||
case 'name': {
|
||||
assertion.name = e.target.value;
|
||||
break;
|
||||
|
||||
const handleAssertionsChange = useCallback((updatedAssertions) => {
|
||||
dispatch(setRequestAssertions({
|
||||
collectionUid: collection.uid,
|
||||
itemUid: item.uid,
|
||||
assertions: updatedAssertions
|
||||
}));
|
||||
}, [dispatch, collection.uid, item.uid]);
|
||||
|
||||
const handleAssertionDrag = useCallback(({ updateReorderedItem }) => {
|
||||
dispatch(moveAssertion({
|
||||
collectionUid: collection.uid,
|
||||
itemUid: item.uid,
|
||||
updateReorderedItem
|
||||
}));
|
||||
}, [dispatch, collection.uid, item.uid]);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'name',
|
||||
name: 'Expr',
|
||||
isKeyField: true,
|
||||
placeholder: 'Expr',
|
||||
width: '30%'
|
||||
},
|
||||
{
|
||||
key: 'operator',
|
||||
name: 'Operator',
|
||||
width: '120px',
|
||||
getValue: (row) => parseAssertionOperator(row.value).operator,
|
||||
render: ({ row, rowIndex, isLastEmptyRow }) => {
|
||||
const { operator } = parseAssertionOperator(row.value);
|
||||
const assertionValue = parseAssertionOperator(row.value).value;
|
||||
|
||||
const handleOperatorChange = (newOperator) => {
|
||||
const currentAssertions = assertions || [];
|
||||
const existingAssertion = currentAssertions.find((a) => a.uid === row.uid);
|
||||
const newValue = isUnaryOperator(newOperator) ? newOperator : `${newOperator} ${assertionValue}`;
|
||||
|
||||
if (existingAssertion) {
|
||||
const updatedAssertions = currentAssertions.map((assertion) => {
|
||||
if (assertion.uid === row.uid) {
|
||||
return {
|
||||
...assertion,
|
||||
value: newValue
|
||||
};
|
||||
}
|
||||
return assertion;
|
||||
});
|
||||
handleAssertionsChange(updatedAssertions);
|
||||
} else {
|
||||
handleAssertionsChange([...currentAssertions, { ...row, value: newValue }]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AssertionOperator
|
||||
operator={operator}
|
||||
onChange={handleOperatorChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case 'value': {
|
||||
assertion.value = e.target.value;
|
||||
break;
|
||||
}
|
||||
case 'enabled': {
|
||||
assertion.enabled = e.target.checked;
|
||||
break;
|
||||
},
|
||||
{
|
||||
key: 'value',
|
||||
name: 'Value',
|
||||
width: '30%',
|
||||
render: ({ row, value, onChange, isLastEmptyRow }) => {
|
||||
const { operator, value: assertionValue } = parseAssertionOperator(value);
|
||||
|
||||
if (isUnaryOperator(operator)) {
|
||||
return <input type="text" className="cursor-default" disabled />;
|
||||
}
|
||||
|
||||
return (
|
||||
<SingleLineEditor
|
||||
value={assertionValue}
|
||||
theme={storedTheme}
|
||||
onSave={onSave}
|
||||
onChange={(newValue) => onChange(`${operator} ${newValue}`)}
|
||||
onRun={handleRun}
|
||||
collection={collection}
|
||||
item={item}
|
||||
placeholder={isLastEmptyRow ? 'Value' : ''}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
dispatch(
|
||||
updateAssertion({
|
||||
assertion: assertion,
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid
|
||||
})
|
||||
);
|
||||
};
|
||||
];
|
||||
|
||||
const handleRemoveAssertion = (assertion) => {
|
||||
dispatch(
|
||||
deleteAssertion({
|
||||
assertUid: assertion.uid,
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const handleAssertionDrag = ({ updateReorderedItem }) => {
|
||||
dispatch(
|
||||
moveAssertion({
|
||||
collectionUid: collection.uid,
|
||||
itemUid: item.uid,
|
||||
updateReorderedItem
|
||||
})
|
||||
);
|
||||
const defaultRow = {
|
||||
name: '',
|
||||
value: 'eq ',
|
||||
operator: 'eq'
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full">
|
||||
<Table
|
||||
headers={[
|
||||
{ name: 'Expr', accessor: 'expr', width: '30%' },
|
||||
{ name: 'Operator', accessor: 'operator', width: '120px' },
|
||||
{ name: 'Value', accessor: 'value', width: '30%' },
|
||||
{ name: '', accessor: '', width: '15%' }
|
||||
]}
|
||||
>
|
||||
<ReorderTable updateReorderedItem={handleAssertionDrag}>
|
||||
{assertions && assertions.length
|
||||
? assertions.map((assertion) => {
|
||||
return (
|
||||
<tr key={assertion.uid} data-uid={assertion.uid}>
|
||||
<td className="flex relative">
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
value={assertion.name}
|
||||
className="mousetrap"
|
||||
onChange={(e) => handleAssertionChange(e, assertion, 'name')}
|
||||
/>
|
||||
</td>
|
||||
<AssertionRow
|
||||
key={assertion.uid}
|
||||
assertion={assertion}
|
||||
item={item}
|
||||
collection={collection}
|
||||
handleAssertionChange={handleAssertionChange}
|
||||
handleRemoveAssertion={handleRemoveAssertion}
|
||||
onSave={onSave}
|
||||
handleRun={handleRun}
|
||||
/>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
</ReorderTable>
|
||||
</Table>
|
||||
<button className="btn-add-assertion text-link pr-2 py-3 mt-2 select-none" onClick={handleAddAssertion}>
|
||||
+ Add Assertion
|
||||
</button>
|
||||
<EditableTable
|
||||
columns={columns}
|
||||
rows={assertions || []}
|
||||
onChange={handleAssertionsChange}
|
||||
defaultRow={defaultRow}
|
||||
reorderable={true}
|
||||
onReorder={handleAssertionDrag}
|
||||
/>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default Assertions;
|
||||
|
||||
@@ -1,153 +1,86 @@
|
||||
import React from 'react';
|
||||
import React, { useCallback } 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 {
|
||||
addFormUrlEncodedParam,
|
||||
updateFormUrlEncodedParam,
|
||||
deleteFormUrlEncodedParam,
|
||||
moveFormUrlEncodedParam
|
||||
moveFormUrlEncodedParam,
|
||||
setFormUrlEncodedParams
|
||||
} from 'providers/ReduxStore/slices/collections';
|
||||
import MultiLineEditor from 'components/MultiLineEditor';
|
||||
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import EditableTable from 'components/EditableTable';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import ReorderTable from 'components/ReorderTable/index';
|
||||
import Table from 'components/Table/index';
|
||||
|
||||
const FormUrlEncodedParams = ({ item, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
const params = item.draft ? get(item, 'draft.request.body.formUrlEncoded') : get(item, 'request.body.formUrlEncoded');
|
||||
|
||||
const addParam = () => {
|
||||
dispatch(
|
||||
addFormUrlEncodedParam({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
|
||||
const handleRun = () => dispatch(sendRequest(item, collection.uid));
|
||||
const handleParamChange = (e, _param, type) => {
|
||||
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 handleParamsChange = useCallback((updatedParams) => {
|
||||
dispatch(setFormUrlEncodedParams({
|
||||
collectionUid: collection.uid,
|
||||
itemUid: item.uid,
|
||||
params: updatedParams
|
||||
}));
|
||||
}, [dispatch, collection.uid, item.uid]);
|
||||
|
||||
const handleParamDrag = useCallback(({ updateReorderedItem }) => {
|
||||
dispatch(moveFormUrlEncodedParam({
|
||||
collectionUid: collection.uid,
|
||||
itemUid: item.uid,
|
||||
updateReorderedItem
|
||||
}));
|
||||
}, [dispatch, collection.uid, item.uid]);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'name',
|
||||
name: 'Key',
|
||||
isKeyField: true,
|
||||
placeholder: 'Key',
|
||||
width: '30%'
|
||||
},
|
||||
{
|
||||
key: 'value',
|
||||
name: 'Value',
|
||||
placeholder: 'Value',
|
||||
render: ({ row, value, onChange, isLastEmptyRow }) => (
|
||||
<MultiLineEditor
|
||||
value={value || ''}
|
||||
theme={storedTheme}
|
||||
onSave={onSave}
|
||||
onChange={onChange}
|
||||
allowNewlines={true}
|
||||
onRun={handleRun}
|
||||
collection={collection}
|
||||
item={item}
|
||||
placeholder={isLastEmptyRow ? 'Value' : ''}
|
||||
/>
|
||||
)
|
||||
}
|
||||
dispatch(
|
||||
updateFormUrlEncodedParam({
|
||||
param: param,
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid
|
||||
})
|
||||
);
|
||||
};
|
||||
];
|
||||
|
||||
const handleRemoveParams = (param) => {
|
||||
dispatch(
|
||||
deleteFormUrlEncodedParam({
|
||||
paramUid: param.uid,
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const handleParamDrag = ({ updateReorderedItem }) => {
|
||||
dispatch(
|
||||
moveFormUrlEncodedParam({
|
||||
collectionUid: collection.uid,
|
||||
itemUid: item.uid,
|
||||
updateReorderedItem
|
||||
})
|
||||
);
|
||||
const defaultRow = {
|
||||
name: '',
|
||||
value: '',
|
||||
description: ''
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full">
|
||||
<Table
|
||||
headers={[
|
||||
{ name: 'Key', accessor: 'key', width: '40%' },
|
||||
{ name: 'Value', accessor: 'value', width: '46%' },
|
||||
{ name: '', accessor: '', width: '14%' }
|
||||
]}
|
||||
>
|
||||
<ReorderTable updateReorderedItem={handleParamDrag}>
|
||||
{params && params.length
|
||||
? params.map((param, index) => {
|
||||
return (
|
||||
<tr key={param.uid} data-uid={param.uid}>
|
||||
<td className="flex relative">
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
value={param.name}
|
||||
className="mousetrap"
|
||||
onChange={(e) => handleParamChange(e, param, 'name')}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<MultiLineEditor
|
||||
value={param.value}
|
||||
theme={storedTheme}
|
||||
onSave={onSave}
|
||||
onChange={(newValue) =>
|
||||
handleParamChange(
|
||||
{
|
||||
target: {
|
||||
value: newValue
|
||||
}
|
||||
},
|
||||
param,
|
||||
'value'
|
||||
)}
|
||||
allowNewlines={true}
|
||||
onRun={handleRun}
|
||||
collection={collection}
|
||||
item={item}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={param.enabled}
|
||||
tabIndex="-1"
|
||||
className="mr-3 mousetrap"
|
||||
onChange={(e) => handleParamChange(e, param, 'enabled')}
|
||||
/>
|
||||
<button tabIndex="-1" onClick={() => handleRemoveParams(param)}>
|
||||
<IconTrash strokeWidth={1.5} size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
</ReorderTable>
|
||||
</Table>
|
||||
<button className="btn-add-param text-link pr-2 py-3 mt-2 select-none" onClick={addParam}>
|
||||
+ Add Param
|
||||
</button>
|
||||
<EditableTable
|
||||
columns={columns}
|
||||
rows={params || []}
|
||||
onChange={handleParamsChange}
|
||||
defaultRow={defaultRow}
|
||||
reorderable={true}
|
||||
onReorder={handleParamDrag}
|
||||
/>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default FormUrlEncodedParams;
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState, useCallback, useMemo, useRef } from 'react';
|
||||
import find from 'lodash/find';
|
||||
import get from 'lodash/get';
|
||||
import classnames from 'classnames';
|
||||
@@ -15,54 +15,86 @@ import Tests from 'components/RequestPane/Tests';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { updateRequestGraphqlQuery } from 'providers/ReduxStore/slices/collections';
|
||||
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import Documentation from 'components/Documentation/index';
|
||||
import GraphQLSchemaActions from '../GraphQLSchemaActions/index';
|
||||
import HeightBoundContainer from 'ui/HeightBoundContainer';
|
||||
import Settings from 'components/RequestPane/Settings';
|
||||
import RequestPaneTabs from 'components/RequestPane/RequestPaneTabs';
|
||||
|
||||
const MULTIPLE_CONTENT_TABS = new Set(['script', 'vars', 'auth', 'docs']);
|
||||
|
||||
const TAB_CONFIG = [
|
||||
{ key: 'query', label: 'Query' },
|
||||
{ key: 'variables', label: 'Variables' },
|
||||
{ key: 'headers', label: 'Headers' },
|
||||
{ key: 'auth', label: 'Auth' },
|
||||
{ key: 'vars', label: 'Vars' },
|
||||
{ key: 'script', label: 'Script' },
|
||||
{ key: 'assert', label: 'Assert' },
|
||||
{ key: 'tests', label: 'Tests' },
|
||||
{ key: 'docs', label: 'Docs' },
|
||||
{ key: 'settings', label: 'Settings' }
|
||||
];
|
||||
|
||||
const GraphQLRequestPane = ({ item, collection, onSchemaLoad, toggleDocs, handleGqlClickReference }) => {
|
||||
const dispatch = useDispatch();
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
|
||||
const query = item.draft
|
||||
? get(item, 'draft.request.body.graphql.query', '')
|
||||
: get(item, 'request.body.graphql.query', '');
|
||||
const variables = item.draft
|
||||
? get(item, 'draft.request.body.graphql.variables')
|
||||
: get(item, 'request.body.graphql.variables');
|
||||
|
||||
const { displayedTheme } = useTheme();
|
||||
const [schema, setSchema] = useState(null);
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const schemaActionsRef = useRef(null);
|
||||
|
||||
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
|
||||
const requestPaneTab = focusedTab?.requestPaneTab;
|
||||
|
||||
useEffect(() => {
|
||||
onSchemaLoad(schema);
|
||||
}, [schema]);
|
||||
}, [schema, onSchemaLoad]);
|
||||
|
||||
const onQueryChange = (value) => {
|
||||
dispatch(
|
||||
updateRequestGraphqlQuery({
|
||||
query: value,
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid
|
||||
})
|
||||
);
|
||||
};
|
||||
const onRun = () => dispatch(sendRequest(item, collection.uid));
|
||||
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
|
||||
const onQueryChange = useCallback(
|
||||
(value) => {
|
||||
dispatch(
|
||||
updateRequestGraphqlQuery({
|
||||
query: value,
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid
|
||||
})
|
||||
);
|
||||
},
|
||||
[dispatch, item.uid, collection.uid]
|
||||
);
|
||||
|
||||
const selectTab = (tab) => {
|
||||
dispatch(
|
||||
updateRequestPaneTab({
|
||||
uid: item.uid,
|
||||
requestPaneTab: tab
|
||||
})
|
||||
);
|
||||
};
|
||||
const onRun = useCallback(
|
||||
() => dispatch(sendRequest(item, collection.uid)),
|
||||
[dispatch, item, collection.uid]
|
||||
);
|
||||
|
||||
const getTabPanel = (tab) => {
|
||||
switch (tab) {
|
||||
case 'query': {
|
||||
const onSave = useCallback(
|
||||
() => dispatch(saveRequest(item.uid, collection.uid)),
|
||||
[dispatch, item.uid, collection.uid]
|
||||
);
|
||||
|
||||
const selectTab = useCallback(
|
||||
(tabKey) => {
|
||||
dispatch(updateRequestPaneTab({ uid: item.uid, requestPaneTab: tabKey }));
|
||||
},
|
||||
[dispatch, item.uid]
|
||||
);
|
||||
|
||||
const allTabs = useMemo(() => TAB_CONFIG.map(({ key, label }) => ({ key, label })), []);
|
||||
|
||||
const tabPanel = useMemo(() => {
|
||||
switch (requestPaneTab) {
|
||||
case 'query':
|
||||
return (
|
||||
<QueryEditor
|
||||
collection={collection}
|
||||
@@ -77,94 +109,55 @@ const GraphQLRequestPane = ({ item, collection, onSchemaLoad, toggleDocs, handle
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case 'variables': {
|
||||
case 'variables':
|
||||
return <GraphQLVariables item={item} variables={variables} collection={collection} />;
|
||||
}
|
||||
case 'headers': {
|
||||
case 'headers':
|
||||
return <RequestHeaders item={item} collection={collection} />;
|
||||
}
|
||||
case 'auth': {
|
||||
case 'auth':
|
||||
return <Auth item={item} collection={collection} />;
|
||||
}
|
||||
case 'vars': {
|
||||
case 'vars':
|
||||
return <Vars item={item} collection={collection} />;
|
||||
}
|
||||
case 'assert': {
|
||||
case 'assert':
|
||||
return <Assertions item={item} collection={collection} />;
|
||||
}
|
||||
case 'script': {
|
||||
case 'script':
|
||||
return <Script item={item} collection={collection} />;
|
||||
}
|
||||
case 'tests': {
|
||||
case 'tests':
|
||||
return <Tests item={item} collection={collection} />;
|
||||
}
|
||||
case 'docs': {
|
||||
case 'docs':
|
||||
return <Documentation item={item} collection={collection} />;
|
||||
}
|
||||
case 'settings': {
|
||||
case 'settings':
|
||||
return <Settings item={item} collection={collection} />;
|
||||
}
|
||||
default: {
|
||||
default:
|
||||
return <div className="mt-4">404 | Not found</div>;
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [requestPaneTab, item, collection, displayedTheme, schema, onSave, query, onRun, onQueryChange, handleGqlClickReference, preferences, variables]);
|
||||
|
||||
if (!activeTabUid) {
|
||||
return <div>Something went wrong</div>;
|
||||
}
|
||||
|
||||
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
|
||||
if (!focusedTab || !focusedTab.uid || !focusedTab.requestPaneTab) {
|
||||
if (!activeTabUid || !focusedTab?.uid || !requestPaneTab) {
|
||||
return <div className="pb-4 px-4">An error occurred!</div>;
|
||||
}
|
||||
|
||||
const getTabClassname = (tabName) => {
|
||||
return classnames(`tab select-none ${tabName}`, {
|
||||
active: tabName === focusedTab.requestPaneTab
|
||||
});
|
||||
};
|
||||
const isMultipleContentTab = MULTIPLE_CONTENT_TABS.has(requestPaneTab);
|
||||
|
||||
const rightContent = (
|
||||
<div ref={schemaActionsRef}>
|
||||
<GraphQLSchemaActions item={item} collection={collection} onSchemaLoad={setSchema} toggleDocs={toggleDocs} />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledWrapper className="flex flex-col h-full relative">
|
||||
<div className="flex flex-wrap items-center tabs" role="tablist">
|
||||
<div className={getTabClassname('query')} role="tab" onClick={() => selectTab('query')}>
|
||||
Query
|
||||
</div>
|
||||
<div className={getTabClassname('variables')} role="tab" onClick={() => selectTab('variables')}>
|
||||
Variables
|
||||
</div>
|
||||
<div className={getTabClassname('headers')} role="tab" onClick={() => selectTab('headers')}>
|
||||
Headers
|
||||
</div>
|
||||
<div className={getTabClassname('auth')} role="tab" onClick={() => selectTab('auth')}>
|
||||
Auth
|
||||
</div>
|
||||
<div className={getTabClassname('vars')} role="tab" onClick={() => selectTab('vars')}>
|
||||
Vars
|
||||
</div>
|
||||
<div className={getTabClassname('script')} role="tab" onClick={() => selectTab('script')}>
|
||||
Script
|
||||
</div>
|
||||
<div className={getTabClassname('assert')} role="tab" onClick={() => selectTab('assert')}>
|
||||
Assert
|
||||
</div>
|
||||
<div className={getTabClassname('tests')} role="tab" onClick={() => selectTab('tests')}>
|
||||
Tests
|
||||
</div>
|
||||
<div className={getTabClassname('docs')} role="tab" onClick={() => selectTab('docs')}>
|
||||
Docs
|
||||
</div>
|
||||
<div className={getTabClassname('settings')} role="tab" onClick={() => selectTab('settings')}>
|
||||
Settings
|
||||
</div>
|
||||
<GraphQLSchemaActions item={item} collection={collection} onSchemaLoad={setSchema} toggleDocs={toggleDocs} />
|
||||
</div>
|
||||
<section className="flex w-full mt-5 flex-1 relative">
|
||||
<HeightBoundContainer>{getTabPanel(focusedTab.requestPaneTab)}</HeightBoundContainer>
|
||||
<div className="flex flex-col h-full relative">
|
||||
<RequestPaneTabs
|
||||
tabs={allTabs}
|
||||
activeTab={requestPaneTab}
|
||||
onTabSelect={selectTab}
|
||||
rightContent={rightContent}
|
||||
rightContentRef={schemaActionsRef}
|
||||
/>
|
||||
|
||||
<section className={classnames('flex w-full flex-1', { 'mt-5': !isMultipleContentTab })}>
|
||||
<HeightBoundContainer>{tabPanel}</HeightBoundContainer>
|
||||
</section>
|
||||
</StyledWrapper>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
height: 2.3rem;
|
||||
height: 2.1rem;
|
||||
border: ${(props) => props.theme.requestTabPanel.url.border};
|
||||
border-radius: ${(props) => props.theme.border.radius.base};
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ const StyledWrapper = styled.div`
|
||||
padding: 6px 0px;
|
||||
border: none;
|
||||
border-bottom: solid 2px transparent;
|
||||
margin-right: 1.25rem;
|
||||
margin-right: ${(props) => props.theme.tabs.marginRight};
|
||||
color: var(--color-tab-inactive);
|
||||
cursor: pointer;
|
||||
|
||||
@@ -20,6 +20,7 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
|
||||
&.active {
|
||||
font-weight: ${(props) => props.theme.tabs.active.fontWeight} !important;
|
||||
color: ${(props) => props.theme.tabs.active.color} !important;
|
||||
border-bottom: solid 2px ${(props) => props.theme.tabs.active.border} !important;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import classnames from 'classnames';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { find, get } from 'lodash';
|
||||
import { updateRequestPaneTab } from 'providers/ReduxStore/slices/tabs';
|
||||
import QueryParams from 'components/RequestPane/QueryParams';
|
||||
import RequestHeaders from 'components/RequestPane/RequestHeaders';
|
||||
@@ -11,175 +12,144 @@ import Vars from 'components/RequestPane/Vars';
|
||||
import Assertions from 'components/RequestPane/Assertions';
|
||||
import Script from 'components/RequestPane/Script';
|
||||
import Tests from 'components/RequestPane/Tests';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { find, get } from 'lodash';
|
||||
import Documentation from 'components/Documentation/index';
|
||||
import HeightBoundContainer from 'ui/HeightBoundContainer';
|
||||
import { useEffect } from 'react';
|
||||
import StatusDot from 'components/StatusDot';
|
||||
import Settings from 'components/RequestPane/Settings';
|
||||
import Documentation from 'components/Documentation/index';
|
||||
import StatusDot from 'components/StatusDot';
|
||||
import RequestPaneTabs from 'components/RequestPane/RequestPaneTabs';
|
||||
import HeightBoundContainer from 'ui/HeightBoundContainer';
|
||||
|
||||
const MULTIPLE_CONTENT_TABS = new Set(['params', 'script', 'vars', 'auth', 'docs']);
|
||||
|
||||
const TAB_CONFIG = [
|
||||
{ key: 'params', label: 'Params' },
|
||||
{ key: 'body', label: 'Body' },
|
||||
{ key: 'headers', label: 'Headers' },
|
||||
{ key: 'auth', label: 'Auth' },
|
||||
{ key: 'vars', label: 'Vars' },
|
||||
{ key: 'script', label: 'Script' },
|
||||
{ key: 'assert', label: 'Assert' },
|
||||
{ key: 'tests', label: 'Tests' },
|
||||
{ key: 'docs', label: 'Docs' },
|
||||
{ key: 'settings', label: 'Settings' }
|
||||
];
|
||||
|
||||
const TAB_PANELS = {
|
||||
params: QueryParams,
|
||||
body: RequestBody,
|
||||
headers: RequestHeaders,
|
||||
auth: Auth,
|
||||
vars: Vars,
|
||||
assert: Assertions,
|
||||
script: Script,
|
||||
tests: Tests,
|
||||
docs: Documentation,
|
||||
settings: Settings
|
||||
};
|
||||
|
||||
const HttpRequestPane = ({ item, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
|
||||
|
||||
const selectTab = (tab) => {
|
||||
dispatch(
|
||||
updateRequestPaneTab({
|
||||
uid: item.uid,
|
||||
requestPaneTab: tab
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const getTabPanel = (tab) => {
|
||||
switch (tab) {
|
||||
case 'params': {
|
||||
return <QueryParams item={item} collection={collection} />;
|
||||
}
|
||||
case 'body': {
|
||||
return <RequestBody item={item} collection={collection} />;
|
||||
}
|
||||
case 'headers': {
|
||||
return <RequestHeaders item={item} collection={collection} />;
|
||||
}
|
||||
case 'auth': {
|
||||
return <Auth item={item} collection={collection} />;
|
||||
}
|
||||
case 'vars': {
|
||||
return <Vars item={item} collection={collection} />;
|
||||
}
|
||||
case 'assert': {
|
||||
return <Assertions item={item} collection={collection} />;
|
||||
}
|
||||
case 'script': {
|
||||
return <Script item={item} collection={collection} />;
|
||||
}
|
||||
case 'tests': {
|
||||
return <Tests item={item} collection={collection} />;
|
||||
}
|
||||
case 'docs': {
|
||||
return <Documentation item={item} collection={collection} />;
|
||||
}
|
||||
case 'settings': {
|
||||
return <Settings item={item} collection={collection} />;
|
||||
}
|
||||
default: {
|
||||
return <div className="mt-4">404 | Not found</div>;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (!activeTabUid) {
|
||||
return <div>Something went wrong</div>;
|
||||
}
|
||||
const bodyModeRef = useRef(null);
|
||||
const initialAutoSelectDone = useRef(false);
|
||||
|
||||
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
|
||||
if (!focusedTab || !focusedTab.uid || !focusedTab.requestPaneTab) {
|
||||
const requestPaneTab = focusedTab?.requestPaneTab;
|
||||
|
||||
const getProperty = useCallback(
|
||||
(key) => (item.draft ? get(item, `draft.${key}`, []) : get(item, key, [])),
|
||||
[item.draft, item]
|
||||
);
|
||||
|
||||
const params = getProperty('request.params');
|
||||
const body = getProperty('request.body');
|
||||
const headers = getProperty('request.headers');
|
||||
const script = getProperty('request.script');
|
||||
const assertions = getProperty('request.assertions');
|
||||
const tests = getProperty('request.tests');
|
||||
const docs = getProperty('request.docs');
|
||||
const requestVars = getProperty('request.vars.req');
|
||||
const responseVars = getProperty('request.vars.res');
|
||||
const auth = getProperty('request.auth');
|
||||
const tags = getProperty('tags');
|
||||
|
||||
const activeCounts = useMemo(() => ({
|
||||
params: params.filter((p) => p.enabled).length,
|
||||
headers: headers.filter((h) => h.enabled).length,
|
||||
assertions: assertions.filter((a) => a.enabled).length,
|
||||
vars: requestVars.filter((r) => r.enabled).length + responseVars.filter((r) => r.enabled).length
|
||||
}), [params, headers, assertions, requestVars, responseVars]);
|
||||
|
||||
const selectTab = useCallback(
|
||||
(tabKey) => {
|
||||
dispatch(updateRequestPaneTab({ uid: item.uid, requestPaneTab: tabKey }));
|
||||
},
|
||||
[dispatch, item.uid]
|
||||
);
|
||||
|
||||
const indicators = useMemo(() => {
|
||||
const hasScriptError = item.preRequestScriptErrorMessage || item.postResponseScriptErrorMessage;
|
||||
const hasTestError = item.testScriptErrorMessage;
|
||||
|
||||
return {
|
||||
params: activeCounts.params > 0 ? <sup className="font-medium">{activeCounts.params}</sup> : null,
|
||||
body: body.mode !== 'none' ? <StatusDot /> : null,
|
||||
headers: activeCounts.headers > 0 ? <sup className="font-medium">{activeCounts.headers}</sup> : null,
|
||||
auth: auth.mode !== 'none' ? <StatusDot /> : null,
|
||||
vars: activeCounts.vars > 0 ? <sup className="font-medium">{activeCounts.vars}</sup> : null,
|
||||
script: (script.req || script.res) ? (hasScriptError ? <StatusDot type="error" /> : <StatusDot />) : null,
|
||||
assert: activeCounts.assertions > 0 ? <sup className="font-medium">{activeCounts.assertions}</sup> : null,
|
||||
tests: tests?.length > 0 ? (hasTestError ? <StatusDot type="error" /> : <StatusDot />) : null,
|
||||
docs: docs?.length > 0 ? <StatusDot /> : null,
|
||||
settings: tags?.length > 0 ? <StatusDot /> : null
|
||||
};
|
||||
}, [activeCounts, body.mode, auth.mode, script, item.preRequestScriptErrorMessage, item.postResponseScriptErrorMessage, item.testScriptErrorMessage, tests, docs, tags]);
|
||||
|
||||
const allTabs = useMemo(
|
||||
() => TAB_CONFIG.map(({ key, label }) => ({ key, label, indicator: indicators[key] })),
|
||||
[indicators]
|
||||
);
|
||||
|
||||
const tabPanel = useMemo(() => {
|
||||
const Component = TAB_PANELS[requestPaneTab];
|
||||
return Component ? <Component item={item} collection={collection} /> : <div className="mt-4">404 | Not found</div>;
|
||||
}, [requestPaneTab, item, collection]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialAutoSelectDone.current && activeCounts.params === 0 && body.mode !== 'none') {
|
||||
selectTab('body');
|
||||
}
|
||||
initialAutoSelectDone.current = true;
|
||||
}, [activeCounts.params, body.mode, selectTab]);
|
||||
|
||||
if (!activeTabUid || !focusedTab?.uid || !requestPaneTab) {
|
||||
return <div className="pb-4 px-4">An error occurred!</div>;
|
||||
}
|
||||
|
||||
const getTabClassname = (tabName) => {
|
||||
return classnames(`tab select-none ${tabName}`, {
|
||||
active: tabName === focusedTab.requestPaneTab
|
||||
});
|
||||
};
|
||||
const isMultipleContentTab = MULTIPLE_CONTENT_TABS.has(requestPaneTab);
|
||||
|
||||
const isMultipleContentTab = ['params', 'script', 'vars', 'auth', 'docs'].includes(focusedTab.requestPaneTab);
|
||||
|
||||
// get the length of active params, headers, asserts and vars as well as the contents of the body, tests and script
|
||||
const getPropertyFromDraftOrRequest = (propertyKey) =>
|
||||
item.draft ? get(item, `draft.${propertyKey}`, []) : get(item, propertyKey, []);
|
||||
const params = getPropertyFromDraftOrRequest('request.params');
|
||||
const body = getPropertyFromDraftOrRequest('request.body');
|
||||
const headers = getPropertyFromDraftOrRequest('request.headers');
|
||||
const script = getPropertyFromDraftOrRequest('request.script');
|
||||
const assertions = getPropertyFromDraftOrRequest('request.assertions');
|
||||
const tests = getPropertyFromDraftOrRequest('request.tests');
|
||||
const docs = getPropertyFromDraftOrRequest('request.docs');
|
||||
const requestVars = getPropertyFromDraftOrRequest('request.vars.req');
|
||||
const auth = getPropertyFromDraftOrRequest('request.auth');
|
||||
const tags = getPropertyFromDraftOrRequest('tags');
|
||||
|
||||
const activeParamsLength = params.filter((param) => param.enabled).length;
|
||||
const activeHeadersLength = headers.filter((header) => header.enabled).length;
|
||||
const activeAssertionsLength = assertions.filter((assertion) => assertion.enabled).length;
|
||||
const activeVarsLength = requestVars.filter((request) => request.enabled).length;
|
||||
|
||||
useEffect(() => {
|
||||
if (activeParamsLength === 0 && body.mode !== 'none') {
|
||||
selectTab('body');
|
||||
}
|
||||
}, []);
|
||||
const rightContent = requestPaneTab === 'body' ? (
|
||||
<div ref={bodyModeRef}>
|
||||
<RequestBodyMode item={item} collection={collection} />
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<StyledWrapper className="flex flex-col h-full relative">
|
||||
<div className="flex flex-wrap items-center tabs" role="tablist">
|
||||
<div className={getTabClassname('params')} role="tab" onClick={() => selectTab('params')}>
|
||||
Params
|
||||
{activeParamsLength > 0 && <sup className="ml-1 font-medium">{activeParamsLength}</sup>}
|
||||
</div>
|
||||
<div className={getTabClassname('body')} role="tab" onClick={() => selectTab('body')}>
|
||||
Body
|
||||
{body.mode !== 'none' && <StatusDot />}
|
||||
</div>
|
||||
<div className={getTabClassname('headers')} role="tab" onClick={() => selectTab('headers')}>
|
||||
Headers
|
||||
{activeHeadersLength > 0 && <sup className="ml-[.125rem] font-medium">{activeHeadersLength}</sup>}
|
||||
</div>
|
||||
<div className={getTabClassname('auth')} role="tab" onClick={() => selectTab('auth')}>
|
||||
Auth
|
||||
{auth.mode !== 'none' && <StatusDot />}
|
||||
</div>
|
||||
<div className={getTabClassname('vars')} role="tab" onClick={() => selectTab('vars')}>
|
||||
Vars
|
||||
{activeVarsLength > 0 && <sup className="ml-1 font-medium">{activeVarsLength}</sup>}
|
||||
</div>
|
||||
<div className={getTabClassname('script')} role="tab" onClick={() => selectTab('script')}>
|
||||
Script
|
||||
{(script.req || script.res) && (
|
||||
item.preRequestScriptErrorMessage || item.postResponseScriptErrorMessage
|
||||
? <StatusDot type="error" />
|
||||
: <StatusDot />
|
||||
)}
|
||||
</div>
|
||||
<div className={getTabClassname('assert')} role="tab" onClick={() => selectTab('assert')}>
|
||||
Assert
|
||||
{activeAssertionsLength > 0 && <sup className="ml-1 font-medium">{activeAssertionsLength}</sup>}
|
||||
</div>
|
||||
<div className={getTabClassname('tests')} role="tab" onClick={() => selectTab('tests')}>
|
||||
Tests
|
||||
{tests && tests.length > 0 && (
|
||||
item.testScriptErrorMessage
|
||||
? <StatusDot type="error" />
|
||||
: <StatusDot />
|
||||
)}
|
||||
</div>
|
||||
<div className={getTabClassname('docs')} role="tab" onClick={() => selectTab('docs')}>
|
||||
Docs
|
||||
{docs && docs.length > 0 && <StatusDot />}
|
||||
</div>
|
||||
<div className={getTabClassname('settings')} role="tab" onClick={() => selectTab('settings')}>
|
||||
Settings
|
||||
{tags && tags.length > 0 && <StatusDot />}
|
||||
</div>
|
||||
{focusedTab.requestPaneTab === 'body' ? (
|
||||
<div className="flex flex-grow justify-end items-center">
|
||||
<RequestBodyMode item={item} collection={collection} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<section
|
||||
className={classnames('flex w-full flex-1', {
|
||||
'mt-5': !isMultipleContentTab
|
||||
})}
|
||||
>
|
||||
<HeightBoundContainer>
|
||||
{getTabPanel(focusedTab.requestPaneTab)}
|
||||
</HeightBoundContainer>
|
||||
<div className="flex flex-col h-full relative">
|
||||
<RequestPaneTabs
|
||||
tabs={allTabs}
|
||||
activeTab={requestPaneTab}
|
||||
onTabSelect={selectTab}
|
||||
rightContent={rightContent}
|
||||
rightContentRef={bodyModeRef}
|
||||
delayedTabs={['body']}
|
||||
/>
|
||||
|
||||
<section className={classnames('flex w-full flex-1', { 'mt-3': !isMultipleContentTab })}>
|
||||
<HeightBoundContainer>{tabPanel}</HeightBoundContainer>
|
||||
</section>
|
||||
</StyledWrapper>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,48 +1,41 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-weight: 500;
|
||||
table-layout: fixed;
|
||||
|
||||
thead,
|
||||
td {
|
||||
border: 1px solid ${(props) => props.theme.table.border};
|
||||
}
|
||||
|
||||
thead {
|
||||
color: ${(props) => props.theme.table.thead.color};
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
user-select: none;
|
||||
}
|
||||
td {
|
||||
padding: 6px 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-add-param {
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
}
|
||||
|
||||
input[type='text'] {
|
||||
width: 100%;
|
||||
border: solid 1px transparent;
|
||||
outline: none !important;
|
||||
color: ${(props) => props.theme.table.input.color};
|
||||
.upload-btn,
|
||||
.clear-file-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: color 0.15s ease;
|
||||
|
||||
&:focus {
|
||||
outline: none !important;
|
||||
border: solid 1px transparent;
|
||||
&:hover {
|
||||
color: ${(props) => props.theme.colors.text.link};
|
||||
}
|
||||
}
|
||||
|
||||
input[type='checkbox'] {
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
top: 1px;
|
||||
.clear-file-btn:hover {
|
||||
color: ${(props) => props.theme.colors.text.danger};
|
||||
}
|
||||
|
||||
.file-value-cell {
|
||||
padding: 4px 0;
|
||||
|
||||
.file-name {
|
||||
font-size: 12px;
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
}
|
||||
|
||||
.value-cell {
|
||||
.flex-1 {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,216 +1,216 @@
|
||||
import React from 'react';
|
||||
import React, { useCallback } 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 { IconUpload, IconX, IconFile } from '@tabler/icons';
|
||||
import {
|
||||
addMultipartFormParam,
|
||||
updateMultipartFormParam,
|
||||
deleteMultipartFormParam,
|
||||
moveMultipartFormParam
|
||||
moveMultipartFormParam,
|
||||
setMultipartFormParams
|
||||
} from 'providers/ReduxStore/slices/collections';
|
||||
import { browseFiles } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import MultiLineEditor from 'components/MultiLineEditor';
|
||||
import SingleLineEditor from 'components/SingleLineEditor';
|
||||
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import EditableTable from 'components/EditableTable';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import FilePickerEditor from 'components/FilePickerEditor';
|
||||
import Table from 'components/Table/index';
|
||||
import ReorderTable from 'components/ReorderTable/index';
|
||||
import path from 'utils/common/path';
|
||||
import { isWindowsOS } from 'utils/common/platform';
|
||||
|
||||
const MultipartFormParams = ({ item, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
const params = item.draft ? get(item, 'draft.request.body.multipartForm') : get(item, 'request.body.multipartForm');
|
||||
|
||||
const addParam = () => {
|
||||
dispatch(
|
||||
addMultipartFormParam({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
type: 'text',
|
||||
value: ''
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const addFile = () => {
|
||||
dispatch(
|
||||
addMultipartFormParam({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
type: 'file',
|
||||
value: []
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
|
||||
const handleRun = () => dispatch(sendRequest(item, collection.uid));
|
||||
const handleParamChange = (e, _param, type) => {
|
||||
const param = cloneDeep(_param);
|
||||
switch (type) {
|
||||
case 'name': {
|
||||
param.name = e.target.value;
|
||||
break;
|
||||
}
|
||||
case 'value': {
|
||||
param.value = e.target.value;
|
||||
break;
|
||||
}
|
||||
case 'contentType': {
|
||||
param.contentType = e.target.value;
|
||||
break;
|
||||
}
|
||||
case 'enabled': {
|
||||
param.enabled = e.target.checked;
|
||||
break;
|
||||
|
||||
const handleParamsChange = useCallback((updatedParams) => {
|
||||
dispatch(setMultipartFormParams({
|
||||
collectionUid: collection.uid,
|
||||
itemUid: item.uid,
|
||||
params: updatedParams
|
||||
}));
|
||||
}, [dispatch, collection.uid, item.uid]);
|
||||
|
||||
const handleParamDrag = useCallback(({ updateReorderedItem }) => {
|
||||
dispatch(moveMultipartFormParam({
|
||||
collectionUid: collection.uid,
|
||||
itemUid: item.uid,
|
||||
updateReorderedItem
|
||||
}));
|
||||
}, [dispatch, collection.uid, item.uid]);
|
||||
|
||||
const handleBrowseFiles = useCallback((row, onChange) => {
|
||||
dispatch(browseFiles())
|
||||
.then((filePaths) => {
|
||||
const processedPaths = filePaths.map((filePath) => {
|
||||
const collectionDir = collection.pathname;
|
||||
if (filePath.startsWith(collectionDir)) {
|
||||
return path.relative(collectionDir, filePath);
|
||||
}
|
||||
return filePath;
|
||||
});
|
||||
|
||||
const currentParams = item.draft
|
||||
? get(item, 'draft.request.body.multipartForm')
|
||||
: get(item, 'request.body.multipartForm');
|
||||
const updatedParams = (currentParams || []).map((p) => {
|
||||
if (p.uid === row.uid) {
|
||||
return { ...p, type: 'file', value: processedPaths };
|
||||
}
|
||||
return p;
|
||||
});
|
||||
handleParamsChange(updatedParams);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
});
|
||||
}, [dispatch, collection.pathname, item, handleParamsChange]);
|
||||
|
||||
const handleClearFile = useCallback((row) => {
|
||||
const currentParams = params || [];
|
||||
const updatedParams = currentParams.map((p) => {
|
||||
if (p.uid === row.uid) {
|
||||
return { ...p, type: 'text', value: '' };
|
||||
}
|
||||
return p;
|
||||
});
|
||||
handleParamsChange(updatedParams);
|
||||
}, [params, handleParamsChange]);
|
||||
|
||||
const handleValueChange = useCallback((row, newValue, onChange) => {
|
||||
const currentParams = params || [];
|
||||
const existingParam = currentParams.find((p) => p.uid === row.uid);
|
||||
if (existingParam) {
|
||||
const updatedParams = currentParams.map((p) => {
|
||||
if (p.uid === row.uid) {
|
||||
return { ...p, type: 'text', value: newValue };
|
||||
}
|
||||
return p;
|
||||
});
|
||||
handleParamsChange(updatedParams);
|
||||
} else {
|
||||
onChange(newValue);
|
||||
}
|
||||
dispatch(
|
||||
updateMultipartFormParam({
|
||||
param: param,
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid
|
||||
})
|
||||
);
|
||||
}, [params, handleParamsChange]);
|
||||
|
||||
const getFileName = (filePaths) => {
|
||||
if (!filePaths || (Array.isArray(filePaths) && filePaths.length === 0)) {
|
||||
return null;
|
||||
}
|
||||
const paths = Array.isArray(filePaths) ? filePaths : [filePaths];
|
||||
const validPaths = paths.filter((v) => v != null && v !== '');
|
||||
if (validPaths.length === 0) return null;
|
||||
|
||||
const separator = isWindowsOS() ? '\\' : '/';
|
||||
if (validPaths.length === 1) {
|
||||
return validPaths[0].split(separator).pop();
|
||||
}
|
||||
return `${validPaths.length} file(s)`;
|
||||
};
|
||||
|
||||
const handleRemoveParams = (param) => {
|
||||
dispatch(
|
||||
deleteMultipartFormParam({
|
||||
paramUid: param.uid,
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid
|
||||
})
|
||||
);
|
||||
};
|
||||
const columns = [
|
||||
{
|
||||
key: 'name',
|
||||
name: 'Key',
|
||||
isKeyField: true,
|
||||
placeholder: 'Key',
|
||||
width: '30%'
|
||||
},
|
||||
{
|
||||
key: 'value',
|
||||
name: 'Value',
|
||||
placeholder: 'Value',
|
||||
width: '35%',
|
||||
render: ({ row, value, onChange, isLastEmptyRow }) => {
|
||||
const isFile = row.type === 'file';
|
||||
const fileName = isFile ? getFileName(value) : null;
|
||||
const hasTextValue = !isFile && value && value.length > 0;
|
||||
|
||||
const handleParamDrag = ({ updateReorderedItem }) => {
|
||||
dispatch(
|
||||
moveMultipartFormParam({
|
||||
collectionUid: collection.uid,
|
||||
itemUid: item.uid,
|
||||
updateReorderedItem
|
||||
})
|
||||
);
|
||||
if (fileName) {
|
||||
return (
|
||||
<div className="flex items-center file-value-cell">
|
||||
<IconFile size={16} className="text-muted mr-1" />
|
||||
<span className="file-name flex-1 truncate" title={Array.isArray(value) ? value.join(', ') : value}>
|
||||
{fileName}
|
||||
</span>
|
||||
<button
|
||||
className="clear-file-btn ml-1"
|
||||
onClick={() => handleClearFile(row)}
|
||||
title="Remove file"
|
||||
>
|
||||
<IconX size={16} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center value-cell">
|
||||
<div className="flex-1">
|
||||
<MultiLineEditor
|
||||
onSave={onSave}
|
||||
theme={storedTheme}
|
||||
value={value || ''}
|
||||
onChange={(newValue) => handleValueChange(row, newValue, onChange)}
|
||||
onRun={handleRun}
|
||||
allowNewlines={true}
|
||||
collection={collection}
|
||||
item={item}
|
||||
placeholder={isLastEmptyRow ? 'Value' : ''}
|
||||
/>
|
||||
</div>
|
||||
{!hasTextValue && !isLastEmptyRow && (
|
||||
<button
|
||||
className="upload-btn ml-1"
|
||||
onClick={() => handleBrowseFiles(row, onChange)}
|
||||
title="Select file"
|
||||
>
|
||||
<IconUpload size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'contentType',
|
||||
name: 'Content-Type',
|
||||
placeholder: 'Auto',
|
||||
width: '20%',
|
||||
render: ({ row, value, onChange, isLastEmptyRow }) => (
|
||||
<SingleLineEditor
|
||||
onSave={onSave}
|
||||
theme={storedTheme}
|
||||
placeholder={isLastEmptyRow ? 'Auto' : ''}
|
||||
value={value || ''}
|
||||
onChange={onChange}
|
||||
onRun={handleRun}
|
||||
collection={collection}
|
||||
/>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
const defaultRow = {
|
||||
name: '',
|
||||
value: '',
|
||||
contentType: '',
|
||||
type: 'text'
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full">
|
||||
<Table
|
||||
headers={[
|
||||
{ name: 'Key', accessor: 'key', width: '29%' },
|
||||
{ name: 'Value', accessor: 'value', width: '29%' },
|
||||
{ name: 'Content-Type', accessor: 'content-type', width: '28%' },
|
||||
{ name: '', accessor: '', width: '14%' }
|
||||
]}
|
||||
>
|
||||
<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">
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
value={param.name}
|
||||
className="mousetrap"
|
||||
onChange={(e) => handleParamChange(e, param, 'name')}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
{param.type === 'file' ? (
|
||||
<FilePickerEditor
|
||||
value={param.value}
|
||||
onChange={(newValue) =>
|
||||
handleParamChange(
|
||||
{
|
||||
target: {
|
||||
value: newValue
|
||||
}
|
||||
},
|
||||
param,
|
||||
'value'
|
||||
)}
|
||||
collection={collection}
|
||||
/>
|
||||
) : (
|
||||
<MultiLineEditor
|
||||
onSave={onSave}
|
||||
theme={storedTheme}
|
||||
value={param.value}
|
||||
onChange={(newValue) =>
|
||||
handleParamChange(
|
||||
{
|
||||
target: {
|
||||
value: newValue
|
||||
}
|
||||
},
|
||||
param,
|
||||
'value'
|
||||
)}
|
||||
onRun={handleRun}
|
||||
allowNewlines={true}
|
||||
collection={collection}
|
||||
item={item}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<MultiLineEditor
|
||||
onSave={onSave}
|
||||
theme={storedTheme}
|
||||
placeholder="Auto"
|
||||
value={param.contentType}
|
||||
onChange={(newValue) =>
|
||||
handleParamChange(
|
||||
{
|
||||
target: {
|
||||
value: newValue
|
||||
}
|
||||
},
|
||||
param,
|
||||
'contentType'
|
||||
)}
|
||||
onRun={handleRun}
|
||||
collection={collection}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={param.enabled}
|
||||
tabIndex="-1"
|
||||
className="mr-3 mousetrap"
|
||||
onChange={(e) => handleParamChange(e, param, 'enabled')}
|
||||
/>
|
||||
<button tabIndex="-1" onClick={() => handleRemoveParams(param)}>
|
||||
<IconTrash strokeWidth={1.5} size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
</ReorderTable>
|
||||
</Table>
|
||||
<div>
|
||||
<button className="btn-add-param text-link pr-2 pt-3 mt-2 select-none" onClick={addParam}>
|
||||
+ Add Param
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<button className="btn-add-param text-link pr-2 pt-3 select-none" onClick={addFile}>
|
||||
+ Add File
|
||||
</button>
|
||||
</div>
|
||||
<EditableTable
|
||||
columns={columns}
|
||||
rows={params || []}
|
||||
onChange={handleParamsChange}
|
||||
defaultRow={defaultRow}
|
||||
reorderable={true}
|
||||
onReorder={handleParamDrag}
|
||||
/>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default MultipartFormParams;
|
||||
|
||||
@@ -1,24 +1,17 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import InfoTip from 'components/InfoTip';
|
||||
import { IconTrash } from '@tabler/icons';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import {
|
||||
addQueryParam,
|
||||
updateQueryParam,
|
||||
deleteQueryParam,
|
||||
moveQueryParam,
|
||||
updatePathParam,
|
||||
setQueryParams
|
||||
} from 'providers/ReduxStore/slices/collections';
|
||||
import MultiLineEditor from 'components/MultiLineEditor';
|
||||
import { saveRequest, sendRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
|
||||
import EditableTable from 'components/EditableTable';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import Table from 'components/Table/index';
|
||||
import ReorderTable from 'components/ReorderTable';
|
||||
import BulkEditor from '../../BulkEditor';
|
||||
|
||||
const QueryParams = ({ item, collection }) => {
|
||||
@@ -30,100 +23,100 @@ const QueryParams = ({ item, collection }) => {
|
||||
|
||||
const [isBulkEditMode, setIsBulkEditMode] = useState(false);
|
||||
|
||||
const handleAddQueryParam = () => {
|
||||
dispatch(
|
||||
addQueryParam({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
|
||||
const handleRun = () => dispatch(sendRequest(item, collection.uid));
|
||||
|
||||
const handleQueryParamChange = (e, data, key) => {
|
||||
let value;
|
||||
const handleQueryParamsChange = useCallback((updatedParams) => {
|
||||
const paramsWithType = updatedParams.map((p) => ({ ...p, type: 'query' }));
|
||||
dispatch(setQueryParams({
|
||||
collectionUid: collection.uid,
|
||||
itemUid: item.uid,
|
||||
params: paramsWithType
|
||||
}));
|
||||
}, [dispatch, collection.uid, item.uid]);
|
||||
|
||||
switch (key) {
|
||||
case 'name': {
|
||||
value = e.target.value;
|
||||
break;
|
||||
}
|
||||
case 'value': {
|
||||
value = e.target.value;
|
||||
break;
|
||||
}
|
||||
case 'enabled': {
|
||||
value = e.target.checked;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let queryParam = cloneDeep(data);
|
||||
|
||||
if (queryParam[key] === value) {
|
||||
return;
|
||||
}
|
||||
|
||||
queryParam[key] = value;
|
||||
|
||||
dispatch(
|
||||
updateQueryParam({
|
||||
queryParam,
|
||||
const handlePathParamChange = useCallback((rowUid, key, value) => {
|
||||
const pathParam = pathParams.find((p) => p.uid === rowUid);
|
||||
if (pathParam) {
|
||||
dispatch(updatePathParam({
|
||||
pathParam: { ...pathParam, [key]: value },
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const handlePathParamChange = (e, data) => {
|
||||
let value = e.target.value;
|
||||
|
||||
let pathParam = cloneDeep(data);
|
||||
|
||||
if (pathParam['value'] === value) {
|
||||
return;
|
||||
}));
|
||||
}
|
||||
}, [dispatch, pathParams, item.uid, collection.uid]);
|
||||
|
||||
pathParam['value'] = value;
|
||||
|
||||
dispatch(
|
||||
updatePathParam({
|
||||
pathParam,
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const handleRemoveQueryParam = (param) => {
|
||||
dispatch(
|
||||
deleteQueryParam({
|
||||
paramUid: param.uid,
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const handleQueryParamDrag = ({ updateReorderedItem }) => {
|
||||
dispatch(
|
||||
moveQueryParam({
|
||||
collectionUid: collection.uid,
|
||||
itemUid: item.uid,
|
||||
updateReorderedItem
|
||||
})
|
||||
);
|
||||
};
|
||||
const handleQueryParamDrag = useCallback(({ updateReorderedItem }) => {
|
||||
dispatch(moveQueryParam({
|
||||
collectionUid: collection.uid,
|
||||
itemUid: item.uid,
|
||||
updateReorderedItem
|
||||
}));
|
||||
}, [dispatch, collection.uid, item.uid]);
|
||||
|
||||
const toggleBulkEditMode = () => {
|
||||
setIsBulkEditMode(!isBulkEditMode);
|
||||
};
|
||||
|
||||
const handleBulkParamsChange = (newParams) => {
|
||||
const paramsWithType = newParams.map((item) => ({ ...item, type: 'query' }));
|
||||
dispatch(setQueryParams({ collectionUid: collection.uid, itemUid: item.uid, params: paramsWithType }));
|
||||
const queryColumns = [
|
||||
{
|
||||
key: 'name',
|
||||
name: 'Name',
|
||||
isKeyField: true,
|
||||
placeholder: 'Name',
|
||||
width: '30%'
|
||||
},
|
||||
{
|
||||
key: 'value',
|
||||
name: 'Value',
|
||||
placeholder: 'Value',
|
||||
render: ({ row, value, onChange, isLastEmptyRow }) => (
|
||||
<MultiLineEditor
|
||||
value={value || ''}
|
||||
theme={storedTheme}
|
||||
onSave={onSave}
|
||||
onChange={onChange}
|
||||
onRun={handleRun}
|
||||
collection={collection}
|
||||
item={item}
|
||||
variablesAutocomplete={true}
|
||||
placeholder={isLastEmptyRow ? 'Value' : ''}
|
||||
/>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
const pathColumns = [
|
||||
{
|
||||
key: 'name',
|
||||
name: 'Name',
|
||||
isKeyField: true,
|
||||
width: '30%',
|
||||
readOnly: true
|
||||
},
|
||||
{
|
||||
key: 'value',
|
||||
name: 'Value',
|
||||
placeholder: 'Value',
|
||||
render: ({ row, value, onChange }) => (
|
||||
<MultiLineEditor
|
||||
value={value || ''}
|
||||
theme={storedTheme}
|
||||
onSave={onSave}
|
||||
onChange={(newValue) => handlePathParamChange(row.uid, 'value', newValue)}
|
||||
onRun={handleRun}
|
||||
collection={collection}
|
||||
item={item}
|
||||
/>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
const defaultQueryRow = {
|
||||
name: '',
|
||||
value: '',
|
||||
description: '',
|
||||
type: 'query'
|
||||
};
|
||||
|
||||
if (isBulkEditMode) {
|
||||
@@ -131,7 +124,7 @@ const QueryParams = ({ item, collection }) => {
|
||||
<StyledWrapper className="w-full mt-3">
|
||||
<BulkEditor
|
||||
params={queryParams}
|
||||
onChange={handleBulkParamsChange}
|
||||
onChange={handleQueryParamsChange}
|
||||
onToggle={toggleBulkEditMode}
|
||||
onSave={onSave}
|
||||
onRun={handleRun}
|
||||
@@ -144,69 +137,20 @@ const QueryParams = ({ item, collection }) => {
|
||||
<StyledWrapper className="w-full flex flex-col">
|
||||
<div className="flex-1 mt-2">
|
||||
<div className="mb-1 title text-xs">Query</div>
|
||||
<Table
|
||||
headers={[
|
||||
{ name: 'Name', accessor: 'name', width: '31%' },
|
||||
{ name: 'Value', accessor: 'path', width: '56%' },
|
||||
{ name: '', accessor: '', width: '13%' }
|
||||
]}
|
||||
>
|
||||
<ReorderTable updateReorderedItem={handleQueryParamDrag}>
|
||||
{queryParams && queryParams.length
|
||||
? queryParams.map((param, index) => (
|
||||
<tr key={param.uid} data-uid={param.uid}>
|
||||
<td className="flex relative">
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
value={param.name}
|
||||
className="mousetrap"
|
||||
onChange={(e) => handleQueryParamChange(e, param, 'name')}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<MultiLineEditor
|
||||
value={param.value}
|
||||
theme={storedTheme}
|
||||
onSave={onSave}
|
||||
onChange={(newValue) => handleQueryParamChange({ target: { value: newValue } }, param, 'value')}
|
||||
onRun={handleRun}
|
||||
collection={collection}
|
||||
item={item}
|
||||
variablesAutocomplete={true}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex items-center justify-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={param.enabled}
|
||||
tabIndex="-1"
|
||||
className="mr-3 mousetrap"
|
||||
onChange={(e) => handleQueryParamChange(e, param, 'enabled')}
|
||||
/>
|
||||
<button tabIndex="-1" onClick={() => handleRemoveQueryParam(param)}>
|
||||
<IconTrash strokeWidth={1.5} size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
: null}
|
||||
</ReorderTable>
|
||||
</Table>
|
||||
|
||||
<div className="flex justify-between mt-2">
|
||||
<button className="btn-action text-link pr-2 py-3 select-none" onClick={handleAddQueryParam}>
|
||||
+ <span>Add Param</span>
|
||||
</button>
|
||||
<EditableTable
|
||||
columns={queryColumns}
|
||||
rows={queryParams || []}
|
||||
onChange={handleQueryParamsChange}
|
||||
defaultRow={defaultQueryRow}
|
||||
reorderable={true}
|
||||
onReorder={handleQueryParamDrag}
|
||||
/>
|
||||
<div className="flex justify-end mt-2">
|
||||
<button className="btn-action text-link select-none" onClick={toggleBulkEditMode}>
|
||||
Bulk Edit
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-2 title text-xs flex items-stretch">
|
||||
<span>Path</span>
|
||||
<InfoTip infotipId="path-param-InfoTip">
|
||||
@@ -220,58 +164,22 @@ const QueryParams = ({ item, collection }) => {
|
||||
</div>
|
||||
</InfoTip>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<td>Name</td>
|
||||
<td>Value</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pathParams && pathParams.length
|
||||
? pathParams.map((path, index) => {
|
||||
return (
|
||||
<tr key={path.uid}>
|
||||
<td>
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
value={path.name}
|
||||
className="mousetrap"
|
||||
readOnly={true}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<MultiLineEditor
|
||||
value={path.value}
|
||||
theme={storedTheme}
|
||||
onSave={onSave}
|
||||
onChange={(newValue) =>
|
||||
handlePathParamChange(
|
||||
{
|
||||
target: {
|
||||
value: newValue
|
||||
}
|
||||
},
|
||||
path
|
||||
)}
|
||||
onRun={handleRun}
|
||||
collection={collection}
|
||||
item={item}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
</tbody>
|
||||
</table>
|
||||
{!(pathParams && pathParams.length) ? <div className="title pr-2 py-3 mt-2 text-xs"></div> : null}
|
||||
{pathParams && pathParams.length > 0 ? (
|
||||
<EditableTable
|
||||
columns={pathColumns}
|
||||
rows={pathParams}
|
||||
onChange={() => {}}
|
||||
defaultRow={{}}
|
||||
showCheckbox={false}
|
||||
showDelete={false}
|
||||
showAddRow={false}
|
||||
/>
|
||||
) : (
|
||||
<div className="title pr-2 py-3 mt-2 text-xs"></div>
|
||||
)}
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default QueryParams;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
height: 2.3rem;
|
||||
height: 2.1rem;
|
||||
border: ${(props) => props.theme.requestTabPanel.url.border};
|
||||
border-radius: ${(props) => props.theme.border.radius.base};
|
||||
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { requestUrlChanged, updateRequestMethod } from 'providers/ReduxStore/slices/collections';
|
||||
import { cancelRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import {
|
||||
requestUrlChanged,
|
||||
updateRequestMethod,
|
||||
setRequestHeaders,
|
||||
updateRequestBodyMode,
|
||||
updateRequestBody,
|
||||
updateRequestGraphqlQuery,
|
||||
updateRequestGraphqlVariables,
|
||||
updateRequestAuthMode,
|
||||
updateAuth
|
||||
} from 'providers/ReduxStore/slices/collections';
|
||||
import { saveRequest, cancelRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { getRequestFromCurlCommand } from 'utils/curl';
|
||||
import HttpMethodSelector from './HttpMethodSelector';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { IconDeviceFloppy, IconArrowRight, IconCode, IconSquareRoundedX } from '@tabler/icons';
|
||||
@@ -81,12 +92,289 @@ const QueryUrl = ({ item, collection, handleRun }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleGraphqlPaste = useCallback((event) => {
|
||||
if (item.type !== 'graphql-request') {
|
||||
return;
|
||||
}
|
||||
|
||||
const clipboardData = event.clipboardData || window.clipboardData;
|
||||
const pastedData = clipboardData.getData('Text');
|
||||
|
||||
const curlCommandRegex = /^\s*curl\s/i;
|
||||
if (!curlCommandRegex.test(pastedData)) {
|
||||
toast.error('Invalid cURL command');
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
try {
|
||||
const request = getRequestFromCurlCommand(pastedData, 'graphql-request');
|
||||
if (!request || !request.url) {
|
||||
toast.error('Invalid cURL command');
|
||||
return;
|
||||
}
|
||||
// Update URL
|
||||
dispatch(requestUrlChanged({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
url: request.url
|
||||
}));
|
||||
|
||||
// Update method
|
||||
dispatch(updateRequestMethod({
|
||||
method: request.method.toUpperCase(), // Convert to uppercase
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid
|
||||
}));
|
||||
|
||||
// Update headers
|
||||
if (request.headers && request.headers.length > 0) {
|
||||
dispatch(setRequestHeaders({
|
||||
collectionUid: collection.uid,
|
||||
itemUid: item.uid,
|
||||
headers: request.headers
|
||||
}));
|
||||
}
|
||||
|
||||
// Update body
|
||||
if (request.body) {
|
||||
const bodyMode = request.body.mode;
|
||||
if (bodyMode === 'graphql') {
|
||||
dispatch(updateRequestGraphqlQuery({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
query: request.body.graphql.query
|
||||
}));
|
||||
let variables = request.body.graphql.variables;
|
||||
try {
|
||||
variables = JSON.parse(variables);
|
||||
} catch (error) {
|
||||
// Keep variables as-is if JSON parsing fails
|
||||
}
|
||||
dispatch(updateRequestGraphqlVariables({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
variables: variables
|
||||
}));
|
||||
}
|
||||
|
||||
toast.success('GraphQL query imported successfully');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing cURL command:', error);
|
||||
toast.error('Failed to parse GraphQL query');
|
||||
}
|
||||
}, [dispatch, item.uid, collection.uid]);
|
||||
|
||||
const handleHttpPaste = useCallback((event) => {
|
||||
// Only enable curl paste detection for HTTP requests
|
||||
if (item.type !== 'http-request') {
|
||||
return;
|
||||
}
|
||||
|
||||
const clipboardData = event.clipboardData || window.clipboardData;
|
||||
const pastedData = clipboardData.getData('Text');
|
||||
|
||||
// Check if pasted data looks like a cURL command
|
||||
const curlCommandRegex = /^\s*curl\s/i;
|
||||
if (!curlCommandRegex.test(pastedData)) {
|
||||
// Not a curl command, allow normal paste behavior
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent the default paste behavior
|
||||
event.preventDefault();
|
||||
|
||||
try {
|
||||
// Parse the curl command
|
||||
const request = getRequestFromCurlCommand(pastedData);
|
||||
if (!request || !request.url) {
|
||||
toast.error('Invalid cURL command');
|
||||
return;
|
||||
}
|
||||
|
||||
// Update URL
|
||||
dispatch(
|
||||
requestUrlChanged({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
url: request.url
|
||||
})
|
||||
);
|
||||
|
||||
// Update method
|
||||
if (request.method) {
|
||||
dispatch(
|
||||
updateRequestMethod({
|
||||
method: request.method.toUpperCase(), // Convert to uppercase
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Update headers
|
||||
if (request.headers && request.headers.length > 0) {
|
||||
dispatch(
|
||||
setRequestHeaders({
|
||||
collectionUid: collection.uid,
|
||||
itemUid: item.uid,
|
||||
headers: request.headers
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Update body
|
||||
if (request.body) {
|
||||
const bodyMode = request.body.mode;
|
||||
|
||||
// Set body mode first
|
||||
dispatch(
|
||||
updateRequestBodyMode({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
mode: bodyMode
|
||||
})
|
||||
);
|
||||
|
||||
// Set body content based on mode
|
||||
if (bodyMode === 'json' && request.body.json) {
|
||||
dispatch(
|
||||
updateRequestBody({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
content: request.body.json
|
||||
})
|
||||
);
|
||||
} else if (bodyMode === 'text' && request.body.text) {
|
||||
dispatch(
|
||||
updateRequestBody({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
content: request.body.text
|
||||
})
|
||||
);
|
||||
} else if (bodyMode === 'xml' && request.body.xml) {
|
||||
dispatch(
|
||||
updateRequestBody({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
content: request.body.xml
|
||||
})
|
||||
);
|
||||
} else if (bodyMode === 'graphql' && request.body.graphql) {
|
||||
if (request.body.graphql.query) {
|
||||
dispatch(
|
||||
updateRequestGraphqlQuery({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
query: request.body.graphql.query
|
||||
})
|
||||
);
|
||||
}
|
||||
if (request.body.graphql.variables) {
|
||||
dispatch(
|
||||
updateRequestGraphqlVariables({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
variables: request.body.graphql.variables
|
||||
})
|
||||
);
|
||||
}
|
||||
} else if (bodyMode === 'formUrlEncoded' && request.body.formUrlEncoded) {
|
||||
// For formUrlEncoded, we need to set each param individually
|
||||
// This is a limitation - we'd need to clear existing params first
|
||||
// For now, we'll set the body mode and the user can manually adjust
|
||||
// TODO: Implement proper formUrlEncoded param setting
|
||||
} else if (bodyMode === 'multipartForm' && request.body.multipartForm) {
|
||||
// For multipartForm, similar limitation
|
||||
// TODO: Implement proper multipartForm param setting
|
||||
}
|
||||
}
|
||||
|
||||
// Update auth
|
||||
if (request.auth) {
|
||||
const authMode = request.auth.mode;
|
||||
if (authMode) {
|
||||
dispatch(
|
||||
updateRequestAuthMode({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
mode: authMode
|
||||
})
|
||||
);
|
||||
|
||||
// Set auth content based on mode
|
||||
if (request.auth.basic) {
|
||||
dispatch(
|
||||
updateAuth({
|
||||
mode: 'basic',
|
||||
collectionUid: collection.uid,
|
||||
itemUid: item.uid,
|
||||
content: request.auth.basic
|
||||
})
|
||||
);
|
||||
} else if (request.auth.bearer) {
|
||||
dispatch(
|
||||
updateAuth({
|
||||
mode: 'bearer',
|
||||
collectionUid: collection.uid,
|
||||
itemUid: item.uid,
|
||||
content: request.auth.bearer
|
||||
})
|
||||
);
|
||||
} else if (request.auth.digest) {
|
||||
dispatch(
|
||||
updateAuth({
|
||||
mode: 'digest',
|
||||
collectionUid: collection.uid,
|
||||
itemUid: item.uid,
|
||||
content: request.auth.digest
|
||||
})
|
||||
);
|
||||
} else if (request.auth.ntlm) {
|
||||
dispatch(
|
||||
updateAuth({
|
||||
mode: 'ntlm',
|
||||
collectionUid: collection.uid,
|
||||
itemUid: item.uid,
|
||||
content: request.auth.ntlm
|
||||
})
|
||||
);
|
||||
} else if (request.auth.awsv4) {
|
||||
dispatch(
|
||||
updateAuth({
|
||||
mode: 'awsv4',
|
||||
collectionUid: collection.uid,
|
||||
itemUid: item.uid,
|
||||
content: request.auth.awsv4
|
||||
})
|
||||
);
|
||||
} else if (request.auth.apikey) {
|
||||
dispatch(
|
||||
updateAuth({
|
||||
mode: 'apikey',
|
||||
collectionUid: collection.uid,
|
||||
itemUid: item.uid,
|
||||
content: request.auth.apikey
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
toast.success('cURL command imported successfully');
|
||||
} catch (error) {
|
||||
console.error('Error parsing cURL command:', error);
|
||||
toast.error('Failed to parse cURL command');
|
||||
}
|
||||
},
|
||||
[dispatch, item.uid, item.type, collection.uid]
|
||||
);
|
||||
const handleCancelRequest = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dispatch(cancelRequest(item.cancelTokenUid, item, collection));
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper className="flex items-center">
|
||||
<div className="flex flex-1 items-center h-full method-selector-container">
|
||||
@@ -110,10 +398,12 @@ const QueryUrl = ({ item, collection, handleRun }) => {
|
||||
<SingleLineEditor
|
||||
ref={editorRef}
|
||||
value={url}
|
||||
placeholder="Enter URL or paste a cURL request"
|
||||
onSave={(finalValue) => onSave(finalValue)}
|
||||
theme={storedTheme}
|
||||
onChange={(newValue) => onUrlChange(newValue)}
|
||||
onRun={handleRun}
|
||||
onPaste={item.type === 'http-request' ? handleHttpPaste : item.type === 'graphql-request' ? handleGraphqlPaste : null}
|
||||
collection={collection}
|
||||
highlightPathParams={true}
|
||||
item={item}
|
||||
@@ -127,7 +417,7 @@ const QueryUrl = ({ item, collection, handleRun }) => {
|
||||
handleGenerateCode(e);
|
||||
}}
|
||||
>
|
||||
<IconCode color={theme.requestTabs.icon.color} strokeWidth={1.5} size={22} className="cursor-pointer" />
|
||||
<IconCode color={theme.requestTabs.icon.color} strokeWidth={1.5} size={20} className="cursor-pointer" />
|
||||
<span className="infotiptext text-xs">Generate Code</span>
|
||||
</div>
|
||||
<div
|
||||
@@ -142,7 +432,7 @@ const QueryUrl = ({ item, collection, handleRun }) => {
|
||||
<IconDeviceFloppy
|
||||
color={hasChanges ? theme.colors.text.yellow : theme.requestTabs.icon.color}
|
||||
strokeWidth={1.5}
|
||||
size={22}
|
||||
size={20}
|
||||
className={`${hasChanges ? 'cursor-pointer' : 'cursor-default'}`}
|
||||
/>
|
||||
<span className="infotiptext text-xs">
|
||||
@@ -153,7 +443,7 @@ const QueryUrl = ({ item, collection, handleRun }) => {
|
||||
<IconSquareRoundedX
|
||||
color={theme.requestTabPanel.url.iconDanger}
|
||||
strokeWidth={1.5}
|
||||
size={22}
|
||||
size={20}
|
||||
data-testid="cancel-request-icon"
|
||||
onClick={handleCancelRequest}
|
||||
/>
|
||||
@@ -161,7 +451,7 @@ const QueryUrl = ({ item, collection, handleRun }) => {
|
||||
<IconArrowRight
|
||||
color={theme.requestTabPanel.url.icon}
|
||||
strokeWidth={1.5}
|
||||
size={22}
|
||||
size={20}
|
||||
data-testid="send-arrow-icon"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -2,6 +2,7 @@ import styled from 'styled-components';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
min-width: 125px;
|
||||
|
||||
.body-mode-selector {
|
||||
background: transparent;
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import { IconTrash } from '@tabler/icons';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { addRequestHeader, updateRequestHeader, deleteRequestHeader, moveRequestHeader, setRequestHeaders } from 'providers/ReduxStore/slices/collections';
|
||||
import { moveRequestHeader, setRequestHeaders } from 'providers/ReduxStore/slices/collections';
|
||||
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import SingleLineEditor from 'components/SingleLineEditor';
|
||||
import EditableTable from 'components/EditableTable';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { headers as StandardHTTPHeaders } from 'know-your-http-well';
|
||||
import { MimeTypes } from 'utils/codemirror/autocompleteConstants';
|
||||
import Table from 'components/Table/index';
|
||||
import ReorderTable from 'components/ReorderTable/index';
|
||||
import BulkEditor from '../../BulkEditor';
|
||||
|
||||
const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header);
|
||||
@@ -20,74 +17,76 @@ const RequestHeaders = ({ item, collection, addHeaderText }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
const headers = item.draft ? get(item, 'draft.request.headers') : get(item, 'request.headers');
|
||||
|
||||
const [isBulkEditMode, setIsBulkEditMode] = useState(false);
|
||||
|
||||
const addHeader = () => {
|
||||
dispatch(
|
||||
addRequestHeader({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
|
||||
const handleRun = () => dispatch(sendRequest(item, collection.uid));
|
||||
const handleHeaderValueChange = (e, _header, type) => {
|
||||
const header = cloneDeep(_header);
|
||||
|
||||
switch (type) {
|
||||
case 'name': {
|
||||
// Strip newlines from header keys
|
||||
header.name = e.target.value.replace(/[\r\n]/g, '');
|
||||
break;
|
||||
}
|
||||
case 'value': {
|
||||
header.value = e.target.value;
|
||||
break;
|
||||
}
|
||||
case 'enabled': {
|
||||
header.enabled = e.target.checked;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const handleHeadersChange = useCallback((updatedHeaders) => {
|
||||
dispatch(setRequestHeaders({
|
||||
collectionUid: collection.uid,
|
||||
itemUid: item.uid,
|
||||
headers: updatedHeaders
|
||||
}));
|
||||
}, [dispatch, collection.uid, item.uid]);
|
||||
|
||||
dispatch(
|
||||
updateRequestHeader({
|
||||
header: header,
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const handleRemoveHeader = (header) => {
|
||||
dispatch(
|
||||
deleteRequestHeader({
|
||||
headerUid: header.uid,
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const handleHeaderDrag = ({ updateReorderedItem }) => {
|
||||
dispatch(
|
||||
moveRequestHeader({
|
||||
collectionUid: collection.uid,
|
||||
itemUid: item.uid,
|
||||
updateReorderedItem
|
||||
})
|
||||
);
|
||||
};
|
||||
const handleHeaderDrag = useCallback(({ updateReorderedItem }) => {
|
||||
dispatch(moveRequestHeader({
|
||||
collectionUid: collection.uid,
|
||||
itemUid: item.uid,
|
||||
updateReorderedItem
|
||||
}));
|
||||
}, [dispatch, collection.uid, item.uid]);
|
||||
|
||||
const toggleBulkEditMode = () => {
|
||||
setIsBulkEditMode(!isBulkEditMode);
|
||||
};
|
||||
|
||||
const handleBulkHeadersChange = (newHeaders) => {
|
||||
dispatch(setRequestHeaders({ collectionUid: collection.uid, itemUid: item.uid, headers: newHeaders }));
|
||||
const columns = [
|
||||
{
|
||||
key: 'name',
|
||||
name: 'Name',
|
||||
isKeyField: true,
|
||||
placeholder: 'Name',
|
||||
width: '30%',
|
||||
render: ({ row, value, onChange, isLastEmptyRow }) => (
|
||||
<SingleLineEditor
|
||||
value={value || ''}
|
||||
theme={storedTheme}
|
||||
onSave={onSave}
|
||||
onChange={(newValue) => onChange(newValue.replace(/[\r\n]/g, ''))}
|
||||
autocomplete={headerAutoCompleteList}
|
||||
onRun={handleRun}
|
||||
collection={collection}
|
||||
item={item}
|
||||
placeholder={isLastEmptyRow ? 'Name' : ''}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'value',
|
||||
name: 'Value',
|
||||
placeholder: 'Value',
|
||||
render: ({ row, value, onChange, isLastEmptyRow }) => (
|
||||
<SingleLineEditor
|
||||
value={value || ''}
|
||||
theme={storedTheme}
|
||||
onSave={onSave}
|
||||
onChange={onChange}
|
||||
onRun={handleRun}
|
||||
autocomplete={MimeTypes}
|
||||
collection={collection}
|
||||
item={item}
|
||||
placeholder={isLastEmptyRow ? 'Value' : ''}
|
||||
/>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
const defaultRow = {
|
||||
name: '',
|
||||
value: '',
|
||||
description: ''
|
||||
};
|
||||
|
||||
if (isBulkEditMode) {
|
||||
@@ -95,7 +94,7 @@ const RequestHeaders = ({ item, collection, addHeaderText }) => {
|
||||
<StyledWrapper className="w-full mt-3">
|
||||
<BulkEditor
|
||||
params={headers}
|
||||
onChange={handleBulkHeadersChange}
|
||||
onChange={handleHeadersChange}
|
||||
onToggle={toggleBulkEditMode}
|
||||
onSave={onSave}
|
||||
onRun={handleRun}
|
||||
@@ -106,83 +105,15 @@ const RequestHeaders = ({ item, collection, addHeaderText }) => {
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full">
|
||||
<Table
|
||||
headers={[
|
||||
{ name: 'Key', accessor: 'key', width: '34%' },
|
||||
{ name: 'Value', accessor: 'value', width: '46%' },
|
||||
{ name: '', accessor: '', width: '20%' }
|
||||
]}
|
||||
>
|
||||
<ReorderTable updateReorderedItem={handleHeaderDrag}>
|
||||
{headers && headers.length
|
||||
? headers.map((header) => {
|
||||
return (
|
||||
<tr key={header.uid} data-uid={header.uid}>
|
||||
<td className="flex relative">
|
||||
<SingleLineEditor
|
||||
value={header.name}
|
||||
theme={storedTheme}
|
||||
onSave={onSave}
|
||||
onChange={(newValue) =>
|
||||
handleHeaderValueChange(
|
||||
{
|
||||
target: {
|
||||
value: newValue
|
||||
}
|
||||
},
|
||||
header,
|
||||
'name'
|
||||
)}
|
||||
autocomplete={headerAutoCompleteList}
|
||||
onRun={handleRun}
|
||||
collection={collection}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<SingleLineEditor
|
||||
value={header.value}
|
||||
theme={storedTheme}
|
||||
onSave={onSave}
|
||||
onChange={(newValue) =>
|
||||
handleHeaderValueChange(
|
||||
{
|
||||
target: {
|
||||
value: newValue
|
||||
}
|
||||
},
|
||||
header,
|
||||
'value'
|
||||
)}
|
||||
onRun={handleRun}
|
||||
autocomplete={MimeTypes}
|
||||
collection={collection}
|
||||
item={item}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={header.enabled}
|
||||
tabIndex="-1"
|
||||
className="mr-3 mousetrap"
|
||||
onChange={(e) => handleHeaderValueChange(e, header, 'enabled')}
|
||||
/>
|
||||
<button tabIndex="-1" onClick={() => handleRemoveHeader(header)}>
|
||||
<IconTrash strokeWidth={1.5} size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
</ReorderTable>
|
||||
</Table>
|
||||
<div className="flex justify-between mt-2">
|
||||
<button className="btn-action text-link pr-2 py-3 select-none" onClick={addHeader}>
|
||||
+ {addHeaderText || 'Add Header'}
|
||||
</button>
|
||||
<EditableTable
|
||||
columns={columns}
|
||||
rows={headers || []}
|
||||
onChange={handleHeadersChange}
|
||||
defaultRow={defaultRow}
|
||||
reorderable={true}
|
||||
onReorder={handleHeaderDrag}
|
||||
/>
|
||||
<div className="flex justify-end mt-2">
|
||||
<button className="btn-action text-link select-none" onClick={toggleBulkEditMode}>
|
||||
Bulk Edit
|
||||
</button>
|
||||
@@ -190,4 +121,5 @@ const RequestHeaders = ({ item, collection, addHeaderText }) => {
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default RequestHeaders;
|
||||
|
||||
@@ -1,14 +1,25 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
div.tabs {
|
||||
&.tabs {
|
||||
div.more-tabs {
|
||||
color: var(--color-tab-inactive) !important;
|
||||
border-bottom: solid 2px transparent;
|
||||
}
|
||||
|
||||
div.tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 6px 0px;
|
||||
border: none;
|
||||
border-bottom: solid 2px transparent;
|
||||
margin-right: 1.25rem;
|
||||
margin-right: ${(props) => props.theme.tabs.marginRight};
|
||||
color: var(--color-tab-inactive);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:focus,
|
||||
&:active,
|
||||
@@ -20,12 +31,21 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
|
||||
&.active {
|
||||
font-weight: ${(props) => props.theme.tabs.active.fontWeight} !important;
|
||||
color: ${(props) => props.theme.tabs.active.color} !important;
|
||||
border-bottom: solid 2px ${(props) => props.theme.tabs.active.border} !important;
|
||||
}
|
||||
|
||||
.content-indicator {
|
||||
color: ${(props) => props.theme.text}
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
sup {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
line-height: 1;
|
||||
vertical-align: baseline;
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
|
||||
import classnames from 'classnames';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import { IconChevronDown } from '@tabler/icons';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const DROPDOWN_WIDTH = 60;
|
||||
const CALCULATION_DELAY_DEFAULT = 20;
|
||||
const CALCULATION_DELAY_EXTENDED = 150;
|
||||
|
||||
const RequestPaneTabs = ({
|
||||
tabs,
|
||||
activeTab,
|
||||
onTabSelect,
|
||||
rightContent,
|
||||
rightContentRef,
|
||||
delayedTabs = []
|
||||
}) => {
|
||||
const [visibleTabs, setVisibleTabs] = useState([]);
|
||||
const [overflowTabs, setOverflowTabs] = useState([]);
|
||||
|
||||
const tabsContainerRef = useRef(null);
|
||||
const tabRefsMap = useRef({});
|
||||
const dropdownTippyRef = useRef(null);
|
||||
|
||||
const handleTabSelect = useCallback(
|
||||
(tabKey) => {
|
||||
onTabSelect(tabKey);
|
||||
dropdownTippyRef.current?.hide();
|
||||
},
|
||||
[onTabSelect]
|
||||
);
|
||||
|
||||
const calculateTabVisibility = useCallback(() => {
|
||||
const container = tabsContainerRef.current;
|
||||
if (!container || !tabs.length) return;
|
||||
|
||||
const containerWidth = container.offsetWidth;
|
||||
const rightContentWidth = rightContentRef?.current
|
||||
? rightContentRef.current.offsetWidth + 20
|
||||
: 0;
|
||||
|
||||
const availableWidth = containerWidth - rightContentWidth - DROPDOWN_WIDTH;
|
||||
const visible = [];
|
||||
const overflow = [];
|
||||
let currentWidth = 0;
|
||||
|
||||
for (const tab of tabs) {
|
||||
const tabElement = tabRefsMap.current[tab.key];
|
||||
const tabWidth = tabElement ? tabElement.offsetWidth + 20 : 100;
|
||||
|
||||
if (currentWidth + tabWidth <= availableWidth && !overflow.length) {
|
||||
visible.push(tab);
|
||||
currentWidth += tabWidth;
|
||||
} else {
|
||||
overflow.push(tab);
|
||||
}
|
||||
}
|
||||
|
||||
if (!visible.some((t) => t.key === activeTab) && overflow.length) {
|
||||
const activeTabIndex = overflow.findIndex((t) => t.key === activeTab);
|
||||
if (activeTabIndex !== -1) {
|
||||
const [activeTabItem] = overflow.splice(activeTabIndex, 1);
|
||||
const lastVisible = visible.pop();
|
||||
if (lastVisible) overflow.unshift(lastVisible);
|
||||
visible.push(activeTabItem);
|
||||
}
|
||||
}
|
||||
|
||||
setVisibleTabs(visible);
|
||||
setOverflowTabs(overflow);
|
||||
}, [tabs, activeTab, rightContentRef]);
|
||||
|
||||
const renderTab = useCallback(
|
||||
(tab, isInDropdown = false) => {
|
||||
const isActive = tab.key === activeTab;
|
||||
|
||||
if (isInDropdown) {
|
||||
return (
|
||||
<div
|
||||
key={tab.key}
|
||||
className={classnames('dropdown-item', { active: isActive })}
|
||||
role="tab"
|
||||
onClick={() => handleTabSelect(tab.key)}
|
||||
>
|
||||
<span className="flex items-center gap-1">
|
||||
{tab.label}
|
||||
{tab.indicator}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tab.key}
|
||||
className={classnames('tab select-none', tab.key, { active: isActive })}
|
||||
role="tab"
|
||||
onClick={() => handleTabSelect(tab.key)}
|
||||
ref={(el) => el && (tabRefsMap.current[tab.key] = el)}
|
||||
>
|
||||
{tab.label}
|
||||
{tab.indicator}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[activeTab, handleTabSelect]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const delay = delayedTabs.includes(activeTab) ? CALCULATION_DELAY_EXTENDED : CALCULATION_DELAY_DEFAULT;
|
||||
const timeoutId = setTimeout(() => requestAnimationFrame(calculateTabVisibility), delay);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [calculateTabVisibility, activeTab, delayedTabs]);
|
||||
|
||||
useEffect(() => {
|
||||
let frameId = null;
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (frameId) cancelAnimationFrame(frameId);
|
||||
frameId = requestAnimationFrame(calculateTabVisibility);
|
||||
});
|
||||
|
||||
if (tabsContainerRef.current) observer.observe(tabsContainerRef.current);
|
||||
if (rightContentRef?.current) observer.observe(rightContentRef.current);
|
||||
|
||||
return () => {
|
||||
if (frameId) cancelAnimationFrame(frameId);
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [calculateTabVisibility, rightContentRef]);
|
||||
|
||||
const hiddenStyle = useMemo(
|
||||
() => ({ visibility: 'hidden', position: 'absolute', display: 'flex', pointerEvents: 'none' }),
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledWrapper ref={tabsContainerRef} className="flex items-center tabs" role="tablist">
|
||||
<div style={hiddenStyle}>
|
||||
{tabs.map((tab) => (
|
||||
<div
|
||||
key={tab.key}
|
||||
className={classnames('tab select-none', tab.key, { active: tab.key === activeTab })}
|
||||
ref={(el) => el && (tabRefsMap.current[tab.key] = el)}
|
||||
>
|
||||
{tab.label}
|
||||
{tab.indicator}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{visibleTabs.map((tab) => renderTab(tab))}
|
||||
|
||||
{overflowTabs.length > 0 && (
|
||||
<Dropdown
|
||||
icon={(
|
||||
<div className="more-tabs select-none flex items-center cursor-pointer gap-1">
|
||||
<span>More</span>
|
||||
<IconChevronDown size={14} strokeWidth={2} />
|
||||
</div>
|
||||
)}
|
||||
placement="bottom-start"
|
||||
onCreate={(instance) => (dropdownTippyRef.current = instance)}
|
||||
>
|
||||
<div style={{ minWidth: '150px' }}>{overflowTabs.map((tab) => renderTab(tab, true))}</div>
|
||||
</Dropdown>
|
||||
)}
|
||||
|
||||
{rightContent && (
|
||||
<div className="flex flex-grow justify-end items-center">
|
||||
{rightContent}
|
||||
</div>
|
||||
)}
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default RequestPaneTabs;
|
||||
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { addRequestTag, deleteRequestTag, updateCollectionTagsList } from 'providers/ReduxStore/slices/collections';
|
||||
import { makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
|
||||
import TagList from 'components/TagList/index';
|
||||
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
|
||||
@@ -26,6 +27,7 @@ const Tags = ({ item, collection }) => {
|
||||
collectionUid: collection.uid
|
||||
})
|
||||
);
|
||||
dispatch(makeTabPermanent({ uid: item.uid }));
|
||||
}
|
||||
}, [dispatch, tags, item.uid, collection.uid]);
|
||||
|
||||
@@ -37,6 +39,7 @@ const Tags = ({ item, collection }) => {
|
||||
collectionUid: collection.uid
|
||||
})
|
||||
);
|
||||
dispatch(makeTabPermanent({ uid: item.uid }));
|
||||
}, [dispatch, item.uid, collection.uid]);
|
||||
|
||||
const handleRequestSave = () => {
|
||||
|
||||
@@ -1,170 +1,100 @@
|
||||
import React from 'react';
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import { IconTrash } from '@tabler/icons';
|
||||
import React, { useCallback } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { addVar, updateVar, deleteVar, moveVar } from 'providers/ReduxStore/slices/collections';
|
||||
import { moveVar, setRequestVars } from 'providers/ReduxStore/slices/collections';
|
||||
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import MultiLineEditor from 'components/MultiLineEditor';
|
||||
import InfoTip from 'components/InfoTip';
|
||||
import EditableTable from 'components/EditableTable';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import toast from 'react-hot-toast';
|
||||
import { variableNameRegex } from 'utils/common/regex';
|
||||
import Table from 'components/Table/index';
|
||||
import ReorderTable from 'components/ReorderTable/index';
|
||||
|
||||
const VarsTable = ({ item, collection, vars, varType }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
|
||||
const handleAddVar = () => {
|
||||
dispatch(
|
||||
addVar({
|
||||
type: varType,
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
|
||||
const handleRun = () => dispatch(sendRequest(item, collection.uid));
|
||||
const handleVarChange = (e, v, type) => {
|
||||
const _var = cloneDeep(v);
|
||||
switch (type) {
|
||||
case 'name': {
|
||||
const value = e.target.value;
|
||||
|
||||
if (variableNameRegex.test(value) === false) {
|
||||
toast.error(
|
||||
'Variable contains invalid characters! Variables must only contain alpha-numeric characters, "-", "_", "."'
|
||||
);
|
||||
return;
|
||||
}
|
||||
const handleVarsChange = useCallback((updatedVars) => {
|
||||
dispatch(setRequestVars({
|
||||
collectionUid: collection.uid,
|
||||
itemUid: item.uid,
|
||||
vars: updatedVars,
|
||||
type: varType
|
||||
}));
|
||||
}, [dispatch, collection.uid, item.uid, varType]);
|
||||
|
||||
_var.name = value;
|
||||
break;
|
||||
}
|
||||
case 'value': {
|
||||
_var.value = e.target.value;
|
||||
break;
|
||||
}
|
||||
case 'enabled': {
|
||||
_var.enabled = e.target.checked;
|
||||
break;
|
||||
}
|
||||
const handleVarDrag = useCallback(({ updateReorderedItem }) => {
|
||||
dispatch(moveVar({
|
||||
type: varType,
|
||||
collectionUid: collection.uid,
|
||||
itemUid: item.uid,
|
||||
updateReorderedItem
|
||||
}));
|
||||
}, [dispatch, varType, collection.uid, item.uid]);
|
||||
|
||||
const getRowError = useCallback((row, index, key) => {
|
||||
if (key !== 'name') return null;
|
||||
if (!row.name || row.name.trim() === '') return null;
|
||||
if (!variableNameRegex.test(row.name)) {
|
||||
return 'Variable contains invalid characters. Must only contain alphanumeric characters, "-", "_", "."';
|
||||
}
|
||||
dispatch(
|
||||
updateVar({
|
||||
type: varType,
|
||||
var: _var,
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid
|
||||
})
|
||||
);
|
||||
};
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
const handleRemoveVar = (_var) => {
|
||||
dispatch(
|
||||
deleteVar({
|
||||
type: varType,
|
||||
varUid: _var.uid,
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid
|
||||
})
|
||||
);
|
||||
};
|
||||
const columns = [
|
||||
{
|
||||
key: 'name',
|
||||
name: 'Name',
|
||||
isKeyField: true,
|
||||
placeholder: 'Name',
|
||||
width: '35%'
|
||||
},
|
||||
{
|
||||
key: 'value',
|
||||
name: varType === 'request' ? 'Value' : (
|
||||
<div className="flex items-center">
|
||||
<span>Expr</span>
|
||||
<InfoTip content="You can write any valid JS expression here" infotipId={`request-${varType}-var`} />
|
||||
</div>
|
||||
),
|
||||
placeholder: varType === 'request' ? 'Value' : 'Expr',
|
||||
render: ({ row, value, onChange, isLastEmptyRow }) => (
|
||||
<MultiLineEditor
|
||||
value={value || ''}
|
||||
theme={storedTheme}
|
||||
onSave={onSave}
|
||||
onChange={onChange}
|
||||
onRun={handleRun}
|
||||
collection={collection}
|
||||
item={item}
|
||||
placeholder={isLastEmptyRow ? (varType === 'request' ? 'Value' : 'Expr') : ''}
|
||||
/>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
const handleVarDrag = ({ updateReorderedItem }) => {
|
||||
dispatch(
|
||||
moveVar({
|
||||
type: varType,
|
||||
collectionUid: collection.uid,
|
||||
itemUid: item.uid,
|
||||
updateReorderedItem
|
||||
})
|
||||
);
|
||||
const defaultRow = {
|
||||
name: '',
|
||||
value: '',
|
||||
...(varType === 'response' ? { local: false } : {})
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full">
|
||||
<Table
|
||||
headers={[
|
||||
{ name: 'Name', accessor: 'name', width: '40%' },
|
||||
{ name: varType === 'request' ? (
|
||||
<div className="flex items-center">
|
||||
<span>Value</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center">
|
||||
<span>Expr</span>
|
||||
<InfoTip content="You can write any valid JS expression here" infotipId="response-var" />
|
||||
</div>
|
||||
), accessor: 'value', width: '46%' },
|
||||
{ name: '', accessor: '', width: '14%' }
|
||||
]}
|
||||
>
|
||||
<ReorderTable updateReorderedItem={handleVarDrag}>
|
||||
{vars && vars.length
|
||||
? vars.map((_var) => {
|
||||
return (
|
||||
<tr key={_var.uid} data-uid={_var.uid}>
|
||||
<td className="flex relative">
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
value={_var.name}
|
||||
className="mousetrap"
|
||||
onChange={(e) => handleVarChange(e, _var, 'name')}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<MultiLineEditor
|
||||
value={_var.value}
|
||||
theme={storedTheme}
|
||||
onSave={onSave}
|
||||
onChange={(newValue) =>
|
||||
handleVarChange(
|
||||
{
|
||||
target: {
|
||||
value: newValue
|
||||
}
|
||||
},
|
||||
_var,
|
||||
'value'
|
||||
)}
|
||||
onRun={handleRun}
|
||||
collection={collection}
|
||||
item={item}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={_var.enabled}
|
||||
tabIndex="-1"
|
||||
className="mr-3 mousetrap"
|
||||
onChange={(e) => handleVarChange(e, _var, 'enabled')}
|
||||
/>
|
||||
<button tabIndex="-1" onClick={() => handleRemoveVar(_var)}>
|
||||
<IconTrash strokeWidth={1.5} size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
</ReorderTable>
|
||||
</Table>
|
||||
<button className="btn-add-var text-link pr-2 py-3 mt-2 select-none" onClick={handleAddVar}>
|
||||
+ Add
|
||||
</button>
|
||||
<EditableTable
|
||||
columns={columns}
|
||||
rows={vars || []}
|
||||
onChange={handleVarsChange}
|
||||
defaultRow={defaultRow}
|
||||
getRowError={getRowError}
|
||||
reorderable={true}
|
||||
onReorder={handleVarDrag}
|
||||
/>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default VarsTable;
|
||||
|
||||
@@ -2,7 +2,6 @@ import React from 'react';
|
||||
import get from 'lodash/get';
|
||||
import VarsTable from './VarsTable';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import DeprecationWarning from 'components/DeprecationWarning';
|
||||
|
||||
const Vars = ({ item, collection }) => {
|
||||
const requestVars = item.draft ? get(item, 'draft.request.vars.req') : get(item, 'request.vars.req');
|
||||
@@ -11,8 +10,13 @@ const Vars = ({ item, collection }) => {
|
||||
return (
|
||||
<StyledWrapper className="w-full flex flex-col">
|
||||
<div className="mt-2">
|
||||
<div className="mb-1 title text-xs">Pre Request</div>
|
||||
<VarsTable item={item} collection={collection} vars={requestVars} varType="request" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="mt-1 mb-1 title text-xs">Post Response</div>
|
||||
<VarsTable item={item} collection={collection} vars={responseVars} varType="response" />
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,7 +6,7 @@ const StyledWrapper = styled.div`
|
||||
padding: 6px 0px;
|
||||
border: none;
|
||||
border-bottom: solid 2px transparent;
|
||||
margin-right: 1.25rem;
|
||||
margin-right: ${(props) => props.theme.tabs.marginRight};
|
||||
color: var(--color-tab-inactive);
|
||||
cursor: pointer;
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
height: 2.3rem;
|
||||
height: 2.1rem;
|
||||
position: relative;
|
||||
border: ${(props) => props.theme.requestTabPanel.url.border};
|
||||
border-radius: ${(props) => props.theme.border.radius.base};
|
||||
|
||||
@@ -7,7 +7,6 @@ import HttpRequestPane from 'components/RequestPane/HttpRequestPane';
|
||||
import GrpcRequestPane from 'components/RequestPane/GrpcRequestPane/index';
|
||||
import ResponsePane from 'components/ResponsePane';
|
||||
import GrpcResponsePane from 'components/ResponsePane/GrpcResponsePane';
|
||||
import Welcome from 'components/Welcome';
|
||||
import { findItemInCollection } from 'utils/collections';
|
||||
import { cancelRequest, sendRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import RequestNotFound from './RequestNotFound';
|
||||
@@ -34,6 +33,7 @@ import WSRequestPane from 'components/RequestPane/WSRequestPane';
|
||||
import WSResponsePane from 'components/ResponsePane/WsResponsePane';
|
||||
import { useTabPaneBoundaries } from 'hooks/useTabPaneBoundaries/index';
|
||||
import ResponseExample from 'components/ResponseExample';
|
||||
import WorkspaceHome from 'components/WorkspaceHome';
|
||||
|
||||
const MIN_LEFT_PANE_WIDTH = 300;
|
||||
const MIN_RIGHT_PANE_WIDTH = 350;
|
||||
@@ -137,7 +137,7 @@ const RequestTabPanel = () => {
|
||||
}, [dragging]);
|
||||
|
||||
if (!activeTabUid) {
|
||||
return <Welcome />;
|
||||
return <WorkspaceHome />;
|
||||
}
|
||||
|
||||
if (!focusedTab || !focusedTab.uid || !focusedTab.collectionUid) {
|
||||
@@ -243,7 +243,7 @@ const RequestTabPanel = () => {
|
||||
isVerticalLayout ? 'vertical-layout' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="pt-4 pb-3 px-4">
|
||||
<div className="pt-3 pb-3 px-4">
|
||||
{
|
||||
isGrpcRequest
|
||||
? <GrpcQueryUrl item={item} collection={collection} handleRun={handleRun} />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { uuid } from 'utils/common';
|
||||
import { IconFiles, IconRun, IconEye, IconSettings } from '@tabler/icons';
|
||||
import { IconBox, IconRun, IconEye, IconSettings } from '@tabler/icons';
|
||||
import EnvironmentSelector from 'components/Environments/EnvironmentSelector';
|
||||
import { addTab } from 'providers/ReduxStore/slices/tabs';
|
||||
import { useDispatch } from 'react-redux';
|
||||
@@ -45,26 +45,28 @@ const CollectionToolBar = ({ collection }) => {
|
||||
<StyledWrapper>
|
||||
<div className="flex items-center py-2 px-4">
|
||||
<div className="flex flex-1 items-center cursor-pointer hover:underline" onClick={viewCollectionSettings}>
|
||||
<IconFiles size={18} strokeWidth={1.5} />
|
||||
<IconBox size={18} strokeWidth={1.5} />
|
||||
<span className="ml-2 mr-4 font-medium">{collection?.name}</span>
|
||||
</div>
|
||||
<div className="flex flex-3 items-center justify-end">
|
||||
<span className="mr-2">
|
||||
<JsSandboxMode collection={collection} />
|
||||
</span>
|
||||
<span className="mr-3">
|
||||
<ToolHint text="Runner" toolhintId="RunnnerToolhintId" place="bottom">
|
||||
<IconRun className="cursor-pointer" size={18} strokeWidth={1.5} onClick={handleRun} />
|
||||
<IconRun className="cursor-pointer" size={16} strokeWidth={1.5} onClick={handleRun} />
|
||||
</ToolHint>
|
||||
</span>
|
||||
<span className="mr-3">
|
||||
<ToolHint text="Variables" toolhintId="VariablesToolhintId">
|
||||
<IconEye className="cursor-pointer" size={18} strokeWidth={1.5} onClick={viewVariables} />
|
||||
<IconEye className="cursor-pointer" size={16} strokeWidth={1.5} onClick={viewVariables} />
|
||||
</ToolHint>
|
||||
</span>
|
||||
<span className="mr-3">
|
||||
<ToolHint text="Collection Settings" toolhintId="CollectionSettingsToolhintId">
|
||||
<IconSettings className="cursor-pointer" size={18} strokeWidth={1.5} onClick={viewCollectionSettings} />
|
||||
<IconSettings className="cursor-pointer" size={16} strokeWidth={1.5} onClick={viewCollectionSettings} />
|
||||
</ToolHint>
|
||||
</span>
|
||||
<span className="mr-2">
|
||||
<ToolHint text="Javascript Sandbox" toolhintId="JavascriptSandboxToolhintId" place="bottom">
|
||||
<JsSandboxMode collection={collection} />
|
||||
</ToolHint>
|
||||
</span>
|
||||
<span>
|
||||
|
||||
@@ -8,8 +8,7 @@ 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';
|
||||
import GradientCloseButton from '../RequestTab/GradientCloseButton';
|
||||
|
||||
const ExampleTab = ({ tab, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -59,7 +58,7 @@ const ExampleTab = ({ tab, collection }) => {
|
||||
if (!item || !example) {
|
||||
return (
|
||||
<StyledWrapper
|
||||
className="flex items-center justify-between tab-container px-1"
|
||||
className="flex items-center justify-between tab-container px-3"
|
||||
onMouseUp={(e) => {
|
||||
if (e.button === 1) {
|
||||
e.preventDefault();
|
||||
@@ -75,7 +74,7 @@ const ExampleTab = ({ tab, collection }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledWrapper className="flex items-center justify-between tab-container px-1">
|
||||
<StyledWrapper className="flex items-center justify-between tab-container px-2">
|
||||
{showConfirmClose && (
|
||||
<ConfirmRequestClose
|
||||
item={item}
|
||||
@@ -103,7 +102,7 @@ const ExampleTab = ({ tab, collection }) => {
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={`flex items-center tab-label pl-2 ${tab.preview ? 'italic' : ''}`}
|
||||
className={`flex items-center tab-label ${tab.preview ? 'italic' : ''}`}
|
||||
onContextMenu={handleRightClick}
|
||||
onDoubleClick={() => dispatch(makeTabPermanent({ uid: tab.uid }))}
|
||||
onMouseUp={(e) => {
|
||||
@@ -116,13 +115,13 @@ const ExampleTab = ({ tab, collection }) => {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ExampleIcon size={16} color="currentColor" className="mr-2 text-gray-500 flex-shrink-0" />
|
||||
<ExampleIcon size={14} color="currentColor" className="mr-1.5 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"
|
||||
<GradientCloseButton
|
||||
hasChanges={hasChanges}
|
||||
onClick={(e) => {
|
||||
if (!hasChanges) {
|
||||
return handleCloseClick(e);
|
||||
@@ -132,13 +131,7 @@ const ExampleTab = ({ tab, collection }) => {
|
||||
e.preventDefault();
|
||||
setShowConfirmClose(true);
|
||||
}}
|
||||
>
|
||||
{!hasChanges ? (
|
||||
<CloseTabIcon />
|
||||
) : (
|
||||
<DraftTabIcon />
|
||||
)}
|
||||
</div>
|
||||
/>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div.attrs((props) => ({
|
||||
style: {
|
||||
'--gradient-color': props.theme.requestTabs.bg,
|
||||
'--gradient-color-active': props.theme.bg
|
||||
}
|
||||
}))`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
position: absolute;
|
||||
width: 44px;
|
||||
height: 100%;
|
||||
right: 0;
|
||||
top: 0;
|
||||
padding-right: 4px;
|
||||
z-index: 3;
|
||||
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
var(--gradient-color) 40%
|
||||
);
|
||||
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.15s ease;
|
||||
|
||||
li.active & {
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
var(--gradient-color-active) 40%
|
||||
);
|
||||
}
|
||||
|
||||
li:hover &,
|
||||
&.has-changes {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.close-icon-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: ${(props) => props.theme.border.radius.base};
|
||||
cursor: pointer;
|
||||
transition: background-color 0.12s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: ${(props) => props.theme.requestTabs.icon.hoverBg};
|
||||
|
||||
.close-icon {
|
||||
color: ${(props) => props.theme.requestTabs.icon.hoverColor};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.close-icon {
|
||||
color: ${(props) => props.theme.requestTabs.icon.color};
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
transition: color 0.12s ease;
|
||||
}
|
||||
|
||||
.has-changes-icon {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.draft-icon-wrapper {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.close-icon-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&.has-changes:not(li:hover &) {
|
||||
.draft-icon-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.close-icon-wrapper {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
li:hover &.has-changes {
|
||||
.draft-icon-wrapper {
|
||||
display: none;
|
||||
}
|
||||
.close-icon-wrapper {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
import CloseTabIcon from '../CloseTabIcon';
|
||||
import DraftTabIcon from '../DraftTabIcon';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const GradientCloseButton = ({ onClick, hasChanges = false }) => {
|
||||
return (
|
||||
<StyledWrapper className={`close-gradient ${hasChanges ? 'has-changes' : ''}`}>
|
||||
<div className="close-icon-container" onClick={onClick} data-testid="request-tab-close-icon">
|
||||
<span className="draft-icon-wrapper">
|
||||
<DraftTabIcon />
|
||||
</span>
|
||||
<span className="close-icon-wrapper">
|
||||
<CloseTabIcon />
|
||||
</span>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default GradientCloseButton;
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from 'react';
|
||||
import CloseTabIcon from './CloseTabIcon';
|
||||
import DraftTabIcon from './DraftTabIcon';
|
||||
import GradientCloseButton from './GradientCloseButton';
|
||||
import { IconVariable, IconSettings, IconRun, IconFolder, IconShieldLock } from '@tabler/icons';
|
||||
|
||||
const SpecialTab = ({ handleCloseClick, type, tabName, handleDoubleClick, hasDraft }) => {
|
||||
@@ -8,49 +7,49 @@ const SpecialTab = ({ handleCloseClick, type, tabName, handleDoubleClick, hasDra
|
||||
switch (type) {
|
||||
case 'collection-settings': {
|
||||
return (
|
||||
<div onDoubleClick={handleDoubleClick} className="flex items-center flex-nowrap overflow-hidden">
|
||||
<IconSettings size={18} strokeWidth={1.5} className="text-yellow-600" />
|
||||
<span className="ml-1 leading-6">Collection</span>
|
||||
</div>
|
||||
<>
|
||||
<IconSettings size={14} strokeWidth={1.5} className="text-yellow-600 flex-shrink-0" />
|
||||
<span className="ml-1 tab-name">Collection</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
case 'collection-overview': {
|
||||
return (
|
||||
<>
|
||||
<IconSettings size={18} strokeWidth={1.5} className="text-yellow-600" />
|
||||
<span className="ml-1 leading-6">Collection</span>
|
||||
<IconSettings size={14} strokeWidth={1.5} className="text-yellow-600 flex-shrink-0" />
|
||||
<span className="ml-1 tab-name">Overview</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
case 'security-settings': {
|
||||
return (
|
||||
<>
|
||||
<IconShieldLock size={18} strokeWidth={1.5} className="text-yellow-600" />
|
||||
<span className="ml-1">Security</span>
|
||||
<IconShieldLock size={14} strokeWidth={1.5} className="text-yellow-600 flex-shrink-0" />
|
||||
<span className="ml-1 tab-name">Security</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
case 'folder-settings': {
|
||||
return (
|
||||
<div onDoubleClick={handleDoubleClick} className="flex items-center flex-nowrap overflow-hidden">
|
||||
<IconFolder size={18} strokeWidth={1.5} className="text-yellow-600 min-w-[18px]" />
|
||||
<span className="ml-1 leading-6 truncate">{tabName || 'Folder'}</span>
|
||||
</div>
|
||||
<>
|
||||
<IconFolder size={14} strokeWidth={1.5} className="text-yellow-600 flex-shrink-0" />
|
||||
<span className="ml-1 tab-name">{tabName || 'Folder'}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
case 'variables': {
|
||||
return (
|
||||
<>
|
||||
<IconVariable size={18} strokeWidth={1.5} className="text-yellow-600" />
|
||||
<span className="ml-1 leading-6">Variables</span>
|
||||
<IconVariable size={14} strokeWidth={1.5} className="text-yellow-600 flex-shrink-0" />
|
||||
<span className="ml-1 tab-name">Variables</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
case 'collection-runner': {
|
||||
return (
|
||||
<>
|
||||
<IconRun size={18} strokeWidth={1.5} className="text-yellow-600" />
|
||||
<span className="ml-1 leading-6">Runner</span>
|
||||
<IconRun size={14} strokeWidth={1.5} className="text-yellow-600 flex-shrink-0" />
|
||||
<span className="ml-1 tab-name">Runner</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -59,10 +58,13 @@ const SpecialTab = ({ handleCloseClick, type, tabName, handleDoubleClick, hasDra
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center tab-label pl-2">{getTabInfo(type, tabName)}</div>
|
||||
<div className="flex px-2 close-icon-container" onClick={(e) => handleCloseClick(e)}>
|
||||
{hasDraft ? <DraftTabIcon /> : <CloseTabIcon />}
|
||||
<div
|
||||
className="flex items-baseline tab-label"
|
||||
onDoubleClick={handleDoubleClick}
|
||||
>
|
||||
{getTabInfo(type, tabName)}
|
||||
</div>
|
||||
<GradientCloseButton hasChanges={hasDraft} onClick={(e) => handleCloseClick(e)} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,43 +1,32 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
.tab-label {
|
||||
overflow: hidden;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tab-method {
|
||||
font-size: 0.6875rem;
|
||||
letter-spacing: 0.02em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tab-name {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
font-size: 0.8125rem;
|
||||
|
||||
.close-icon-container {
|
||||
min-height: 20px;
|
||||
min-width: 24px;
|
||||
margin-left: 4px;
|
||||
border-radius: 3px;
|
||||
|
||||
.close-icon {
|
||||
display: none;
|
||||
color: ${(props) => props.theme.requestTabs.icon.color};
|
||||
width: 8px;
|
||||
padding-bottom: 6px;
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:hover .close-icon {
|
||||
color: ${(props) => props.theme.requestTabs.icon.hoverColor};
|
||||
background-color: ${(props) => props.theme.requestTabs.icon.hoverBg};
|
||||
}
|
||||
|
||||
.has-changes-icon {
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.tab-method {
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
}
|
||||
// so that the name does not cutoff when italicized
|
||||
padding-right: 2px;
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useState, useRef, Fragment, useMemo } from 'react';
|
||||
import React, { useCallback, useState, useRef, Fragment, useMemo, useEffect } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { closeTabs, makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
|
||||
import { saveRequest, saveCollectionRoot, saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
|
||||
@@ -17,16 +17,17 @@ import StyledWrapper from './StyledWrapper';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import CloneCollectionItem from 'components/Sidebar/Collections/Collection/CollectionItem/CloneCollectionItem/index';
|
||||
import NewRequest from 'components/Sidebar/NewRequest/index';
|
||||
import CloseTabIcon from './CloseTabIcon';
|
||||
import DraftTabIcon from './DraftTabIcon';
|
||||
import GradientCloseButton from './GradientCloseButton';
|
||||
import { flattenItems } from 'utils/collections/index';
|
||||
import { closeWsConnection } from 'utils/network/index';
|
||||
import ExampleTab from '../ExampleTab';
|
||||
|
||||
const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUid }) => {
|
||||
const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUid, hasOverflow, setHasOverflow }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
const theme = storedTheme === 'dark' ? darkTheme : lightTheme;
|
||||
const tabNameRef = useRef(null);
|
||||
const lastOverflowStateRef = useRef(null);
|
||||
const [showConfirmClose, setShowConfirmClose] = useState(false);
|
||||
const [showConfirmCollectionClose, setShowConfirmCollectionClose] = useState(false);
|
||||
const [showConfirmFolderClose, setShowConfirmFolderClose] = useState(false);
|
||||
@@ -36,6 +37,48 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
|
||||
|
||||
const item = findItemInCollection(collection, tab.uid);
|
||||
|
||||
const method = useMemo(() => {
|
||||
if (!item) return;
|
||||
switch (item.type) {
|
||||
case 'grpc-request':
|
||||
return 'gRPC';
|
||||
case 'ws-request':
|
||||
return 'WS';
|
||||
case 'graphql-request':
|
||||
return 'GQL';
|
||||
default:
|
||||
return item.draft ? get(item, 'draft.request.method') : get(item, 'request.method');
|
||||
}
|
||||
}, [item]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!item || !tabNameRef.current || !setHasOverflow) return;
|
||||
|
||||
const checkOverflow = () => {
|
||||
if (tabNameRef.current && setHasOverflow) {
|
||||
const hasOverflow = tabNameRef.current.scrollWidth > tabNameRef.current.clientWidth;
|
||||
if (lastOverflowStateRef.current !== hasOverflow) {
|
||||
lastOverflowStateRef.current = hasOverflow;
|
||||
setHasOverflow(hasOverflow);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const timeoutId = setTimeout(checkOverflow, 0);
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
checkOverflow();
|
||||
});
|
||||
|
||||
if (tabNameRef.current) {
|
||||
resizeObserver.observe(tabNameRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [item, item?.name, method, setHasOverflow]);
|
||||
|
||||
const handleCloseClick = (event) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
@@ -105,11 +148,12 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
|
||||
|
||||
const hasDraft = tab.type === 'collection-settings' && collection?.draft;
|
||||
const hasFolderDraft = tab.type === 'folder-settings' && folder?.draft;
|
||||
|
||||
if (['collection-settings', 'collection-overview', 'folder-settings', 'variables', 'collection-runner', 'security-settings'].includes(tab.type)) {
|
||||
return (
|
||||
<StyledWrapper
|
||||
className={`flex items-center justify-between tab-container px-1 ${tab.preview ? 'italic' : ''}`}
|
||||
onMouseUp={handleMouseUp} // Add middle-click behavior here
|
||||
className={`flex items-center justify-between tab-container px-2 ${tab.preview ? 'italic' : ''}`}
|
||||
onMouseUp={handleMouseUp}
|
||||
>
|
||||
{showConfirmCollectionClose && tab.type === 'collection-settings' && (
|
||||
<ConfirmCollectionClose
|
||||
@@ -192,21 +236,6 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
|
||||
);
|
||||
}
|
||||
|
||||
const getMethodText = useCallback((item) => {
|
||||
if (!item) return;
|
||||
|
||||
switch (item.type) {
|
||||
case 'grpc-request':
|
||||
return 'gRPC';
|
||||
case 'ws-request':
|
||||
return 'WS';
|
||||
case 'graphql-request':
|
||||
return 'GQL';
|
||||
default:
|
||||
return item.draft ? get(item, 'draft.request.method') : get(item, 'request.method');
|
||||
}
|
||||
}, [item]);
|
||||
|
||||
const hasChanges = useMemo(() => hasRequestChanges(item), [item]);
|
||||
|
||||
if (!item) {
|
||||
@@ -228,10 +257,36 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
|
||||
}
|
||||
|
||||
const isWS = item.type === 'ws-request';
|
||||
const method = getMethodText(item);
|
||||
|
||||
useEffect(() => {
|
||||
const checkOverflow = () => {
|
||||
if (tabNameRef.current && setHasOverflow) {
|
||||
const hasOverflow = tabNameRef.current.scrollWidth > tabNameRef.current.clientWidth;
|
||||
if (lastOverflowStateRef.current !== hasOverflow) {
|
||||
lastOverflowStateRef.current = hasOverflow;
|
||||
setHasOverflow(hasOverflow);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const timeoutId = setTimeout(checkOverflow, 0);
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
checkOverflow();
|
||||
});
|
||||
|
||||
if (tabNameRef.current) {
|
||||
resizeObserver.observe(tabNameRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [item.name, method, setHasOverflow]);
|
||||
|
||||
return (
|
||||
<StyledWrapper className="flex items-center justify-between tab-container px-1">
|
||||
<StyledWrapper className="flex items-center justify-between tab-container px-2">
|
||||
{showConfirmClose && (
|
||||
<ConfirmRequestClose
|
||||
item={item}
|
||||
@@ -268,7 +323,7 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={`flex items-baseline tab-label pl-2 ${tab.preview ? 'italic' : ''}`}
|
||||
className={`flex items-baseline tab-label ${tab.preview ? 'italic' : ''}`}
|
||||
onContextMenu={handleRightClick}
|
||||
onDoubleClick={() => dispatch(makeTabPermanent({ uid: tab.uid }))}
|
||||
onMouseUp={(e) => {
|
||||
@@ -284,7 +339,7 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
|
||||
<span className="tab-method uppercase" style={{ color: getMethodColor(method) }}>
|
||||
{method}
|
||||
</span>
|
||||
<span className="ml-1 tab-name" title={item.name}>
|
||||
<span ref={tabNameRef} className="ml-1 tab-name" title={item.name}>
|
||||
{item.name}
|
||||
</span>
|
||||
<RequestTabMenu
|
||||
@@ -297,25 +352,19 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
|
||||
dispatch={dispatch}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="flex px-2 close-icon-container"
|
||||
<GradientCloseButton
|
||||
hasChanges={hasChanges}
|
||||
onClick={(e) => {
|
||||
if (!hasChanges) {
|
||||
isWS && closeWsConnection(item.uid);
|
||||
return handleCloseClick(e);
|
||||
};
|
||||
}
|
||||
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setShowConfirmClose(true);
|
||||
}}
|
||||
>
|
||||
{!hasChanges ? (
|
||||
<CloseTabIcon />
|
||||
) : (
|
||||
<DraftTabIcon />
|
||||
)}
|
||||
</div>
|
||||
/>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
@@ -349,7 +398,7 @@ function RequestTabMenu({ onDropdownCreate, collectionRequestTabs, tabIndex, col
|
||||
}
|
||||
|
||||
dispatch(closeTabs({ tabUids: [tabUid] }));
|
||||
} catch (err) {}
|
||||
} catch (err) { }
|
||||
}
|
||||
|
||||
function handleRevertChanges(event) {
|
||||
@@ -368,7 +417,7 @@ function RequestTabMenu({ onDropdownCreate, collectionRequestTabs, tabIndex, col
|
||||
collectionUid: collection.uid
|
||||
}));
|
||||
}
|
||||
} catch (err) {}
|
||||
} catch (err) { }
|
||||
}
|
||||
|
||||
function handleCloseOtherTabs(event) {
|
||||
|
||||
@@ -1,13 +1,44 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
border-bottom: 1px solid ${(props) => props.theme.requestTabs.bottomBorder};
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: ${(props) => props.theme.requestTabs.bottomBorder};
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.tabs-scroll-container {
|
||||
overflow-x: auto;
|
||||
overflow-y: clip;
|
||||
padding-bottom: 10px;
|
||||
margin-bottom: -10px;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
scrollbar-width: none;
|
||||
|
||||
ul {
|
||||
margin-bottom: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
|
||||
ul {
|
||||
padding: 0;
|
||||
padding: 0 3px;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
overflow: scroll;
|
||||
align-items: flex-end;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
@@ -17,57 +48,127 @@ const Wrapper = styled.div`
|
||||
|
||||
li {
|
||||
display: inline-flex;
|
||||
max-width: 150px;
|
||||
border: 1px solid transparent;
|
||||
max-width: 180px;
|
||||
min-width: 80px;
|
||||
list-style: none;
|
||||
padding-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
cursor: pointer;
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
height: 38px;
|
||||
|
||||
margin-right: 6px;
|
||||
font-size: 0.8125rem;
|
||||
position: relative;
|
||||
margin-right: 3px;
|
||||
color: ${(props) => props.theme.requestTabs.color};
|
||||
background: ${(props) => props.theme.requestTabs.bg};
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
padding: 6px 0;
|
||||
flex-shrink: 0;
|
||||
transition: background-color 0.15s ease;
|
||||
margin-bottom: 3px;
|
||||
|
||||
.tab-container {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&:not(.active) {
|
||||
background: ${(props) => props.theme.requestTabs.bg};
|
||||
border-color: transparent;
|
||||
border-radius: ${(props) => props.theme.border.radius.base};
|
||||
|
||||
}
|
||||
|
||||
&:nth-last-child(1) {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
&.has-overflow:not(:hover) .tab-name {
|
||||
mask-image: linear-gradient(
|
||||
to right,
|
||||
${(props) => props.theme.requestTabs.color} 0%,
|
||||
${(props) => props.theme.requestTabs.color} calc(100% - 12px),
|
||||
transparent 100%
|
||||
);
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to right,
|
||||
${(props) => props.theme.requestTabs.color} 0%,
|
||||
${(props) => props.theme.requestTabs.color} calc(100% - 12px),
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
&.has-overflow:hover .tab-name {
|
||||
mask-image: linear-gradient(
|
||||
to right,
|
||||
${(props) => props.theme.requestTabs.color} 0%,
|
||||
${(props) => props.theme.requestTabs.color} calc(100% - 8px),
|
||||
transparent 100%
|
||||
);
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to right,
|
||||
${(props) => props.theme.requestTabs.color} 0%,
|
||||
${(props) => props.theme.requestTabs.color} calc(100% - 8px),
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: ${(props) => props.theme.requestTabs.active.bg};
|
||||
}
|
||||
background: ${(props) => props.theme.bg || '#ffffff'};
|
||||
border: 1px solid ${(props) => props.theme.requestTabs.bottomBorder};
|
||||
border-bottom-color: ${(props) => props.theme.bg || '#ffffff'};
|
||||
border-radius: 8px 8px 0 0;
|
||||
z-index: 2;
|
||||
margin-bottom: -2px;
|
||||
padding-bottom: 12px;
|
||||
|
||||
&.active {
|
||||
.close-icon-container .close-icon {
|
||||
display: block;
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 1px;
|
||||
left: -8px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: transparent;
|
||||
border-bottom-right-radius: 6px;
|
||||
box-shadow: 3px 3px 0 0 ${(props) => props.theme.bg || '#ffffff'};
|
||||
border-right: 1px solid ${(props) => props.theme.requestTabs.bottomBorder};
|
||||
border-bottom: 1px solid ${(props) => props.theme.requestTabs.bottomBorder};
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.close-icon-container .close-icon {
|
||||
display: block;
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 1px;
|
||||
right: -8px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: transparent;
|
||||
border-bottom-left-radius: 6px;
|
||||
box-shadow: -3px 3px 0 0 ${(props) => props.theme.bg || '#ffffff'};
|
||||
border-left: 1px solid ${(props) => props.theme.requestTabs.bottomBorder};
|
||||
border-bottom: 1px solid ${(props) => props.theme.requestTabs.bottomBorder};
|
||||
}
|
||||
}
|
||||
|
||||
&.short-tab {
|
||||
vertical-align: bottom;
|
||||
width: 34px;
|
||||
min-width: 34px;
|
||||
max-width: 34px;
|
||||
padding: 3px 0px;
|
||||
width: 32px;
|
||||
min-width: 32px;
|
||||
max-width: 32px;
|
||||
padding: 5px 0;
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: ${(props) => props.theme.requestTabs.shortTab.color};
|
||||
background-color: ${(props) => props.theme.requestTabs.shortTab.bg};
|
||||
position: relative;
|
||||
top: -1px;
|
||||
background-color: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: ${(props) => props.theme.border.radius.base};
|
||||
flex-shrink: 0;
|
||||
|
||||
> div {
|
||||
padding: 3px 4px;
|
||||
padding: 3px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
transition: background-color 0.12s ease, color 0.12s ease;
|
||||
}
|
||||
|
||||
> div.home-icon-container {
|
||||
@@ -81,19 +182,23 @@ const Wrapper = styled.div`
|
||||
}
|
||||
|
||||
svg {
|
||||
height: 22px;
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
> div {
|
||||
background-color: ${(props) => props.theme.requestTabs.shortTab.hoverBg};
|
||||
color: ${(props) => props.theme.requestTabs.shortTab.hoverColor};
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.has-chevrons ul {
|
||||
padding-left: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
export default Wrapper;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import find from 'lodash/find';
|
||||
import filter from 'lodash/filter';
|
||||
import classnames from 'classnames';
|
||||
@@ -10,11 +10,15 @@ import CollectionToolBar from './CollectionToolBar';
|
||||
import RequestTab from './RequestTab';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import DraggableTab from './DraggableTab';
|
||||
import CreateUntitledRequest from 'components/CreateUntitledRequest';
|
||||
|
||||
const RequestTabs = () => {
|
||||
const dispatch = useDispatch();
|
||||
const tabsRef = useRef();
|
||||
const scrollContainerRef = useRef();
|
||||
const [newRequestModalOpen, setNewRequestModalOpen] = useState(false);
|
||||
const [tabOverflowStates, setTabOverflowStates] = useState({});
|
||||
const [showChevrons, setShowChevrons] = useState(false);
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
|
||||
const collections = useSelector((state) => state.collections.collections);
|
||||
@@ -22,10 +26,48 @@ const RequestTabs = () => {
|
||||
const sidebarCollapsed = useSelector((state) => state.app.sidebarCollapsed);
|
||||
const screenWidth = useSelector((state) => state.app.screenWidth);
|
||||
|
||||
const createSetHasOverflow = useCallback((tabUid) => {
|
||||
return (hasOverflow) => {
|
||||
setTabOverflowStates((prev) => {
|
||||
if (prev[tabUid] === hasOverflow) {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
[tabUid]: hasOverflow
|
||||
};
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
const activeTab = find(tabs, (t) => t.uid === activeTabUid);
|
||||
const activeCollection = find(collections, (c) => c.uid === activeTab?.collectionUid);
|
||||
const collectionRequestTabs = filter(tabs, (t) => t.collectionUid === activeTab?.collectionUid);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeTabUid || !activeTab) return;
|
||||
|
||||
const checkOverflow = () => {
|
||||
if (tabsRef.current && scrollContainerRef.current) {
|
||||
const hasOverflow = tabsRef.current.scrollWidth > scrollContainerRef.current.clientWidth;
|
||||
setShowChevrons(hasOverflow);
|
||||
}
|
||||
};
|
||||
|
||||
checkOverflow();
|
||||
const resizeObserver = new ResizeObserver(checkOverflow);
|
||||
if (scrollContainerRef.current) {
|
||||
resizeObserver.observe(scrollContainerRef.current);
|
||||
}
|
||||
|
||||
return () => resizeObserver.disconnect();
|
||||
}, [activeTabUid, activeTab, collectionRequestTabs.length, screenWidth, leftSidebarWidth, sidebarCollapsed]);
|
||||
|
||||
const getTabClassname = (tab, index) => {
|
||||
return classnames('request-tab select-none', {
|
||||
'active': tab.uid === activeTabUid,
|
||||
'last-tab': tabs && tabs.length && index === tabs.length - 1
|
||||
'last-tab': tabs && tabs.length && index === tabs.length - 1,
|
||||
'has-overflow': tabOverflowStates[tab.uid]
|
||||
});
|
||||
};
|
||||
|
||||
@@ -37,37 +79,26 @@ const RequestTabs = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const createNewTab = () => setNewRequestModalOpen(true);
|
||||
|
||||
if (!activeTabUid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activeTab = find(tabs, (t) => t.uid === activeTabUid);
|
||||
if (!activeTab) {
|
||||
return <StyledWrapper>Something went wrong!</StyledWrapper>;
|
||||
}
|
||||
|
||||
const activeCollection = find(collections, (c) => c.uid === activeTab.collectionUid);
|
||||
const collectionRequestTabs = filter(tabs, (t) => t.collectionUid === activeTab.collectionUid);
|
||||
|
||||
const effectiveSidebarWidth = sidebarCollapsed ? 0 : leftSidebarWidth;
|
||||
const maxTablistWidth = screenWidth - effectiveSidebarWidth - 150;
|
||||
const tabsWidth = collectionRequestTabs.length * 150 + 34; // 34: (+)icon
|
||||
const showChevrons = maxTablistWidth < tabsWidth;
|
||||
|
||||
const leftSlide = () => {
|
||||
tabsRef.current.scrollBy({
|
||||
scrollContainerRef.current?.scrollBy({
|
||||
left: -120,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
};
|
||||
|
||||
// todo: bring new tab to focus if its not in focus
|
||||
// tabsRef.current.scrollLeft
|
||||
|
||||
const rightSlide = () => {
|
||||
tabsRef.current.scrollBy({
|
||||
scrollContainerRef.current?.scrollBy({
|
||||
left: 120,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
@@ -87,7 +118,7 @@ const RequestTabs = () => {
|
||||
{collectionRequestTabs && collectionRequestTabs.length ? (
|
||||
<>
|
||||
<CollectionToolBar collection={activeCollection} />
|
||||
<div className="flex items-center pl-4">
|
||||
<div className="flex items-center pl-2">
|
||||
<ul role="tablist">
|
||||
{showChevrons ? (
|
||||
<li className="select-none short-tab" onClick={leftSlide}>
|
||||
@@ -103,36 +134,40 @@ const RequestTabs = () => {
|
||||
</div>
|
||||
</li> */}
|
||||
</ul>
|
||||
<ul role="tablist" style={{ maxWidth: maxTablistWidth }} ref={tabsRef}>
|
||||
{collectionRequestTabs && collectionRequestTabs.length
|
||||
? collectionRequestTabs.map((tab, index) => {
|
||||
return (
|
||||
<DraggableTab
|
||||
key={tab.uid}
|
||||
id={tab.uid}
|
||||
index={index}
|
||||
onMoveTab={(source, target) => {
|
||||
dispatch(reorderTabs({
|
||||
sourceUid: source,
|
||||
targetUid: target
|
||||
}));
|
||||
}}
|
||||
className={getTabClassname(tab, index)}
|
||||
onClick={() => handleClick(tab)}
|
||||
>
|
||||
<RequestTab
|
||||
collectionRequestTabs={collectionRequestTabs}
|
||||
tabIndex={index}
|
||||
<div className="tabs-scroll-container" style={{ maxWidth: maxTablistWidth }} ref={scrollContainerRef}>
|
||||
<ul role="tablist" ref={tabsRef}>
|
||||
{collectionRequestTabs && collectionRequestTabs.length
|
||||
? collectionRequestTabs.map((tab, index) => {
|
||||
return (
|
||||
<DraggableTab
|
||||
key={tab.uid}
|
||||
tab={tab}
|
||||
collection={activeCollection}
|
||||
folderUid={tab.folderUid}
|
||||
/>
|
||||
</DraggableTab>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
</ul>
|
||||
id={tab.uid}
|
||||
index={index}
|
||||
onMoveTab={(source, target) => {
|
||||
dispatch(reorderTabs({
|
||||
sourceUid: source,
|
||||
targetUid: target
|
||||
}));
|
||||
}}
|
||||
className={getTabClassname(tab, index)}
|
||||
onClick={() => handleClick(tab)}
|
||||
>
|
||||
<RequestTab
|
||||
collectionRequestTabs={collectionRequestTabs}
|
||||
tabIndex={index}
|
||||
key={tab.uid}
|
||||
tab={tab}
|
||||
collection={activeCollection}
|
||||
folderUid={tab.folderUid}
|
||||
hasOverflow={tabOverflowStates[tab.uid]}
|
||||
setHasOverflow={createSetHasOverflow(tab.uid)}
|
||||
/>
|
||||
</DraggableTab>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<ul role="tablist">
|
||||
{showChevrons ? (
|
||||
@@ -142,19 +177,16 @@ const RequestTabs = () => {
|
||||
</div>
|
||||
</li>
|
||||
) : null}
|
||||
<li className="select-none short-tab" id="create-new-tab" onClick={createNewTab}>
|
||||
<div className="flex items-center">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="22"
|
||||
height="22"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 16 16"
|
||||
>
|
||||
<path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z" />
|
||||
</svg>
|
||||
</div>
|
||||
</li>
|
||||
<div className="flex items-center cursor-pointer short-tab">
|
||||
|
||||
{activeCollection && (
|
||||
<CreateUntitledRequest
|
||||
collectionUid={activeCollection.uid}
|
||||
itemUid={null}
|
||||
placement="bottom-start"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* Moved to post mvp */}
|
||||
{/* <li className="select-none new-tab choose-request">
|
||||
<div className="flex items-center">
|
||||
|
||||
@@ -8,6 +8,7 @@ import { updateResponseExampleMultipartFormParams } from 'providers/ReduxStore/s
|
||||
import mime from 'mime-types';
|
||||
import path from 'utils/common/path';
|
||||
import MultiLineEditor from 'components/MultiLineEditor';
|
||||
import SingleLineEditor from 'components/SingleLineEditor';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import FilePickerEditor from 'components/FilePickerEditor';
|
||||
import Table from 'components/Table-v2';
|
||||
@@ -206,7 +207,7 @@ const ResponseExampleMultipartFormParams = ({ item, collection, exampleUid, edit
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex items-center justify-center pl-4">
|
||||
<MultiLineEditor
|
||||
<SingleLineEditor
|
||||
onSave={() => {}}
|
||||
theme={storedTheme}
|
||||
placeholder="Auto"
|
||||
|
||||
@@ -6,7 +6,7 @@ const StyledWrapper = styled.div`
|
||||
padding: 6px 0px;
|
||||
border: none;
|
||||
border-bottom: solid 2px transparent;
|
||||
margin-right: 1.25rem;
|
||||
margin-right: ${(props) => props.theme.tabs.marginRight};
|
||||
color: var(--color-tab-inactive);
|
||||
cursor: pointer;
|
||||
|
||||
@@ -20,6 +20,7 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
|
||||
&.active {
|
||||
font-weight: ${(props) => props.theme.tabs.active.fontWeight} !important;
|
||||
color: ${(props) => props.theme.tabs.active.color} !important;
|
||||
border-bottom: solid 2px ${(props) => props.theme.tabs.active.border} !important;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ const ClearTimeline = ({ collection, item }) => {
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledWrapper className="ml-2 flex items-center">
|
||||
<StyledWrapper className="flex items-center">
|
||||
<button onClick={clearResponse} className="text-link hover:underline" title="Clear Timeline">
|
||||
Clear Timeline
|
||||
</button>
|
||||
|
||||
@@ -37,7 +37,7 @@ const StyledWrapper = styled.div`
|
||||
padding: 6px 0px;
|
||||
border: none;
|
||||
border-bottom: solid 2px transparent;
|
||||
margin-right: 1.25rem;
|
||||
margin-right: ${(props) => props.theme.tabs.marginRight};
|
||||
color: var(--color-tab-inactive);
|
||||
cursor: pointer;
|
||||
|
||||
@@ -51,6 +51,7 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
|
||||
&.active {
|
||||
font-weight: ${(props) => props.theme.tabs.active.fontWeight} !important;
|
||||
color: ${(props) => props.theme.tabs.active.color} !important;
|
||||
border-bottom: solid 2px ${(props) => props.theme.tabs.active.border} !important;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ const StyledWrapper = styled.div`
|
||||
padding: 6px 0px;
|
||||
border: none;
|
||||
border-bottom: solid 2px transparent;
|
||||
margin-right: 1.25rem;
|
||||
margin-right: ${(props) => props.theme.tabs.marginRight};
|
||||
color: var(--color-tab-inactive);
|
||||
cursor: pointer;
|
||||
|
||||
@@ -25,6 +25,7 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
|
||||
&.active {
|
||||
font-weight: ${(props) => props.theme.tabs.active.fontWeight} !important;
|
||||
color: ${(props) => props.theme.tabs.active.color} !important;
|
||||
border-bottom: solid 2px ${(props) => props.theme.tabs.active.border} !important;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import GrpcStatusCode from './GrpcStatusCode';
|
||||
import ResponseTime from '../ResponseTime/index';
|
||||
import Timeline from '../Timeline';
|
||||
import ClearTimeline from '../ClearTimeline';
|
||||
import ResponseSave from '../ResponseSave';
|
||||
import ResponseClear from '../ResponseClear';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import ResponseTrailers from './ResponseTrailers';
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
border-radius: 4px;
|
||||
border: 1px solid ${(props) => props.theme.console.border};
|
||||
|
||||
.query-response-content {
|
||||
border-top: 1px solid ${(props) => props.theme.console.border};
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,60 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import QueryResult from '../QueryResult';
|
||||
import { useInitialResponseFormat, useResponsePreviewFormatOptions } from '../QueryResult/index';
|
||||
import QueryResultTypeSelector from '../QueryResult/QueryResultTypeSelector/index';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import classnames from 'classnames';
|
||||
|
||||
const QueryResponse = ({
|
||||
item,
|
||||
collection,
|
||||
data,
|
||||
dataBuffer,
|
||||
disableRunEventListener,
|
||||
headers,
|
||||
error
|
||||
}) => {
|
||||
const { initialFormat, initialTab } = useInitialResponseFormat(dataBuffer, headers);
|
||||
const previewFormatOptions = useResponsePreviewFormatOptions(dataBuffer, headers);
|
||||
const [selectedFormat, setSelectedFormat] = useState('raw');
|
||||
const [selectedTab, setSelectedTab] = useState('editor');
|
||||
|
||||
useEffect(() => {
|
||||
if (initialFormat !== null && initialTab !== null) {
|
||||
setSelectedFormat(initialFormat);
|
||||
setSelectedTab(initialTab);
|
||||
}
|
||||
}, [initialFormat, initialTab]);
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="flex items-center justify-end p-2">
|
||||
<QueryResultTypeSelector
|
||||
formatOptions={previewFormatOptions}
|
||||
formatValue={selectedFormat}
|
||||
onFormatChange={(newFormat) => {
|
||||
setSelectedFormat(newFormat);
|
||||
}}
|
||||
onPreviewTabSelect={() => {
|
||||
setSelectedTab((prev) => prev === 'editor' ? 'preview' : 'editor');
|
||||
}}
|
||||
selectedTab={selectedTab}
|
||||
/>
|
||||
</div>
|
||||
<div className={classnames('flex-1 query-response-content', selectedTab === 'editor' ? 'px-2 py-1' : '')}>
|
||||
<QueryResult
|
||||
item={item}
|
||||
collection={collection}
|
||||
data={data}
|
||||
dataBuffer={dataBuffer}
|
||||
disableRunEventListener={disableRunEventListener}
|
||||
headers={headers}
|
||||
error={error}
|
||||
selectedFormat={selectedFormat}
|
||||
selectedTab={selectedTab}
|
||||
/>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default QueryResponse;
|
||||
@@ -0,0 +1,78 @@
|
||||
import React, { useRef, useState, useEffect } from 'react';
|
||||
import { isValidHtml } from 'utils/common/index';
|
||||
import { escapeHtml, isValidHtmlSnippet } from 'utils/response/index';
|
||||
|
||||
const HtmlPreview = React.memo(({ data, baseUrl }) => {
|
||||
const webviewContainerRef = useRef(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!webviewContainerRef.current) return;
|
||||
|
||||
const checkDragging = () => {
|
||||
const hasDraggingParent = webviewContainerRef.current?.closest('.dragging');
|
||||
setIsDragging(!!hasDraggingParent);
|
||||
};
|
||||
|
||||
// Watch from a common ancestor where .dragging gets added
|
||||
const watchTarget = webviewContainerRef.current.closest('.main-section')
|
||||
|| document.body;
|
||||
|
||||
const mutationObserver = new MutationObserver(checkDragging);
|
||||
mutationObserver.observe(watchTarget, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class'],
|
||||
subtree: true
|
||||
});
|
||||
|
||||
// Check initial state
|
||||
checkDragging();
|
||||
|
||||
return () => mutationObserver.disconnect();
|
||||
}, []);
|
||||
|
||||
if (isValidHtml(data) || isValidHtmlSnippet(data)) {
|
||||
const htmlContent = data.includes('<head>')
|
||||
? data.replace('<head>', `<head><base href="${escapeHtml(baseUrl)}">`)
|
||||
: `<head><base href="${escapeHtml(baseUrl)}"></head>${data}`;
|
||||
|
||||
const dragStyles = isDragging ? { pointerEvents: 'none', userSelect: 'none' } : {};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={webviewContainerRef}
|
||||
className="h-full bg-white webview-container"
|
||||
style={dragStyles}
|
||||
>
|
||||
<webview
|
||||
src={`data:text/html; charset=utf-8,${encodeURIComponent(htmlContent)}`}
|
||||
webpreferences="disableDialogs=true, javascript=yes"
|
||||
className="h-full bg-white"
|
||||
style={dragStyles}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// For all other data types, render safely as formatted text
|
||||
let displayContent = '';
|
||||
if (data === null || data === undefined) {
|
||||
displayContent = String(data);
|
||||
} else if (typeof data === 'object') {
|
||||
displayContent = JSON.stringify(data, null);
|
||||
} else if (typeof data === 'string') {
|
||||
displayContent = data;
|
||||
} else {
|
||||
displayContent = String(data);
|
||||
}
|
||||
|
||||
return (
|
||||
<pre
|
||||
className="bg-white font-mono text-[13px] whitespace-pre-wrap break-words overflow-auto overflow-x-hidden p-4 text-[#24292f] w-full max-w-full h-full box-border relative"
|
||||
>
|
||||
{displayContent}
|
||||
</pre>
|
||||
);
|
||||
});
|
||||
|
||||
export default HtmlPreview;
|
||||
@@ -0,0 +1,63 @@
|
||||
import React from 'react';
|
||||
import ReactJson from 'react-json-view';
|
||||
import ErrorAlert from 'ui/ErrorAlert/index';
|
||||
|
||||
const JsonPreview = ({ data, displayedTheme }) => {
|
||||
// Helper function to validate and parse JSON data
|
||||
const validateJsonData = (data) => {
|
||||
// If data is already an object or array, use it directly
|
||||
if (typeof data === 'object' && data !== null) {
|
||||
return { data, error: null };
|
||||
}
|
||||
|
||||
// If data is a string, try to parse it
|
||||
if (typeof data === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
return { data: parsed, error: null };
|
||||
} catch (e) {
|
||||
return { data: null, error: `Invalid JSON format: ${e.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
// For other types, return error
|
||||
return { data: null, error: 'Invalid input. Expected a JSON object, array, or valid JSON string.' };
|
||||
};
|
||||
|
||||
// Validate and parse JSON data
|
||||
const jsonData = validateJsonData(data);
|
||||
|
||||
// Show error if parsing failed
|
||||
if (jsonData.error) {
|
||||
return <ErrorAlert title="Cannot preview as JSON" message={jsonData.error} />;
|
||||
}
|
||||
|
||||
// Validate that data can be rendered as JSON tree
|
||||
if (jsonData.data === null || jsonData.data === undefined) {
|
||||
return <ErrorAlert title="Cannot preview as JSON" message="Data is null or undefined. Expected a valid JSON object or array." />;
|
||||
}
|
||||
|
||||
if (typeof jsonData.data !== 'object') {
|
||||
return <ErrorAlert title="Cannot preview as JSON" message="Data cannot be rendered as a JSON tree. Expected a JSON object or array." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ReactJson
|
||||
src={jsonData.data}
|
||||
theme={displayedTheme === 'light' ? 'rjv-default' : 'monokai'}
|
||||
collapsed={1}
|
||||
displayDataTypes={false}
|
||||
displayObjectSize={true}
|
||||
enableClipboard={true}
|
||||
name={false}
|
||||
style={{
|
||||
backgroundColor: 'transparent',
|
||||
fontSize: '12px',
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
|
||||
padding: '16px'
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default JsonPreview;
|
||||
@@ -0,0 +1,25 @@
|
||||
import React, { memo, useMemo } from 'react';
|
||||
|
||||
const TextPreview = memo(({ data }) => {
|
||||
const displayData = useMemo(() => {
|
||||
if (data === null || data === undefined) {
|
||||
return String(data);
|
||||
}
|
||||
if (typeof data === 'object') {
|
||||
try {
|
||||
return JSON.stringify(data);
|
||||
} catch {
|
||||
return String(data);
|
||||
}
|
||||
}
|
||||
return String(data);
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<div className="p-4 font-mono text-[13px] whitespace-pre-wrap break-words overflow-auto overflow-x-hidden w-full max-w-full h-full">
|
||||
{displayData}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default TextPreview;
|
||||
@@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import ReactPlayer from 'react-player';
|
||||
|
||||
const VideoPreview = React.memo(({ contentType, dataBuffer }) => {
|
||||
const [videoUrl, setVideoUrl] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const videoType = contentType.split(';')[0];
|
||||
const byteArray = Buffer.from(dataBuffer, 'base64');
|
||||
const blob = new Blob([byteArray], { type: videoType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
setVideoUrl(url);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
}, [contentType, dataBuffer]);
|
||||
|
||||
if (!videoUrl) return <div>Loading video...</div>;
|
||||
|
||||
return (
|
||||
<ReactPlayer
|
||||
url={videoUrl}
|
||||
controls
|
||||
muted={true}
|
||||
width="100%"
|
||||
height="100%"
|
||||
onError={(e) => console.error('Error loading video:', e)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export default VideoPreview;
|
||||
@@ -0,0 +1,77 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
padding: 16px;
|
||||
overflow: auto;
|
||||
color: ${(props) => props.theme.text};
|
||||
|
||||
.xml-container {
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
.xml-node-name {
|
||||
color: ${(props) => props.theme.codemirror.tokens.property};
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.xml-separator {
|
||||
color: ${(props) => props.theme.codemirror.tokens.operator};
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
.xml-value {
|
||||
color: ${(props) => props.theme.codemirror.tokens.string};
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.xml-empty-value {
|
||||
color: ${(props) => props.theme.codemirror.tokens.comment};
|
||||
}
|
||||
|
||||
.xml-count {
|
||||
color: ${(props) => props.theme.codemirror.tokens.comment};
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.xml-toggle-button {
|
||||
margin-right: 8px;
|
||||
cursor: pointer;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: ${(props) => props.theme.codemirror.tokens.atom};
|
||||
flex-shrink: 0;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: ${(props) => props.theme.console.buttonHoverBg};
|
||||
}
|
||||
}
|
||||
|
||||
.xml-array-toggle-button {
|
||||
margin-right: 8px;
|
||||
cursor: pointer;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: ${(props) => props.theme.codemirror.tokens.atom};
|
||||
flex-shrink: 0;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: ${(props) => props.theme.console.buttonHoverBg};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,396 @@
|
||||
import ErrorAlert from 'ui/ErrorAlert/index';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
// The expected "data" prop must be an XML string.
|
||||
export default function XmlPreview({ data, defaultExpanded = true }) {
|
||||
// Parse XML string
|
||||
const parsedData = useMemo(() => {
|
||||
if (typeof data !== 'string') {
|
||||
return { error: 'Invalid input. Expected an XML string.' };
|
||||
}
|
||||
|
||||
const parsed = parseXMLString(data);
|
||||
if (parsed === null) {
|
||||
return { error: 'Failed to parse XML string. Invalid XML format.' };
|
||||
}
|
||||
return parsed;
|
||||
}, [data]);
|
||||
|
||||
// Check for parsing error
|
||||
if (parsedData && typeof parsedData === 'object' && parsedData.error) {
|
||||
return (
|
||||
<ErrorAlert title="Cannot preview as XML" message={parsedData.error} />
|
||||
);
|
||||
}
|
||||
|
||||
// Validate that data can be rendered as a tree
|
||||
const isValidTreeData = (data) => {
|
||||
if (data === null || data === undefined) return false;
|
||||
if (typeof data === 'object' && !Array.isArray(data)) return true;
|
||||
if (Array.isArray(data)) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
if (!isValidTreeData(parsedData)) {
|
||||
return (
|
||||
<ErrorAlert title="Cannot preview as XML" message="Data cannot be rendered as a tree. Expected a valid XML string." />
|
||||
);
|
||||
}
|
||||
|
||||
// If root is an object with a single key, unwrap it to show the actual root element
|
||||
let rootNode = parsedData;
|
||||
let rootNodeName = '';
|
||||
|
||||
if (typeof parsedData === 'object' && !Array.isArray(parsedData) && parsedData !== null) {
|
||||
const keys = Object.keys(parsedData).filter((k) => k !== '$' && k !== '@_' && k !== '#text');
|
||||
if (keys.length === 1) {
|
||||
rootNodeName = keys[0];
|
||||
rootNode = parsedData[keys[0]];
|
||||
} else if (keys.length === 0) {
|
||||
// Empty object with no children
|
||||
return (
|
||||
<ErrorAlert title="Cannot preview as XML" message="Cannot render XML tree. Root object is empty." />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="xml-container">
|
||||
<XmlNode
|
||||
node={rootNode}
|
||||
nodeName={rootNodeName}
|
||||
isRoot={true}
|
||||
isLast={true}
|
||||
defaultExpanded={defaultExpanded}
|
||||
/>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
// Component for rendering array entries with expand/collapse functionality
|
||||
const XmlArrayNode = ({ arrayKey, items, depth, defaultExpanded = true }) => {
|
||||
const [expanded, setExpanded] = useState(defaultExpanded);
|
||||
|
||||
const toggle = (e) => {
|
||||
e.stopPropagation();
|
||||
setExpanded((v) => !v);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ paddingLeft: `${(depth + 1) * 20}px` }}>
|
||||
<div className="flex items-center mb-1">
|
||||
<button
|
||||
onClick={toggle}
|
||||
className="xml-array-toggle-button"
|
||||
tabIndex={-1}
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
{expanded ? '▼' : '▶'}
|
||||
</button>
|
||||
<span className="xml-node-name">{arrayKey}</span>
|
||||
<span className="xml-count">[{items.length}]</span>
|
||||
</div>
|
||||
{expanded && (
|
||||
<div className="array-content">
|
||||
{items.map((item, itemIdx) => (
|
||||
<XmlNode
|
||||
key={`${arrayKey}-${itemIdx}`}
|
||||
node={item}
|
||||
nodeName={`${itemIdx}`}
|
||||
isLast={itemIdx === items.length - 1}
|
||||
defaultExpanded={false}
|
||||
depth={depth + 2}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const XmlNode = ({
|
||||
node,
|
||||
nodeName = '',
|
||||
isRoot = false,
|
||||
isLast = true,
|
||||
defaultExpanded = true,
|
||||
depth = 0
|
||||
}) => {
|
||||
const [expanded, setExpanded] = useState(defaultExpanded);
|
||||
|
||||
let displayNodeName = nodeName;
|
||||
|
||||
if (Array.isArray(node)) {
|
||||
// For repeated XML elements with same name (e.g. <item>...</item><item>...</item>)
|
||||
return (
|
||||
<>
|
||||
{node.map((item, idx) => (
|
||||
<XmlNode
|
||||
key={idx}
|
||||
node={item}
|
||||
nodeName={displayNodeName}
|
||||
isRoot={false}
|
||||
isLast={idx === node.length - 1}
|
||||
defaultExpanded={false}
|
||||
depth={depth}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const childEntries = getChildrenEntries(node);
|
||||
const childCount = getChildCount(node);
|
||||
const isLeaf = isTextNode(node) || (typeof node === 'object' && childCount === 0);
|
||||
|
||||
const toggle = (e) => {
|
||||
e.stopPropagation();
|
||||
setExpanded((v) => !v);
|
||||
};
|
||||
|
||||
// For leaf nodes with text content or attributes with empty values
|
||||
if (isLeaf && isTextNode(node)) {
|
||||
const value = String(node);
|
||||
|
||||
return (
|
||||
<div className="flex items-start mb-1" style={{ paddingLeft: `${depth * 20}px` }}>
|
||||
{displayNodeName && (
|
||||
<>
|
||||
<span className="xml-node-name">{displayNodeName}</span>
|
||||
<span className="xml-separator">:</span>
|
||||
</>
|
||||
)}
|
||||
<span className="xml-value">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// For empty leaf nodes (attributes without values, etc)
|
||||
if (isLeaf && !isTextNode(node)) {
|
||||
// Check if this is an attribute-only node with _text
|
||||
if (typeof node === 'object' && node !== null && '_text' in node) {
|
||||
// This node has both attributes and text, handle in expandable section
|
||||
// Fall through to expandable node rendering
|
||||
} else {
|
||||
return (
|
||||
<div className="flex items-center mb-1" style={{ paddingLeft: `${depth * 20}px` }}>
|
||||
{displayNodeName && (
|
||||
<>
|
||||
<span className="xml-node-name">{displayNodeName}</span>
|
||||
<span className="xml-separator">:</span>
|
||||
<span className="xml-empty-value">{'{}'}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// For expandable nodes - show as tree structure
|
||||
// If no node name at root level, render children directly
|
||||
if (!displayNodeName && depth === 0) {
|
||||
if (childEntries.length > 0) {
|
||||
return (
|
||||
<div>
|
||||
{childEntries.map(([key, value], idx) => (
|
||||
<XmlNode
|
||||
key={key + idx}
|
||||
node={value}
|
||||
nodeName={key}
|
||||
isLast={idx === childEntries.length - 1}
|
||||
defaultExpanded={defaultExpanded}
|
||||
depth={0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// If no display name at non-root level, use a fallback
|
||||
if (!displayNodeName) {
|
||||
displayNodeName = '(unnamed)';
|
||||
}
|
||||
|
||||
// Determine if this node's value is an array
|
||||
const hasArrayValue = Array.isArray(node);
|
||||
const arrayLength = hasArrayValue ? node.length : 0;
|
||||
|
||||
return (
|
||||
<div style={{ paddingLeft: `${depth * 20}px` }}>
|
||||
<div className="flex items-center mb-1">
|
||||
<button
|
||||
onClick={toggle}
|
||||
className="xml-toggle-button"
|
||||
tabIndex={-1}
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
{expanded ? '▼' : '▶'}
|
||||
</button>
|
||||
|
||||
<span className="xml-node-name">
|
||||
{displayNodeName}
|
||||
</span>
|
||||
|
||||
{childCount > 0 && (
|
||||
<span className="xml-count">
|
||||
{`{${childCount}}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{expanded && childEntries.length > 0 && (
|
||||
<div>
|
||||
{childEntries.map(([key, value], idx) => {
|
||||
// Check if this is an attribute (starts with _)
|
||||
const isAttribute = key.startsWith('_');
|
||||
|
||||
// Handle attributes
|
||||
if (isAttribute) {
|
||||
const displayValue = value === '' ? 'value' : value;
|
||||
|
||||
return (
|
||||
<div key={key + idx} className="flex items-start mb-1" style={{ paddingLeft: `${(depth + 1) * 20}px` }}>
|
||||
<span className="xml-node-name">{key}</span>
|
||||
<span className="xml-separator">:</span>
|
||||
<span className={value === '' ? 'xml-empty-value' : 'xml-value'}>{displayValue}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Check if this child is an array
|
||||
const isArrayChild = Array.isArray(value);
|
||||
|
||||
if (isArrayChild) {
|
||||
return (
|
||||
<XmlArrayNode
|
||||
key={`${key}-${idx}`}
|
||||
arrayKey={key}
|
||||
items={value}
|
||||
depth={depth}
|
||||
defaultExpanded={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<XmlNode
|
||||
key={key + idx}
|
||||
node={value}
|
||||
nodeName={key}
|
||||
isLast={idx === childEntries.length - 1}
|
||||
defaultExpanded={false}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Helper function to parse XML string to object
|
||||
function parseXMLString(xmlString) {
|
||||
if (typeof xmlString !== 'string') return null;
|
||||
|
||||
try {
|
||||
const parser = new DOMParser();
|
||||
// Parse as XML only
|
||||
const xmlDoc = parser.parseFromString(xmlString, 'text/xml');
|
||||
|
||||
// Check for parsing errors
|
||||
const parserError = xmlDoc.querySelector('parsererror');
|
||||
if (parserError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Convert XML DOM to object
|
||||
function xmlToObject(node) {
|
||||
if (node.nodeType !== 1) return null; // Not an element node
|
||||
|
||||
const result = {};
|
||||
|
||||
// Get attributes - store them directly with underscore prefix
|
||||
if (node.attributes && node.attributes.length > 0) {
|
||||
for (let i = 0; i < node.attributes.length; i++) {
|
||||
const attr = node.attributes[i];
|
||||
result[`_${attr.name}`] = attr.value || '';
|
||||
}
|
||||
}
|
||||
|
||||
// Get child nodes
|
||||
const childNodes = Array.from(node.childNodes);
|
||||
const elementChildren = childNodes.filter((child) => child.nodeType === 1);
|
||||
const textChildren = childNodes.filter((child) => child.nodeType === 3 && child.textContent.trim());
|
||||
|
||||
// If only text children and no element children, return text content
|
||||
if (elementChildren.length === 0 && textChildren.length > 0) {
|
||||
const textContent = textChildren.map((t) => t.textContent.trim()).join(' ').trim();
|
||||
// If has attributes, store text as a special property
|
||||
if (Object.keys(result).length > 0) {
|
||||
result['_text'] = textContent;
|
||||
return result;
|
||||
}
|
||||
return textContent || null;
|
||||
}
|
||||
|
||||
// Process element children
|
||||
if (elementChildren.length > 0) {
|
||||
const childMap = {};
|
||||
elementChildren.forEach((child) => {
|
||||
const childName = child.nodeName; // Preserve original casing
|
||||
const childValue = xmlToObject(child);
|
||||
|
||||
if (childValue !== null || elementChildren.filter((c) => c.nodeName.toLowerCase() === childName).length > 1) {
|
||||
if (childMap[childName]) {
|
||||
// Multiple children with same name - convert to array
|
||||
if (!Array.isArray(childMap[childName])) {
|
||||
childMap[childName] = [childMap[childName]];
|
||||
}
|
||||
childMap[childName].push(childValue);
|
||||
} else {
|
||||
childMap[childName] = childValue;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Merge children into result
|
||||
Object.assign(result, childMap);
|
||||
}
|
||||
|
||||
return Object.keys(result).length > 0 ? result : null;
|
||||
}
|
||||
|
||||
const rootElement = xmlDoc.documentElement;
|
||||
if (!rootElement) return null;
|
||||
|
||||
const parsed = xmlToObject(rootElement);
|
||||
return parsed ? { [rootElement.nodeName]: parsed } : null;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isTextNode(node) {
|
||||
return typeof node === 'string' || typeof node === 'number' || node === null;
|
||||
}
|
||||
|
||||
function getChildrenEntries(node) {
|
||||
// Given an XML-like JS object, return an array of [key, value] for all properties
|
||||
// This includes attributes (with _ prefix) and child elements
|
||||
if (typeof node !== 'object' || node === null) return [];
|
||||
return Object.entries(node);
|
||||
}
|
||||
|
||||
function getChildCount(node) {
|
||||
if (Array.isArray(node)) {
|
||||
return node.length;
|
||||
}
|
||||
const children = getChildrenEntries(node);
|
||||
return children.length;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import CodeEditor from 'components/CodeEditor/index';
|
||||
import { get } from 'lodash';
|
||||
import find from 'lodash/find';
|
||||
@@ -11,44 +11,22 @@ import 'react-pdf/dist/esm/Page/AnnotationLayer.css';
|
||||
import 'react-pdf/dist/esm/Page/TextLayer.css';
|
||||
import { GlobalWorkerOptions } from 'pdfjs-dist/build/pdf';
|
||||
GlobalWorkerOptions.workerSrc = 'pdfjs-dist/legacy/build/pdf.worker.min.mjs';
|
||||
import ReactPlayer from 'react-player';
|
||||
|
||||
const VideoPreview = React.memo(({ contentType, dataBuffer }) => {
|
||||
const [videoUrl, setVideoUrl] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const videoType = contentType.split(';')[0];
|
||||
const byteArray = Buffer.from(dataBuffer, 'base64');
|
||||
const blob = new Blob([byteArray], { type: videoType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
setVideoUrl(url);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
}, [contentType, dataBuffer]);
|
||||
|
||||
if (!videoUrl) return <div>Loading video...</div>;
|
||||
|
||||
return (
|
||||
<ReactPlayer
|
||||
url={videoUrl}
|
||||
controls
|
||||
muted={true}
|
||||
width="100%"
|
||||
height="100%"
|
||||
onError={(e) => console.error('Error loading video:', e)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
import XmlPreview from './XmlPreview/index';
|
||||
import TextPreview from './TextPreview';
|
||||
import HtmlPreview from './HtmlPreview';
|
||||
import VideoPreview from './VideoPreview';
|
||||
import JsonPreview from './JsonPreview';
|
||||
|
||||
const QueryResultPreview = ({
|
||||
previewTab,
|
||||
allowedPreviewModes,
|
||||
selectedTab,
|
||||
data,
|
||||
dataBuffer,
|
||||
formattedData,
|
||||
item,
|
||||
contentType,
|
||||
collection,
|
||||
mode,
|
||||
codeMirrorMode,
|
||||
previewMode,
|
||||
disableRunEventListener,
|
||||
displayedTheme
|
||||
}) => {
|
||||
@@ -63,10 +41,6 @@ const QueryResultPreview = ({
|
||||
function onDocumentLoadSuccess({ numPages }) {
|
||||
setNumPages(numPages);
|
||||
}
|
||||
// Fail safe, so we don't render anything with an invalid tab
|
||||
if (!allowedPreviewModes.find((previewMode) => previewMode?.uid == previewTab?.uid)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const onRun = () => {
|
||||
if (disableRunEventListener) {
|
||||
@@ -87,19 +61,31 @@ const QueryResultPreview = ({
|
||||
);
|
||||
};
|
||||
|
||||
switch (previewTab?.mode) {
|
||||
if (selectedTab === 'editor') {
|
||||
return (
|
||||
<CodeEditor
|
||||
collection={collection}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
theme={displayedTheme}
|
||||
onRun={onRun}
|
||||
onSave={onSave}
|
||||
onScroll={onScroll}
|
||||
value={formattedData}
|
||||
mode={codeMirrorMode}
|
||||
initialScroll={focusedTab.responsePaneScrollPosition || 0}
|
||||
readOnly
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
switch (previewMode) {
|
||||
case 'preview-web': {
|
||||
const webViewSrc = data.replace('<head>', `<head><base href="${item.requestSent?.url || ''}">`);
|
||||
return (
|
||||
<webview
|
||||
src={`data:text/html; charset=utf-8,${encodeURIComponent(webViewSrc)}`}
|
||||
webpreferences="disableDialogs=true, javascript=yes"
|
||||
className="h-full bg-white"
|
||||
/>
|
||||
);
|
||||
const baseUrl = item.requestSent?.url || '';
|
||||
return <HtmlPreview data={data} baseUrl={baseUrl} />;
|
||||
}
|
||||
case 'preview-image': {
|
||||
return <img src={`data:${contentType.replace(/\;(.*)/, '')};base64,${dataBuffer}`} className="mx-auto" />;
|
||||
return <img src={`data:${contentType.replace(/\;(.*)/, '')};base64,${dataBuffer}`} />;
|
||||
}
|
||||
case 'preview-pdf': {
|
||||
return (
|
||||
@@ -120,24 +106,29 @@ const QueryResultPreview = ({
|
||||
case 'preview-video': {
|
||||
return <VideoPreview contentType={contentType} dataBuffer={dataBuffer} />;
|
||||
}
|
||||
default:
|
||||
case 'raw': {
|
||||
return (
|
||||
<CodeEditor
|
||||
collection={collection}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
theme={displayedTheme}
|
||||
onRun={onRun}
|
||||
onSave={onSave}
|
||||
onScroll={onScroll}
|
||||
value={formattedData}
|
||||
mode={mode}
|
||||
initialScroll={focusedTab.responsePaneScrollPosition || 0}
|
||||
readOnly
|
||||
/>
|
||||
);
|
||||
case 'preview-json': {
|
||||
return <JsonPreview data={data} displayedTheme={displayedTheme} />;
|
||||
}
|
||||
|
||||
case 'preview-text': {
|
||||
return <TextPreview data={data} />;
|
||||
}
|
||||
|
||||
case 'preview-xml': {
|
||||
return <XmlPreview data={data} />;
|
||||
}
|
||||
|
||||
default:
|
||||
return (
|
||||
<div className="p-4 flex flex-col items-center justify-center h-full text-center">
|
||||
<div className="text-lg font-semibold text-gray-700 dark:text-gray-200 mb-2">
|
||||
No Preview Available
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Sorry, no preview is available for this content type.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.active {
|
||||
color: ${(props) => props.theme.colors.text.yellow};
|
||||
}
|
||||
|
||||
.preview-response-tab-label {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,46 @@
|
||||
import React from 'react';
|
||||
import { IconEye } from '@tabler/icons';
|
||||
import ButtonDropdown from 'ui/ButtonDropdown';
|
||||
import ToggleSwitch from 'components/ToggleSwitch';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const QueryResultTypeSelector = ({
|
||||
formatOptions,
|
||||
formatValue,
|
||||
onFormatChange,
|
||||
onPreviewTabSelect,
|
||||
selectedTab
|
||||
}) => {
|
||||
const header = (
|
||||
<div className="flex items-center justify-between gap-3 py-[0.35rem] px-[0.6rem]">
|
||||
<span className="text-[0.8125rem] preview-response-tab-label">Preview</span>
|
||||
<ToggleSwitch
|
||||
isOn={selectedTab === 'preview'}
|
||||
handleToggle={(e) => {
|
||||
e.preventDefault();
|
||||
// e.stopPropagation();
|
||||
onPreviewTabSelect();
|
||||
}}
|
||||
size="2xs"
|
||||
data-testid="preview-response-tab"
|
||||
title={selectedTab === 'preview' ? 'Turn off Preview Mode' : 'Turn on Preview Mode'}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<ButtonDropdown
|
||||
label={formatValue}
|
||||
options={formatOptions}
|
||||
value={formatValue}
|
||||
onChange={onFormatChange}
|
||||
header={header}
|
||||
className="h-[20px] text-[11px]"
|
||||
data-testid="format-response-tab"
|
||||
suffix={selectedTab === 'preview' ? <IconEye size={14} strokeWidth={2} className="active mr-[2px]" /> : null}
|
||||
/>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default QueryResultTypeSelector;
|
||||
@@ -1,9 +1,8 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: 100%;
|
||||
grid-template-rows: 1.25rem 1fr;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
/* This is a hack to force Codemirror to use all available space */
|
||||
> div {
|
||||
|
||||
@@ -1,15 +1,34 @@
|
||||
import { debounce } from 'lodash';
|
||||
import { useTheme } from 'providers/Theme/index';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { formatResponse, getContentType } from 'utils/common';
|
||||
import { getEncoding } from 'utils/common/index';
|
||||
import { getDefaultResponseFormat } from 'utils/response';
|
||||
import LargeResponseWarning from '../LargeResponseWarning';
|
||||
import QueryResultFilter from './QueryResultFilter';
|
||||
import React from 'react';
|
||||
import classnames from 'classnames';
|
||||
import { getContentType, formatResponse } from 'utils/common';
|
||||
import { getCodeMirrorModeBasedOnContentType } from 'utils/common/codemirror';
|
||||
import QueryResultPreview from './QueryResultPreview';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { useTheme } from 'providers/Theme/index';
|
||||
import { getEncoding, uuid } from 'utils/common/index';
|
||||
import LargeResponseWarning from '../LargeResponseWarning';
|
||||
import { detectContentTypeFromBuffer } from 'utils/response/index';
|
||||
|
||||
const PREVIEW_FORMAT_OPTIONS = [
|
||||
{
|
||||
// name: 'Structured',
|
||||
options: [
|
||||
{ label: 'JSON', value: 'json', codeMirrorMode: 'application/ld+json' },
|
||||
{ label: 'HTML', value: 'html', codeMirrorMode: 'xml' },
|
||||
{ label: 'XML', value: 'xml', codeMirrorMode: 'xml' },
|
||||
{ label: 'JavaScript', value: 'javascript', codeMirrorMode: 'javascript' }
|
||||
]
|
||||
},
|
||||
{
|
||||
// name: 'Raw',
|
||||
options: [
|
||||
{ label: 'Raw', value: 'raw', codeMirrorMode: 'text/plain' },
|
||||
{ label: 'Hex', value: 'hex', codeMirrorMode: 'text/plain' },
|
||||
{ label: 'Base64', value: 'base64', codeMirrorMode: 'text/plain' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const formatErrorMessage = (error) => {
|
||||
if (!error) return 'Something went wrong';
|
||||
@@ -24,9 +43,87 @@ const formatErrorMessage = (error) => {
|
||||
return error;
|
||||
};
|
||||
|
||||
const QueryResult = ({ item, collection, data, dataBuffer, disableRunEventListener, headers, error }) => {
|
||||
// Custom hook to determine the initial format and tab based on the data buffer and headers
|
||||
export const useInitialResponseFormat = (dataBuffer, headers) => {
|
||||
return useMemo(() => {
|
||||
let buffer = null;
|
||||
try {
|
||||
buffer = dataBuffer ? Buffer.from(dataBuffer, 'base64') : null;
|
||||
} catch (error) {
|
||||
console.error('Error converting dataBuffer to Buffer:', error);
|
||||
buffer = null;
|
||||
}
|
||||
|
||||
const detectedContentType = detectContentTypeFromBuffer(buffer);
|
||||
const contentType = getContentType(headers);
|
||||
|
||||
// Wait until both content types are available
|
||||
if (detectedContentType === null || contentType === undefined) {
|
||||
return { initialFormat: null, initialTab: null };
|
||||
}
|
||||
|
||||
const initial = getDefaultResponseFormat(contentType);
|
||||
return { initialFormat: initial.format, initialTab: initial.tab };
|
||||
}, [dataBuffer, headers]);
|
||||
};
|
||||
|
||||
// Custom hook to determine preview format options based on content type
|
||||
export const useResponsePreviewFormatOptions = (dataBuffer, headers) => {
|
||||
return useMemo(() => {
|
||||
let buffer = null;
|
||||
try {
|
||||
buffer = dataBuffer ? Buffer.from(dataBuffer, 'base64') : null;
|
||||
} catch (error) {
|
||||
console.error('Error converting dataBuffer to Buffer:', error);
|
||||
buffer = null;
|
||||
}
|
||||
|
||||
const detectedContentType = detectContentTypeFromBuffer(buffer);
|
||||
const contentType = getContentType(headers);
|
||||
|
||||
const byteFormatTypes = ['image', 'video', 'audio', 'pdf', 'zip'];
|
||||
|
||||
const isByteFormatType = (contentType) => {
|
||||
return byteFormatTypes.some((type) => contentType.includes(type));
|
||||
};
|
||||
|
||||
const getContentTypeToCheck = () => {
|
||||
if (detectedContentType) {
|
||||
return detectedContentType;
|
||||
}
|
||||
return contentType;
|
||||
};
|
||||
|
||||
const contentTypeToCheck = getContentTypeToCheck();
|
||||
|
||||
if (contentTypeToCheck && isByteFormatType(contentTypeToCheck)) {
|
||||
return PREVIEW_FORMAT_OPTIONS.slice(1, 2); // Remove structured format options
|
||||
}
|
||||
|
||||
return PREVIEW_FORMAT_OPTIONS;
|
||||
}, [dataBuffer, headers]);
|
||||
};
|
||||
|
||||
const QueryResult = ({
|
||||
item,
|
||||
collection,
|
||||
data,
|
||||
dataBuffer,
|
||||
disableRunEventListener,
|
||||
headers,
|
||||
error,
|
||||
selectedFormat, // one of the options in PREVIEW_FORMAT_OPTIONS
|
||||
selectedTab // 'editor' or 'preview'
|
||||
}) => {
|
||||
let buffer = null;
|
||||
try {
|
||||
buffer = Buffer.from(dataBuffer, 'base64'); // dataBuffer is already a base64 string, convert it to actual Buffer
|
||||
} catch (error) {
|
||||
console.error('Error converting dataBuffer to Buffer:', error);
|
||||
buffer = null;
|
||||
}
|
||||
const detectedContentType = detectContentTypeFromBuffer(buffer);
|
||||
const contentType = getContentType(headers);
|
||||
const mode = getCodeMirrorModeBasedOnContentType(contentType, data);
|
||||
const [filter, setFilter] = useState(null);
|
||||
const [showLargeResponse, setShowLargeResponse] = useState(false);
|
||||
const responseEncoding = getEncoding(headers);
|
||||
@@ -56,65 +153,44 @@ const QueryResult = ({ item, collection, data, dataBuffer, disableRunEventListen
|
||||
if (isLargeResponse && !showLargeResponse) {
|
||||
return '';
|
||||
}
|
||||
return formatResponse(data, dataBuffer, mode, filter);
|
||||
return formatResponse(data, dataBuffer, selectedFormat, filter);
|
||||
},
|
||||
[data, dataBuffer, responseEncoding, mode, filter, isLargeResponse, showLargeResponse]
|
||||
[data, dataBuffer, responseEncoding, selectedFormat, filter, isLargeResponse, showLargeResponse]
|
||||
);
|
||||
|
||||
const debouncedResultFilterOnChange = debounce((e) => {
|
||||
setFilter(e.target.value);
|
||||
}, 250);
|
||||
|
||||
const allowedPreviewModes = useMemo(() => {
|
||||
// Always show raw
|
||||
const allowedPreviewModes = [{ mode: 'raw', name: 'Raw', uid: uuid() }];
|
||||
const previewMode = useMemo(() => {
|
||||
// Derive preview mode based on selected format
|
||||
if (selectedFormat === 'html') return 'preview-web';
|
||||
if (selectedFormat === 'json') return 'preview-json';
|
||||
if (selectedFormat === 'xml') return 'preview-xml';
|
||||
if (selectedFormat === 'raw') return 'preview-text';
|
||||
if (selectedFormat === 'javascript') return 'preview-web';
|
||||
|
||||
if (!mode || !contentType) return allowedPreviewModes;
|
||||
|
||||
if (mode?.includes('html') && typeof data === 'string') {
|
||||
allowedPreviewModes.unshift({ mode: 'preview-web', name: 'Web', uid: uuid() });
|
||||
} else if (mode.includes('image')) {
|
||||
allowedPreviewModes.unshift({ mode: 'preview-image', name: 'Image', uid: uuid() });
|
||||
} else if (contentType.includes('pdf')) {
|
||||
allowedPreviewModes.unshift({ mode: 'preview-pdf', name: 'PDF', uid: uuid() });
|
||||
} else if (contentType.includes('audio')) {
|
||||
allowedPreviewModes.unshift({ mode: 'preview-audio', name: 'Audio', uid: uuid() });
|
||||
} else if (contentType.includes('video')) {
|
||||
allowedPreviewModes.unshift({ mode: 'preview-video', name: 'Video', uid: uuid() });
|
||||
// For base64/hex, check content type to determine binary preview type
|
||||
if (selectedFormat === 'base64' || selectedFormat === 'hex') {
|
||||
if (detectedContentType) {
|
||||
if (detectedContentType.includes('image')) return 'preview-image';
|
||||
if (detectedContentType.includes('pdf')) return 'preview-pdf';
|
||||
if (detectedContentType.includes('audio')) return 'preview-audio';
|
||||
if (detectedContentType.includes('video')) return 'preview-video';
|
||||
}
|
||||
// for all other content types, return preview-text
|
||||
return 'preview-text';
|
||||
}
|
||||
return 'preview-text';
|
||||
}, [selectedFormat, detectedContentType]);
|
||||
|
||||
return allowedPreviewModes;
|
||||
}, [mode, data, formattedData]);
|
||||
const codeMirrorMode = useMemo(() => {
|
||||
return PREVIEW_FORMAT_OPTIONS
|
||||
.flatMap((option) => option.options)
|
||||
.find((option) => option.value === selectedFormat)?.codeMirrorMode || 'text/plain';
|
||||
}, [selectedFormat]);
|
||||
|
||||
const [previewTab, setPreviewTab] = useState(allowedPreviewModes[0]);
|
||||
// Ensure the active Tab is always allowed
|
||||
useEffect(() => {
|
||||
if (!allowedPreviewModes.find((previewMode) => previewMode?.uid == previewTab?.uid)) {
|
||||
setPreviewTab(allowedPreviewModes[0]);
|
||||
}
|
||||
}, [previewTab, allowedPreviewModes]);
|
||||
|
||||
const tabs = useMemo(() => {
|
||||
if (allowedPreviewModes.length === 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return allowedPreviewModes.map((previewMode) => (
|
||||
<div
|
||||
className={classnames(
|
||||
'select-none capitalize',
|
||||
previewMode?.uid === previewTab?.uid ? 'active' : 'cursor-pointer'
|
||||
)}
|
||||
role="tab"
|
||||
onClick={() => setPreviewTab(previewMode)}
|
||||
key={previewMode?.uid}
|
||||
>
|
||||
{previewMode?.name}
|
||||
</div>
|
||||
));
|
||||
}, [allowedPreviewModes, previewTab]);
|
||||
|
||||
const queryFilterEnabled = useMemo(() => mode.includes('json'), [mode]);
|
||||
const queryFilterEnabled = useMemo(() => codeMirrorMode.includes('json') && selectedFormat === 'json' && selectedTab === 'editor', [codeMirrorMode, selectedFormat, selectedTab]);
|
||||
const hasScriptError = item.preRequestScriptErrorMessage || item.postResponseScriptErrorMessage;
|
||||
|
||||
return (
|
||||
@@ -122,9 +198,6 @@ const QueryResult = ({ item, collection, data, dataBuffer, disableRunEventListen
|
||||
className="w-full h-full relative flex"
|
||||
queryFilterEnabled={queryFilterEnabled}
|
||||
>
|
||||
<div className="flex justify-end gap-2 text-xs" role="tablist">
|
||||
{tabs}
|
||||
</div>
|
||||
{error ? (
|
||||
<div>
|
||||
{hasScriptError ? null : (
|
||||
@@ -147,21 +220,23 @@ const QueryResult = ({ item, collection, data, dataBuffer, disableRunEventListen
|
||||
) : (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex-1 relative">
|
||||
<QueryResultPreview
|
||||
previewTab={previewTab}
|
||||
data={data}
|
||||
dataBuffer={dataBuffer}
|
||||
formattedData={formattedData}
|
||||
item={item}
|
||||
contentType={contentType}
|
||||
mode={mode}
|
||||
collection={collection}
|
||||
allowedPreviewModes={allowedPreviewModes}
|
||||
disableRunEventListener={disableRunEventListener}
|
||||
displayedTheme={displayedTheme}
|
||||
/>
|
||||
<div className="absolute top-0 left-0 h-full w-full" data-testid="response-preview-container">
|
||||
<QueryResultPreview
|
||||
selectedTab={selectedTab}
|
||||
data={data}
|
||||
dataBuffer={dataBuffer}
|
||||
formattedData={formattedData}
|
||||
item={item}
|
||||
contentType={contentType}
|
||||
previewMode={previewMode}
|
||||
codeMirrorMode={codeMirrorMode}
|
||||
collection={collection}
|
||||
disableRunEventListener={disableRunEventListener}
|
||||
displayedTheme={displayedTheme}
|
||||
/>
|
||||
</div>
|
||||
{queryFilterEnabled && (
|
||||
<QueryResultFilter filter={filter} onChange={debouncedResultFilterOnChange} mode={mode} />
|
||||
<QueryResultFilter filter={filter} onChange={debouncedResultFilterOnChange} mode={codeMirrorMode} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { IconDots } from '@tabler/icons';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import ResponseClear from 'src/components/ResponsePane/ResponseClear';
|
||||
import ResponseSave from 'src/components/ResponsePane/ResponseSave';
|
||||
import ResponseDownload from 'src/components/ResponsePane/ResponseDownload';
|
||||
|
||||
const ResponseActions = ({ collection, item }) => {
|
||||
const menuDropdownTippyRef = useRef();
|
||||
@@ -26,7 +26,7 @@ const ResponseActions = ({ collection, item }) => {
|
||||
<StyledWrapper className="ml-2 flex items-center">
|
||||
<Dropdown onCreate={onMenuDropdownCreate} icon={<MenuIcon />} placement="bottom-end">
|
||||
<ResponseClear item={item} collection={collection} asDropdownItem onClose={handleClose} />
|
||||
<ResponseSave item={item} asDropdownItem onClose={handleClose} />
|
||||
<ResponseDownload item={item} asDropdownItem onClose={handleClose} />
|
||||
</Dropdown>
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,13 @@ import styled from 'styled-components';
|
||||
const StyledWrapper = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: ${(props) => props.theme.codemirror.variable.info.iconColor};
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background-color: ${(props) => props.theme.workspace.button.bg};
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
|
||||
@@ -24,27 +24,51 @@ const getTitleText = ({ isResponseTooLarge, isStreamingResponse }) => {
|
||||
return 'Save current response as example';
|
||||
};
|
||||
|
||||
const ResponseBookmark = ({ item, collection, responseSize }) => {
|
||||
const ResponseBookmark = ({ item, collection, responseSize, children }) => {
|
||||
const dispatch = useDispatch();
|
||||
const [showSaveResponseExampleModal, setShowSaveResponseExampleModal] = useState(false);
|
||||
const response = item.response || {};
|
||||
|
||||
const isResponseTooLarge = responseSize >= 5 * 1024 * 1024; // 5 MB
|
||||
const isStreamingResponse = response.stream;
|
||||
const isDisabled = isResponseTooLarge || isStreamingResponse;
|
||||
|
||||
// Only show for HTTP requests
|
||||
if (item.type !== 'http-request') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleSaveClick = () => {
|
||||
const handleKeyDown = (e) => {
|
||||
if (isDisabled) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleSaveClick(e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveClick = (e) => {
|
||||
if (!response || response.error) {
|
||||
toast.error('No valid response to save as example');
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isResponseTooLarge) {
|
||||
toast.error('Response size exceeds 5MB limit. Cannot save as example.');
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDisabled) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -116,21 +140,28 @@ const ResponseBookmark = ({ item, collection, responseSize }) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledWrapper className="ml-2 flex items-center">
|
||||
<button
|
||||
onClick={handleSaveClick}
|
||||
disabled={isResponseTooLarge || isStreamingResponse}
|
||||
title={
|
||||
disabledMessage
|
||||
}
|
||||
className={classnames('p-1', {
|
||||
'opacity-50 cursor-not-allowed': isResponseTooLarge || isStreamingResponse
|
||||
})}
|
||||
data-testid="response-bookmark-btn"
|
||||
>
|
||||
<IconBookmark size={16} strokeWidth={1.5} />
|
||||
</button>
|
||||
</StyledWrapper>
|
||||
<div
|
||||
role={!!children ? 'button' : undefined}
|
||||
tabIndex={isDisabled ? -1 : 0}
|
||||
aria-disabled={isDisabled}
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={handleSaveClick}
|
||||
title={
|
||||
!children ? disabledMessage : (isDisabled ? disabledMessage : null)
|
||||
}
|
||||
className={classnames({
|
||||
'opacity-50 cursor-not-allowed': isDisabled
|
||||
})}
|
||||
data-testid="response-bookmark-btn"
|
||||
>
|
||||
{children ?? (
|
||||
<StyledWrapper className="flex items-center">
|
||||
<button className="p-1">
|
||||
<IconBookmark size={16} strokeWidth={2} />
|
||||
</button>
|
||||
</StyledWrapper>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CreateExampleModal
|
||||
isOpen={showSaveResponseExampleModal}
|
||||
|
||||
@@ -2,7 +2,13 @@ import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
color: ${(props) => props.theme.requestTabPanel.responseStatus};
|
||||
color: ${(props) => props.theme.codemirror.variable.info.iconColor};
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background-color: ${(props) => props.theme.workspace.button.bg};
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
|
||||
@@ -4,11 +4,11 @@ import { useDispatch } from 'react-redux';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { responseCleared } from 'providers/ReduxStore/slices/collections/index';
|
||||
|
||||
const ResponseClear = ({ collection, item, asDropdownItem, onClose }) => {
|
||||
// Hook to get clear response function
|
||||
export const useResponseClear = (item, collection) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const clearResponse = () => {
|
||||
if (onClose) onClose();
|
||||
dispatch(
|
||||
responseCleared({
|
||||
itemUid: item.uid,
|
||||
@@ -18,21 +18,29 @@ const ResponseClear = ({ collection, item, asDropdownItem, onClose }) => {
|
||||
);
|
||||
};
|
||||
|
||||
if (asDropdownItem) {
|
||||
return (
|
||||
<div className="dropdown-item" onClick={clearResponse}>
|
||||
<IconEraser size={16} strokeWidth={1.5} className="icon mr-2" />
|
||||
Clear
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return { clearResponse };
|
||||
};
|
||||
|
||||
const ResponseClear = ({ collection, item, children }) => {
|
||||
const { clearResponse } = useResponseClear(item, collection);
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
clearResponse();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper className="ml-2 flex items-center">
|
||||
<button onClick={clearResponse} title="Clear response">
|
||||
<IconEraser size={16} strokeWidth={1.5} />
|
||||
</button>
|
||||
</StyledWrapper>
|
||||
<div role={!!children ? 'button' : undefined} tabIndex={0} onClick={clearResponse} title={!children ? 'Clear response' : null} onKeyDown={handleKeyDown} data-testid="response-clear-button">
|
||||
{children ? children : (
|
||||
<StyledWrapper className="flex items-center">
|
||||
<button className="p-1">
|
||||
<IconEraser size={16} strokeWidth={2} />
|
||||
</button>
|
||||
</StyledWrapper>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default ResponseClear;
|
||||
|
||||
@@ -2,7 +2,13 @@ import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
font-size: 0.8125rem;
|
||||
color: ${(props) => props.theme.requestTabPanel.responseStatus};
|
||||
color: ${(props) => props.theme.codemirror.variable.info.iconColor};
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background-color: ${(props) => props.theme.workspace.button.bg};
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
|
||||
@@ -3,7 +3,8 @@ import StyledWrapper from './StyledWrapper';
|
||||
import toast from 'react-hot-toast';
|
||||
import { IconCopy, IconCheck } from '@tabler/icons';
|
||||
|
||||
const ResponseCopy = ({ item }) => {
|
||||
// Hook to get copy response function
|
||||
export const useResponseCopy = (item) => {
|
||||
const response = item.response || {};
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
@@ -30,16 +31,39 @@ const ResponseCopy = ({ item }) => {
|
||||
}
|
||||
};
|
||||
|
||||
return { copyResponse, copied, hasData: !!response.data };
|
||||
};
|
||||
|
||||
const ResponseCopy = ({ item, children }) => {
|
||||
const { copyResponse, copied, hasData } = useResponseCopy(item);
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if ((e.key === 'Enter' || e.key === ' ') && hasData) {
|
||||
e.preventDefault();
|
||||
copyResponse();
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
if (hasData) {
|
||||
copyResponse();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper className="ml-2 flex items-center">
|
||||
<button onClick={copyResponse} disabled={!response.data} title="Copy response to clipboard">
|
||||
{copied ? (
|
||||
<IconCheck size={16} strokeWidth={1.5} />
|
||||
) : (
|
||||
<IconCopy size={16} strokeWidth={1.5} />
|
||||
)}
|
||||
</button>
|
||||
</StyledWrapper>
|
||||
<div role={!!children ? 'button' : undefined} tabIndex={0} onClick={handleClick} title={!children ? 'Copy response to clipboard' : null} onKeyDown={handleKeyDown} data-testid="response-copy-btn">
|
||||
{children ? children : (
|
||||
<StyledWrapper className="flex items-center">
|
||||
<button className="p-1" disabled={!hasData}>
|
||||
{copied ? (
|
||||
<IconCheck size={16} strokeWidth={2} />
|
||||
) : (
|
||||
<IconCopy size={16} strokeWidth={2} />
|
||||
)}
|
||||
</button>
|
||||
</StyledWrapper>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
color: ${(props) => props.theme.codemirror.variable.info.iconColor};
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background-color: ${(props) => props.theme.workspace.button.bg};
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,63 @@
|
||||
import React from 'react';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import toast from 'react-hot-toast';
|
||||
import get from 'lodash/get';
|
||||
import { IconDownload } from '@tabler/icons';
|
||||
import classnames from 'classnames';
|
||||
|
||||
const ResponseDownload = ({ item, children }) => {
|
||||
const { ipcRenderer } = window;
|
||||
const response = item.response || {};
|
||||
const isDisabled = !response.dataBuffer;
|
||||
|
||||
const saveResponseToFile = () => {
|
||||
if (isDisabled) {
|
||||
return;
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
ipcRenderer
|
||||
.invoke('renderer:save-response-to-file', response, item?.requestSent?.url, item.pathname)
|
||||
.then(resolve)
|
||||
.catch((err) => {
|
||||
toast.error(get(err, 'error.message') || 'Something went wrong!');
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (isDisabled) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
saveResponseToFile();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role={!!children ? 'button' : undefined}
|
||||
tabIndex={isDisabled ? -1 : 0}
|
||||
aria-disabled={isDisabled}
|
||||
onClick={saveResponseToFile}
|
||||
onKeyDown={handleKeyDown}
|
||||
title={!children ? 'Save response to file' : null}
|
||||
className={classnames({
|
||||
'opacity-50 cursor-not-allowed': isDisabled
|
||||
})}
|
||||
>
|
||||
{children ? children : (
|
||||
<StyledWrapper className="flex items-center">
|
||||
<button className="p-1">
|
||||
<IconDownload size={16} strokeWidth={2} />
|
||||
</button>
|
||||
</StyledWrapper>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default ResponseDownload;
|
||||
@@ -8,7 +8,13 @@ const Wrapper = styled.div`
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
color: ${(props) => props.theme.codemirror.variable.info.iconColor};
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background-color: ${(props) => props.theme.workspace.button.bg};
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -3,14 +3,14 @@ import { useDispatch, useSelector } from 'react-redux';
|
||||
import { savePreferences } from 'providers/ReduxStore/slices/app';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const IconDockToBottom = () => {
|
||||
export const IconDockToBottom = () => {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="1.5"
|
||||
strokeWidth="2"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
>
|
||||
@@ -25,14 +25,14 @@ const IconDockToBottom = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const IconDockToRight = () => {
|
||||
export const IconDockToRight = () => {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="1.5"
|
||||
strokeWidth="2"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
>
|
||||
@@ -48,7 +48,8 @@ const IconDockToRight = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const ResponseLayoutToggle = () => {
|
||||
// Hook to get orientation and toggle function
|
||||
export const useResponseLayoutToggle = () => {
|
||||
const dispatch = useDispatch();
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const orientation = preferences?.layout?.responsePaneOrientation || 'horizontal';
|
||||
@@ -65,19 +66,42 @@ const ResponseLayoutToggle = () => {
|
||||
dispatch(savePreferences(updatedPreferences));
|
||||
};
|
||||
|
||||
return { orientation, toggleOrientation };
|
||||
};
|
||||
|
||||
const ResponseLayoutToggle = ({ children }) => {
|
||||
const { orientation, toggleOrientation } = useResponseLayoutToggle();
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
toggleOrientation();
|
||||
}
|
||||
};
|
||||
|
||||
const title = !children ? (orientation === 'horizontal' ? 'Switch to vertical layout' : 'Switch to horizontal layout') : null;
|
||||
|
||||
return (
|
||||
<StyledWrapper className="ml-2 flex items-center">
|
||||
<button
|
||||
onClick={toggleOrientation}
|
||||
title={orientation === 'horizontal' ? 'Switch to vertical layout' : 'Switch to horizontal layout'}
|
||||
>
|
||||
{orientation === 'horizontal' ? (
|
||||
<IconDockToBottom />
|
||||
) : (
|
||||
<IconDockToRight />
|
||||
)}
|
||||
</button>
|
||||
</StyledWrapper>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={toggleOrientation}
|
||||
title={title}
|
||||
onKeyDown={handleKeyDown}
|
||||
data-testid="response-layout-toggle-button"
|
||||
>
|
||||
{children ? children : (
|
||||
<StyledWrapper className="flex items-center w-full">
|
||||
<button className="p-1">
|
||||
{orientation === 'horizontal' ? (
|
||||
<IconDockToBottom />
|
||||
) : (
|
||||
<IconDockToRight />
|
||||
)}
|
||||
</button>
|
||||
</StyledWrapper>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user