Compare commits

..

11 Commits

Author SHA1 Message Date
Bijin A B
ae8851385f Merge branch 'main' of usebruno/bruno into workspaces 2025-12-04 04:17:45 +05:30
naman-bruno
77d2fecfe6 fixes 2025-12-03 15:02:45 +05:30
naman-bruno
663b06d60f fix blank line 2025-12-03 14:11:25 +05:30
naman-bruno
dc3b074520 fixes: coderabbit 2025-12-03 14:04:58 +05:30
naman-bruno
c7be4775b3 fixes: comments 2025-12-03 13:36:02 +05:30
naman-bruno
72d5411df8 fixes 2025-12-03 00:48:19 +05:30
naman-bruno
d167be658f fix: close tests 2025-12-03 00:25:55 +05:30
naman-bruno
08c183b4ec fix: tests 2025-12-03 00:06:40 +05:30
naman-bruno
c8d13f16c3 fixes 2025-12-02 14:42:00 +05:30
naman-bruno
399201bbc9 fixes 2025-12-02 14:41:51 +05:30
naman-bruno
93eae99302 init: workspaces 2025-12-02 14:41:39 +05:30
438 changed files with 9307 additions and 22780 deletions

View File

@@ -6,7 +6,7 @@
- Use 2 spaces for indentation. No tabs, just spaces keeps everything neat and uniform.
- Stick to single quotes for strings. For JSX/TSX attributes, use double quotes (e.g., <svg xmlns="..." viewBox="...">) to follow React conventions.
- Stick to single quotes for strings. Double quotes are cool elsewhere, but here we go single.
- Always add semicolons at the end of statements. It's like putting a period at the end of a sentence clarity matters.

View File

@@ -18,9 +18,7 @@ module.exports = runESMImports().then(() => defineConfig([
'**/dist/**/*',
'**/*.bru',
'packages/bruno-js/src/sandbox/bundle-browser-rollup.js',
'packages/bruno-app/public/static/**/*',
'packages/bruno-app/.next/**/*',
'packages/bruno-electron/web/**/*'
'packages/bruno-app/public/static/**/*'
]
},
{

2318
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -23,7 +23,6 @@
"@eslint/compat": "^1.3.2",
"@faker-js/faker": "^7.6.0",
"@jest/globals": "^29.2.0",
"@opencollection/types": "0.3.0",
"@playwright/test": "^1.51.1",
"@rollup/plugin-json": "^6.1.0",
"@stylistic/eslint-plugin": "^5.3.1",

View File

@@ -44,7 +44,6 @@
"i18next": "24.1.2",
"idb": "^7.0.0",
"immer": "^9.0.15",
"js-yaml": "^4.1.0",
"jsesc": "^3.0.2",
"jshint": "^2.13.6",
"json5": "^2.2.3",
@@ -55,7 +54,6 @@
"lodash": "^4.17.21",
"markdown-it": "^13.0.2",
"markdown-it-replace-link": "^1.2.0",
"mime-types": "^3.0.2",
"moment": "^2.30.1",
"moment-timezone": "^0.5.47",
"mousetrap": "^1.6.5",
@@ -85,11 +83,9 @@
"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",
"xml2js": "^0.6.2",
"yup": "^0.32.11"
},
"devDependencies": {

View File

@@ -1,129 +0,0 @@
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;

View File

@@ -1,65 +0,0 @@
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;

View File

@@ -1,138 +0,0 @@
/**
* 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);
}
}
};
}

View File

@@ -1,51 +0,0 @@
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;

View File

@@ -1,19 +0,0 @@
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;

View File

@@ -1,19 +0,0 @@
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;

View File

@@ -1,22 +0,0 @@
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;

View File

@@ -1,97 +0,0 @@
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;

View File

@@ -1,242 +0,0 @@
import styled from 'styled-components';
const Wrapper = styled.div`
height: 36px;
display: flex;
align-items: center;
background: ${(props) => props.theme.sidebar.bg};
-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: 6px;
}
/* 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;
-webkit-app-region: no-drag;
}
/* App action buttons container */
.titlebar-actions {
display: flex;
align-items: center;
}
/* 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 */
&:not(.os-mac) .titlebar-content {
padding-left: 12px;
}
/* Windows-specific styles */
&.os-windows .titlebar-content {
padding-right: 0px;
padding-left: 0px;
}
&.os-windows .titlebar-left {
margin-left: 6px;
}
/* Custom window control buttons for Windows - always interactive, above modal overlay */
.window-controls {
display: flex;
align-items: stretch;
height: 36px;
margin-left: 8px;
position: relative;
z-index: 1000;
}
.window-control-btn {
display: flex;
align-items: center;
justify-content: center;
width: 46px;
height: 100%;
border: none;
background: transparent;
color: ${(props) => props.theme.text};
cursor: pointer;
transition: background-color 0.1s ease;
-webkit-app-region: no-drag;
&:hover {
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
}
&:active {
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
}
&.close:hover {
background: #e81123;
color: white;
}
}
`;
export default Wrapper;

View File

@@ -1,334 +0,0 @@
import React from 'react';
import { IconCheck, IconChevronDown, IconFolder, IconHome, IconPin, IconPinned, IconPlus, IconUpload, IconSettings, IconMinus, IconSquare, IconX, IconCopy } 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, showManageWorkspacePage, 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 ImportWorkspace from 'components/WorkspaceSidebar/ImportWorkspace';
import IconBottombarToggle from 'components/Icons/IconBottombarToggle/index';
import StyledWrapper from './StyledWrapper';
import { toTitleCase } from 'utils/common/index';
import ResponseLayoutToggle from 'components/ResponsePane/ResponseLayoutToggle';
import { isMacOS, isWindowsOS } from 'utils/common/platform';
const getOsClass = () => {
if (isMacOS()) return 'os-mac';
if (isWindowsOS()) return 'os-windows';
return 'os-other';
};
const AppTitleBar = () => {
const dispatch = useDispatch();
const [isFullScreen, setIsFullScreen] = useState(false);
const [isMaximized, setIsMaximized] = useState(false);
const osClass = getOsClass();
const isWindows = osClass === 'os-windows';
// 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();
};
}, []);
// Check initial maximized state and listen for changes (Windows only)
useEffect(() => {
if (!isWindows) return;
const { ipcRenderer } = window;
if (!ipcRenderer) return;
// Get initial state
ipcRenderer.invoke('renderer:window-is-maximized')
.then((maximized) => {
setIsMaximized(maximized);
})
.catch((error) => {
console.error('Error getting initial maximized state:', error);
});
// Listen for maximize/unmaximize events from main process
const removeMaximizedListener = ipcRenderer.on('main:window-maximized', () => {
setIsMaximized(true);
});
const removeUnmaximizedListener = ipcRenderer.on('main:window-unmaximized', () => {
setIsMaximized(false);
});
return () => {
removeMaximizedListener();
removeUnmaximizedListener();
};
}, [isWindows]);
// Window control handlers (Windows only) - these always work, even with modals open
const handleMinimize = useCallback(() => {
window.ipcRenderer?.send('renderer:window-minimize');
}, []);
const handleMaximize = useCallback(() => {
window.ipcRenderer?.send('renderer:window-maximize');
// State will be updated via IPC events from main process (main:window-maximized/main:window-unmaximized)
}, []);
const handleClose = useCallback(() => {
window.ipcRenderer?.send('renderer:window-close');
}, []);
// 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 [importWorkspaceModalOpen, setImportWorkspaceModalOpen] = 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 handleManageWorkspaces = () => {
dispatch(showManageWorkspacePage());
};
const handleImportWorkspace = () => {
setImportWorkspaceModalOpen(true);
};
const handlePinWorkspace = useCallback((workspaceUid, e) => {
e.preventDefault();
e.stopPropagation();
const newPreferences = toggleWorkspacePin(workspaceUid, preferences);
dispatch(savePreferences(newPreferences));
}, [dispatch, preferences]);
const handleToggleSidebar = () => {
dispatch(toggleSidebarCollapse());
};
const handleToggleDevtools = () => {
if (isConsoleOpen) {
dispatch(closeConsole());
} else {
dispatch(openConsole());
}
};
// 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
},
{
id: 'import-workspace',
leftSection: IconUpload,
label: 'Import workspace',
onClick: handleImportWorkspace
},
{
id: 'manage-workspaces',
leftSection: IconSettings,
label: 'Manage workspaces',
onClick: handleManageWorkspaces
}
);
return items;
}, [sortedWorkspaces, activeWorkspaceUid, preferences, handlePinWorkspace]);
return (
<StyledWrapper className={`app-titlebar ${osClass} ${isFullScreen ? 'fullscreen' : ''}`}>
{createWorkspaceModalOpen && (
<CreateWorkspace onClose={() => setCreateWorkspaceModalOpen(false)} />
)}
{importWorkspaceModalOpen && (
<ImportWorkspace onClose={() => setImportWorkspaceModalOpen(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">
<div className="titlebar-actions">
{/* 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>
<ResponseLayoutToggle />
</div>
{isWindows && (
<div className="window-controls">
<button
className="window-control-btn minimize"
onClick={handleMinimize}
aria-label="Minimize"
>
<IconMinus size={16} stroke={1} />
</button>
<button
className="window-control-btn maximize"
onClick={handleMaximize}
aria-label={isMaximized ? 'Restore' : 'Maximize'}
>
{isMaximized ? <IconCopy size={14} stroke={1} /> : <IconSquare size={14} stroke={1} />}
</button>
<button
className="window-control-btn close"
onClick={handleClose}
aria-label="Close"
>
<IconX size={16} stroke={1} />
</button>
</div>
)}
</div>
</div>
</StyledWrapper>
);
};
export default AppTitleBar;

View File

@@ -1,38 +0,0 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
font-size: ${(props) => props.theme.font.size.base};
.body-mode-selector {
background: transparent;
border-radius: 3px;
.dropdown-item {
padding: 0.2rem 0.6rem !important;
padding-left: 1.5rem !important;
display: flex;
align-items: center;
}
.label-item {
padding: 0.2rem 0.6rem !important;
}
.selected-body-mode {
color: ${(props) => props.theme.colors.text.yellow};
}
.dropdown-icon {
display: flex;
align-items: center;
margin-right: 0.5rem;
}
}
.caret {
color: ${(props) => props.theme.colors.text.muted};
fill: ${(props) => props.theme.colors.text.muted};
}
`;
export default StyledWrapper;

View File

@@ -1,33 +1,17 @@
import React, { useMemo } from 'react';
import { IconCaretDown, IconForms, IconBraces, IconCode, IconFileText, IconDatabase, IconFile, IconX } from '@tabler/icons';
import MenuDropdown from 'ui/MenuDropdown';
import React, { useRef, forwardRef } from 'react';
import { IconCaretDown } from '@tabler/icons';
import Dropdown from 'components/Dropdown';
import { humanizeRequestBodyMode } from 'utils/collections';
import StyledWrapper from './StyledWrapper';
const DEFAULT_MODES = [
{
name: 'Form',
options: [
{ id: 'multipartForm', label: 'Multipart Form', leftSection: IconForms },
{ id: 'formUrlEncoded', label: 'Form URL Encoded', leftSection: IconForms }
]
},
{
name: 'Raw',
options: [
{ id: 'json', label: 'JSON', leftSection: IconBraces },
{ id: 'xml', label: 'XML', leftSection: IconCode },
{ id: 'text', label: 'TEXT', leftSection: IconFileText },
{ id: 'sparql', label: 'SPARQL', leftSection: IconDatabase }
]
},
{
name: 'Other',
options: [
{ id: 'file', label: 'File / Binary', leftSection: IconFile },
{ id: 'none', label: 'No Body', leftSection: IconX }
]
}
{ key: 'multipartForm', label: 'Multipart Form', category: 'Form' },
{ key: 'formUrlEncoded', label: 'Form URL Encoded', category: 'Form' },
{ key: 'json', label: 'JSON', category: 'Raw' },
{ key: 'xml', label: 'XML', category: 'Raw' },
{ key: 'text', label: 'TEXT', category: 'Raw' },
{ key: 'sparql', label: 'SPARQL', category: 'Raw' },
{ key: 'file', label: 'File / Binary', category: 'Other' },
{ key: 'none', label: 'None', category: 'Other' }
];
const BodyModeSelector = ({
@@ -37,39 +21,62 @@ const BodyModeSelector = ({
disabled = false,
className = '',
wrapperClassName = '',
showCategories = true,
placement = 'bottom-end'
}) => {
// Add onClick handlers to mode options
const menuItems = useMemo(() => {
return modes.map((group) => ({
...group,
options: group.options.map((option) => ({
...option,
onClick: () => onModeChange(option.id)
}))
}));
}, [modes, onModeChange]);
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const Icon = forwardRef((props, ref) => {
return (
<div ref={ref} className="flex items-center justify-center pl-3 py-1 select-none selected-body-mode">
{humanizeRequestBodyMode(currentMode)}
{' '}
<IconCaretDown className="caret ml-2" size={14} strokeWidth={2} />
</div>
);
});
const onModeSelect = (mode) => {
dropdownTippyRef.current.hide();
onModeChange(mode);
};
// Group modes by category for rendering
const groupedModes = modes.reduce((acc, mode) => {
const category = mode.category || 'Other';
if (!acc[category]) {
acc[category] = [];
}
acc[category].push(mode);
return acc;
}, {});
return (
<StyledWrapper className={wrapperClassName}>
<div className={`inline-flex items-center body-mode-selector ${disabled ? 'cursor-default' : 'cursor-pointer'}`}>
<MenuDropdown
items={menuItems}
placement={placement}
disabled={disabled}
className={className}
selectedItemId={currentMode}
showGroupDividers={false}
groupStyle="select"
>
<div className="flex items-center justify-center pl-3 py-1 select-none selected-body-mode">
{humanizeRequestBodyMode(currentMode)}
{' '}
<IconCaretDown className="caret ml-2" size={14} strokeWidth={2} />
</div>
</MenuDropdown>
</div>
</StyledWrapper>
<div className={`inline-flex items-center body-mode-selector ${disabled ? 'cursor-default' : 'cursor-pointer'} ${wrapperClassName}`}>
<Dropdown
onCreate={onDropdownCreate}
icon={<Icon />}
placement={placement}
disabled={disabled}
className={className}
>
{Object.entries(groupedModes).map(([category, categoryModes]) => (
<React.Fragment key={category}>
{showCategories && <div className="label-item font-medium">{category}</div>}
{categoryModes.map((mode) => (
<div
key={mode.key}
className="dropdown-item"
onClick={() => onModeSelect(mode.key)}
>
{mode.label}
</div>
))}
</React.Fragment>
))}
</Dropdown>
</div>
);
};

View File

@@ -192,6 +192,7 @@ export default class CodeEditor extends React.Component {
if (editor) {
editor.setOption('lint', this.props.mode && editor.getValue().trim().length > 0 ? this.lintOptions : false);
editor.on('change', this._onEdit);
editor.on('scroll', this.onScroll);
editor.scrollTo(null, this.props.initialScroll);
this.addOverlay();
@@ -274,19 +275,13 @@ export default class CodeEditor extends React.Component {
componentWillUnmount() {
if (this.editor) {
if (this.props.onScroll) {
this.props.onScroll(this.editor);
}
this.editor?._destroyLinkAware?.();
this.editor.off('change', this._onEdit);
this.editor.off('scroll', this.onScroll);
// Clean up lint error tooltip
this.cleanupLintErrorTooltip?.();
const wrapper = this.editor.getWrapperElement();
wrapper?.parentNode?.removeChild(wrapper);
this.editor = null;
}
}
@@ -330,6 +325,8 @@ export default class CodeEditor extends React.Component {
this.editor.setOption('mode', 'brunovariables');
};
onScroll = (event) => this.props.onScroll?.(event);
_onEdit = () => {
if (!this.ignoreChangeEvent && this.editor) {
this.editor.setOption('lint', this.editor.getValue().trim().length > 0 ? this.lintOptions : false);

View File

@@ -8,12 +8,20 @@ const Wrapper = styled.div`
.auth-mode-label {
color: ${(props) => props.theme.colors.text.yellow};
.caret {
color: rgb(140, 140, 140);
fill: rgb(140, 140, 140);
}
}
.dropdown-item {
padding: 0.2rem 0.6rem !important;
}
.label-item {
padding: 0.2rem 0.6rem !important;
}
}
.caret {
color: rgb(140, 140, 140);
fill: rgb(140 140 140);
}
`;

View File

@@ -1,7 +1,7 @@
import React, { useMemo, useCallback } from 'react';
import React, { useRef, forwardRef } from 'react';
import get from 'lodash/get';
import { IconCaretDown } from '@tabler/icons';
import MenuDropdown from 'ui/MenuDropdown';
import Dropdown from 'components/Dropdown';
import { useDispatch } from 'react-redux';
import { updateCollectionAuthMode } from 'providers/ReduxStore/slices/collections';
import { humanizeRequestAuthMode } from 'utils/collections';
@@ -9,77 +9,113 @@ import StyledWrapper from './StyledWrapper';
const AuthMode = ({ collection }) => {
const dispatch = useDispatch();
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const authMode = collection.draft?.root ? get(collection, 'draft.root.request.auth.mode') : get(collection, 'root.request.auth.mode');
const onModeChange = useCallback((value) => {
const Icon = forwardRef((props, ref) => {
return (
<div ref={ref} className="flex items-center justify-center auth-mode-label select-none">
{humanizeRequestAuthMode(authMode)} <IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
</div>
);
});
const onModeChange = (value) => {
dispatch(
updateCollectionAuthMode({
collectionUid: collection.uid,
mode: value
})
);
}, [dispatch, collection.uid]);
const menuItems = useMemo(() => [
{
id: 'awsv4',
label: 'AWS Sig v4',
onClick: () => onModeChange('awsv4')
},
{
id: 'basic',
label: 'Basic Auth',
onClick: () => onModeChange('basic')
},
{
id: 'wsse',
label: 'WSSE Auth',
onClick: () => onModeChange('wsse')
},
{
id: 'bearer',
label: 'Bearer Token',
onClick: () => onModeChange('bearer')
},
{
id: 'digest',
label: 'Digest Auth',
onClick: () => onModeChange('digest')
},
{
id: 'ntlm',
label: 'NTLM Auth',
onClick: () => onModeChange('ntlm')
},
{
id: 'oauth2',
label: 'OAuth 2.0',
onClick: () => onModeChange('oauth2')
},
{
id: 'apikey',
label: 'API Key',
onClick: () => onModeChange('apikey')
},
{
id: 'none',
label: 'No Auth',
onClick: () => onModeChange('none')
}
], [onModeChange]);
};
return (
<StyledWrapper>
<div className="inline-flex items-center cursor-pointer auth-mode-selector">
<MenuDropdown
items={menuItems}
placement="bottom-end"
selectedItemId={authMode}
>
<div className="flex items-center justify-center auth-mode-label select-none">
{humanizeRequestAuthMode(authMode)} <IconCaretDown className="caret ml-1" size={14} strokeWidth={2} />
<Dropdown onCreate={onDropdownCreate} icon={<Icon />} placement="bottom-end">
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('awsv4');
}}
>
AWS Sig v4
</div>
</MenuDropdown>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('basic');
}}
>
Basic Auth
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('wsse');
}}
>
WSSE Auth
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('bearer');
}}
>
Bearer Token
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('digest');
}}
>
Digest Auth
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('ntlm');
}}
>
NTLM Auth
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('oauth2');
}}
>
OAuth 2.0
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('apikey');
}}
>
API Key
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('none');
}}
>
No Auth
</div>
</Dropdown>
</div>
</StyledWrapper>
);

View File

@@ -48,37 +48,32 @@ const StyledWrapper = styled.div`
}
.protocol-https,
.protocol-grpcs,
.protocol-wss {
.protocol-grpcs {
position: absolute;
right: 8px;
top: 0;
bottom: 0;
transition: transform 0.3s ease-in-out;
display: flex;
align-items: center;
justify-content: center;
}
.protocol-https {
animation: slideUpDown 9s infinite;
animation: slideUpDown 6s infinite;
transform: translateY(0);
}
.protocol-grpcs {
animation: slideUpDown 9s infinite 3s;
transform: translateY(100%);
}
.protocol-wss {
animation: slideUpDown 9s infinite 6s;
animation: slideUpDown 6s infinite 3s;
transform: translateY(100%);
}
@keyframes slideUpDown {
0%, 30% {
0%, 45% {
transform: translateY(0);
}
33.33%, 97% {
50%, 95% {
transform: translateY(100%);
}
100% {

View File

@@ -180,7 +180,6 @@ const ClientCertSettings = ({ collection }) => {
<span className="protocol-placeholder">
<span className="protocol-https">https://</span>
<span className="protocol-grpcs">grpcs://</span>
<span className="protocol-wss">wss://</span>
</span>
</div>
<input
@@ -374,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" data-testid="add-client-cert">
<button type="submit" className="submit btn btn-sm btn-secondary">
Add
</button>
<div className="h-4 border-l border-gray-600"></div>

View File

@@ -1,77 +1,78 @@
import React, { useState, useCallback } from 'react';
import React, { useState } 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 { setCollectionHeaders } from 'providers/ReduxStore/slices/collections';
import {
addCollectionHeader,
updateCollectionHeader,
deleteCollectionHeader,
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 handleHeadersChange = useCallback((updatedHeaders) => {
dispatch(setCollectionHeaders({ collectionUid: collection.uid, headers: updatedHeaders }));
}, [dispatch, collection.uid]);
const handleBulkHeadersChange = (newHeaders) => {
dispatch(setCollectionHeaders({ collectionUid: collection.uid, headers: newHeaders }));
};
const addHeader = () => {
dispatch(
addCollectionHeader({
collectionUid: collection.uid
})
);
};
const handleSave = () => dispatch(saveCollectionSettings(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 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 defaultRow = {
name: '',
value: '',
description: ''
const handleRemoveHeader = (header) => {
dispatch(
deleteCollectionHeader({
headerUid: header.uid,
collectionUid: collection.uid
})
);
};
if (isBulkEditMode) {
@@ -82,7 +83,7 @@ const Headers = ({ collection }) => {
</div>
<BulkEditor
params={headers}
onChange={handleHeadersChange}
onChange={handleBulkHeadersChange}
onToggle={toggleBulkEditMode}
onSave={handleSave}
/>
@@ -95,17 +96,86 @@ 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>
<EditableTable
columns={columns}
rows={headers}
onChange={handleHeadersChange}
defaultRow={defaultRow}
/>
<div className="flex justify-end mt-2">
<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>
<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
@@ -114,5 +184,4 @@ const Headers = ({ collection }) => {
</StyledWrapper>
);
};
export default Headers;

View File

@@ -3,23 +3,15 @@ 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);
};
@@ -47,24 +39,9 @@ const Info = ({ collection }) => {
<IconWorld className="w-5 h-5 text-green-500" stroke={1.5} />
</div>
<div className="ml-4">
<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 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>
</div>
</div>

View File

@@ -1,29 +0,0 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
max-width: 800px;
.settings-label {
width: 110px;
}
.textbox {
border: 1px solid #ccc;
padding: 0.15rem 0.45rem;
box-shadow: none;
border-radius: 0px;
outline: none;
box-shadow: none;
transition: border-color ease-in-out 0.1s;
border-radius: 3px;
background-color: ${(props) => props.theme.modal.input.bg};
border: 1px solid ${(props) => props.theme.modal.input.border};
&:focus {
border: solid 1px ${(props) => props.theme.modal.input.focusBorder} !important;
outline: none !important;
}
}
`;
export default StyledWrapper;

View File

@@ -1,134 +0,0 @@
import React from 'react';
import { useDispatch } from 'react-redux';
import StyledWrapper from './StyledWrapper';
import { updateCollectionPresets } from 'providers/ReduxStore/slices/collections';
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
import { get } from 'lodash';
const PresetsSettings = ({ collection }) => {
const dispatch = useDispatch();
const initialPresets = { requestType: 'http', requestUrl: '' };
// Get presets from draft.brunoConfig if it exists, otherwise from brunoConfig
const currentPresets = collection.draft?.brunoConfig
? get(collection, 'draft.brunoConfig.presets', initialPresets)
: get(collection, 'brunoConfig.presets', initialPresets);
// Helper to update presets config
const updatePresets = (updates) => {
const updatedPresets = { ...currentPresets, ...updates };
dispatch(updateCollectionPresets({
collectionUid: collection.uid,
presets: updatedPresets
}));
};
const handleSave = () => dispatch(saveCollectionSettings(collection.uid));
const handleRequestTypeChange = (e) => {
updatePresets({ requestType: e.target.value });
};
const handleRequestUrlChange = (e) => {
updatePresets({ requestUrl: e.target.value });
};
return (
<StyledWrapper className="h-full w-full">
<div className="text-xs mb-4 mt-4 text-muted">
These presets will be used as the default values for new requests in this collection.
</div>
<div className="bruno-form">
<div className="mb-3 flex items-center">
<label className="settings-label flex items-center" htmlFor="http">
Request Type
</label>
<div className="flex items-center">
<input
id="http"
className="cursor-pointer"
type="radio"
name="requestType"
onChange={handleRequestTypeChange}
value="http"
checked={(currentPresets.requestType || 'http') === 'http'}
/>
<label htmlFor="http" className="ml-1 cursor-pointer select-none">
HTTP
</label>
<input
id="graphql"
className="ml-4 cursor-pointer"
type="radio"
name="requestType"
onChange={handleRequestTypeChange}
value="graphql"
checked={(currentPresets.requestType || 'http') === 'graphql'}
/>
<label htmlFor="graphql" className="ml-1 cursor-pointer select-none">
GraphQL
</label>
<input
id="grpc"
className="ml-4 cursor-pointer"
type="radio"
name="requestType"
onChange={handleRequestTypeChange}
value="grpc"
checked={(currentPresets.requestType || 'http') === 'grpc'}
/>
<label htmlFor="grpc" className="ml-1 cursor-pointer select-none">
gRPC
</label>
<input
id="ws"
className="ml-4 cursor-pointer"
type="radio"
name="requestType"
onChange={handleRequestTypeChange}
value="ws"
checked={(currentPresets.requestType || 'http') === 'ws'}
/>
<label htmlFor="ws" className="ml-1 cursor-pointer select-none">
WebSocket
</label>
</div>
</div>
<div className="mb-3 flex items-center">
<label className="settings-label" htmlFor="request-url">
Base URL
</label>
<div className="flex items-center w-full">
<div className="flex items-center flex-grow input-container h-full">
<input
id="request-url"
type="text"
name="requestUrl"
placeholder="Request URL"
className="block textbox"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
onChange={handleRequestUrlChange}
value={currentPresets.requestUrl || ''}
style={{ width: '100%' }}
/>
</div>
</div>
</div>
<div className="mt-6">
<button type="button" className="submit btn btn-sm btn-secondary" onClick={handleSave}>
Save
</button>
</div>
</div>
</StyledWrapper>
);
};
export default PresetsSettings;

View File

@@ -6,7 +6,7 @@ const StyledWrapper = styled.div`
padding: 6px 0px;
border: none;
border-bottom: solid 2px transparent;
margin-right: ${(props) => props.theme.tabs.marginRight};
margin-right: 1.25rem;
color: var(--color-tab-inactive);
cursor: pointer;
@@ -20,7 +20,6 @@ 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;
}

View File

@@ -1,81 +1,160 @@
import React, { useCallback } from 'react';
import React from 'react';
import cloneDeep from 'lodash/cloneDeep';
import { IconTrash } from '@tabler/icons';
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 { setCollectionVars } from 'providers/ReduxStore/slices/collections/index';
import {
addCollectionVar,
deleteCollectionVar,
updateCollectionVar
} 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;
const handleVarsChange = useCallback((updatedVars) => {
dispatch(setCollectionVars({ collectionUid: collection.uid, vars: updatedVars, type: varType }));
}, [dispatch, collection.uid, varType]);
if (variableNameRegex.test(value) === false) {
toast.error(
'Variable contains invalid characters! Variables must only contain alpha-numeric characters, "-", "_", "."'
);
return;
}
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, "-", "_", "."';
_var.name = value;
break;
}
case 'value': {
_var.value = e.target.value;
break;
}
case 'enabled': {
_var.enabled = e.target.checked;
break;
}
}
return null;
}, []);
dispatch(
updateCollectionVar({
type: varType,
var: _var,
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 } : {})
const handleRemoveVar = (_var) => {
dispatch(
deleteCollectionVar({
type: varType,
varUid: _var.uid,
collectionUid: collection.uid
})
);
};
return (
<StyledWrapper className="w-full">
<EditableTable
columns={columns}
rows={vars}
onChange={handleVarsChange}
defaultRow={defaultRow}
getRowError={getRowError}
/>
<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>
</StyledWrapper>
);
};
export default VarsTable;

View File

@@ -4,23 +4,18 @@ 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

View File

@@ -9,7 +9,6 @@ import Headers from './Headers';
import Auth from './Auth';
import Script from './Script';
import Test from './Tests';
import Presets from './Presets';
import Protobuf from './Protobuf';
import StyledWrapper from './StyledWrapper';
import Vars from './Vars/index';
@@ -33,34 +32,17 @@ 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 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 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 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 presets = collection.draft?.brunoConfig ? get(collection, 'draft.brunoConfig.presets', {}) : get(collection, 'brunoConfig.presets', {});
const hasPresets = presets && presets.requestUrl !== '';
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) {
@@ -82,14 +64,15 @@ const CollectionSettings = ({ collection }) => {
case 'tests': {
return <Test collection={collection} />;
}
case 'presets': {
return <Presets collection={collection} />;
}
case 'proxy': {
return <ProxySettings collection={collection} />;
}
case 'clientCert': {
return <ClientCertSettings collection={collection} />;
return (
<ClientCertSettings
collection={collection}
/>
);
}
case 'protobuf': {
return <Protobuf collection={collection} />;
@@ -129,10 +112,6 @@ const CollectionSettings = ({ collection }) => {
Tests
{hasTests && <StatusDot />}
</div>
<div className={getTabClassname('presets')} role="tab" onClick={() => setTab('presets')}>
Presets
{hasPresets && <StatusDot />}
</div>
<div className={getTabClassname('proxy')} role="tab" onClick={() => setTab('proxy')}>
Proxy
{Object.keys(proxyConfig).length > 0 && proxyEnabled && <StatusDot />}

View File

@@ -137,7 +137,6 @@ const CollectionProperties = ({ onClose }) => {
value={searchText || ''}
onChange={(e) => setSearchText(e.target.value)}
className="block textbox non-passphrase-input ml-auto font-normal"
autoFocus
/>
<button
type="submit"

View File

@@ -1,8 +0,0 @@
import styled from 'styled-components';
const Wrapper = styled.div`
position: relative;
display: inline-block;
`;
export default Wrapper;

View File

@@ -1,152 +0,0 @@
import React, { useMemo, useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import MenuDropdown from 'ui/MenuDropdown';
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';
import ActionIcon from 'ui/ActionIcon';
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 handleCreateHttpRequest = useCallback(async () => {
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'));
}, [dispatch, collection, itemUid, onRequestCreated]);
const handleCreateGraphQLRequest = useCallback(async () => {
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'));
}, [dispatch, collection, itemUid, onRequestCreated]);
const handleCreateWebSocketRequest = useCallback(async () => {
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'));
}, [dispatch, collection, itemUid, onRequestCreated]);
const handleCreateGrpcRequest = useCallback(async () => {
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'));
}, [dispatch, collection, itemUid, onRequestCreated]);
const menuItems = useMemo(() => [
{
id: 'http',
label: 'HTTP',
leftSection: <IconApi size={16} strokeWidth={2} />,
onClick: handleCreateHttpRequest
},
{
id: 'graphql',
label: 'GraphQL',
leftSection: <IconBrandGraphql size={16} strokeWidth={2} />,
onClick: handleCreateGraphQLRequest
},
{
id: 'websocket',
label: 'WebSocket',
leftSection: <IconPlugConnected size={16} strokeWidth={2} />,
onClick: handleCreateWebSocketRequest
},
{
id: 'grpc',
label: 'gRPC',
leftSection: <IconCode size={16} strokeWidth={2} />,
onClick: handleCreateGrpcRequest
}
], [handleCreateHttpRequest, handleCreateGraphQLRequest, handleCreateWebSocketRequest, handleCreateGrpcRequest]);
if (!collection) {
return null;
}
return (
<MenuDropdown
items={menuItems}
placement={placement}
autoFocusFirstOption={true}
>
<ActionIcon size="sm">
<IconPlus size={16} strokeWidth={2} />
</ActionIcon>
</MenuDropdown>
);
};
export default CreateUntitledRequest;

View File

@@ -168,7 +168,7 @@ const StyledWrapper = styled.div`
position: sticky;
top: 0;
z-index: 10;
td {
padding: 8px 12px;
font-weight: 500;
@@ -256,8 +256,10 @@ 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;
@@ -265,11 +267,13 @@ 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;
@@ -278,17 +282,28 @@ 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;

View File

@@ -7,7 +7,7 @@ import {
IconNetwork
} from '@tabler/icons';
import { clearSelectedRequest } from 'providers/ReduxStore/slices/logs';
import QueryResponse from 'components/ResponsePane/QueryResponse/index';
import QueryResult from 'components/ResponsePane/QueryResult';
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 ? (
<QueryResponse
<QueryResult
item={{ uid: uuid() }}
collection={collection}
data={response.data}

View File

@@ -25,22 +25,13 @@ 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;
padding: 0.375rem 0.625rem 0.25rem 0.625rem;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.025em;
color: ${(props) => props.theme.dropdown.color};
opacity: 0.6;
@@ -68,10 +59,6 @@ const Wrapper = styled.div`
}
}
.dropdown-label {
flex: 1;
}
.dropdown-icon {
flex-shrink: 0;
width: 16px;
@@ -83,31 +70,10 @@ 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;
@@ -136,10 +102,6 @@ const Wrapper = styled.div`
margin-top: 0.25rem;
padding-top: 0.375rem;
}
&.dropdown-item-select {
padding-left: 1.5rem;
}
}
.dropdown-separator {

View File

@@ -1,154 +0,0 @@
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;
&:last-child {
border-right: none;
}
}
}
&.has-checkbox thead td:nth-child(1) {
width: 25px !important;
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;
&:last-child {
border-right: none;
}
}
}
}
&.has-checkbox tbody td: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;
}
}
.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;

View File

@@ -1,318 +0,0 @@
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 className={showCheckbox ? 'has-checkbox' : 'no-checkbox'}>
<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;

View File

@@ -2,55 +2,46 @@ import styled from 'styled-components';
const Wrapper = styled.div`
.current-environment {
border-radius: ${(props) => props.theme.border.radius.base};
padding: 0.25rem 0.3rem 0.25rem 0.5rem;
border-radius: 0.9375rem;
padding: 0.25rem 0.5rem 0.25rem 0.75rem;
user-select: none;
background-color: ${(props) => props.theme.app.collection.toolbar.environmentSelector.bg};
border: 1px solid ${(props) => props.theme.app.collection.toolbar.environmentSelector.border};
background-color: transparent;
border: 1px solid ${(props) => props.theme.dropdown.selectedColor};
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: ${(props) => props.theme.app.collection.toolbar.environmentSelector.caret};
fill: ${(props) => props.theme.app.collection.toolbar.environmentSelector.caret};
align-self: center;
color: rgb(140, 140, 140);
fill: rgb(140, 140, 140);
}
.env-icon {
margin-right: 0.25rem;
color: ${(props) => props.theme.app.collection.toolbar.environmentSelector.icon};
color: ${(props) => props.theme.dropdown.selectedColor};
}
.env-text {
color: ${(props) => props.theme.app.collection.toolbar.environmentSelector.text};
color: ${(props) => props.theme.dropdown.selectedColor};
font-size: ${(props) => props.theme.font.size.base};
display: block;
}
.env-separator {
color: ${(props) => props.theme.app.collection.toolbar.environmentSelector.separator};
margin: 0 0.35rem;
color: #8c8c8c;
margin: 0 0.25rem;
opacity: 0.7;
}
.env-text-inactive {
color: ${(props) => props.theme.colors.text.muted};
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.dropdown.color};
font-size: ${(props) => props.theme.font.size.base};
opacity: 0.7;
}
&.no-environments {
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};
}
background-color: ${(props) => props.theme.sidebar.badge.bg};
border: 1px solid transparent;
color: ${(props) => props.theme.dropdown.secondaryText};
}
}

View File

@@ -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, updateGlobalEnvironmentSettingsModalVisibility } from 'providers/ReduxStore/slices/app';
import { updateEnvironmentSettingsModalVisibility } 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,6 +20,8 @@ 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);
@@ -27,8 +29,6 @@ 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,8 +79,9 @@ const EnvironmentSelector = ({ collection }) => {
const handleSettingsClick = () => {
if (activeTab === 'collection') {
dispatch(updateEnvironmentSettingsModalVisibility(true));
setShowCollectionSettings(true);
} else {
dispatch(updateGlobalEnvironmentSettingsModalVisibility(true));
setShowGlobalSettings(true);
}
dropdownTippyRef.current.hide();
};
@@ -107,8 +108,9 @@ 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.
@@ -162,7 +164,7 @@ const EnvironmentSelector = ({ collection }) => {
)}
</>
) : (
<span className="env-text-inactive max-w-36 truncate no-wrap">No Environment</span>
<span className="env-text-inactive max-w-36 truncate no-wrap">No environments</span>
);
return (
@@ -174,7 +176,7 @@ const EnvironmentSelector = ({ collection }) => {
data-testid="environment-selector-trigger"
>
{displayContent}
<IconCaretDown className="caret flex items-center justify-center" size={12} strokeWidth={2} />
<IconCaretDown className="caret" size={14} strokeWidth={2} />
</div>
);
});
@@ -218,7 +220,7 @@ const EnvironmentSelector = ({ collection }) => {
</div>
{/* Modals - Rendered outside dropdown to avoid conflicts */}
{isGlobalEnvironmentSettingsModalOpen && (
{showGlobalSettings && (
<GlobalEnvironmentSettings
globalEnvironments={globalEnvironments}
collection={collection}
@@ -227,15 +229,13 @@ const EnvironmentSelector = ({ collection }) => {
/>
)}
{isEnvironmentSettingsModalOpen && (
<EnvironmentSettings collection={collection} onClose={handleCloseSettings} />
)}
{showCollectionSettings && <EnvironmentSettings collection={collection} onClose={handleCloseSettings} />}
{showCreateGlobalModal && (
<CreateGlobalEnvironment
onClose={() => setShowCreateGlobalModal(false)}
onEnvironmentCreated={() => {
dispatch(updateGlobalEnvironmentSettingsModalVisibility(true));
setShowGlobalSettings(true);
}}
/>
)}
@@ -245,7 +245,7 @@ const EnvironmentSelector = ({ collection }) => {
type="global"
onClose={() => setShowImportGlobalModal(false)}
onEnvironmentCreated={() => {
dispatch(updateGlobalEnvironmentSettingsModalVisibility(true));
setShowGlobalSettings(true);
}}
/>
)}
@@ -255,7 +255,7 @@ const EnvironmentSelector = ({ collection }) => {
collection={collection}
onClose={() => setShowCreateCollectionModal(false)}
onEnvironmentCreated={() => {
dispatch(updateEnvironmentSettingsModalVisibility(true));
setShowCollectionSettings(true);
}}
/>
)}
@@ -266,7 +266,7 @@ const EnvironmentSelector = ({ collection }) => {
collection={collection}
onClose={() => setShowImportCollectionModal(false)}
onEnvironmentCreated={() => {
dispatch(updateEnvironmentSettingsModalVisibility(true));
setShowCollectionSettings(true);
}}
/>
)}

View File

@@ -11,7 +11,6 @@ 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) {
@@ -59,8 +58,8 @@ const Wrapper = styled.div`
input[type='checkbox'] {
cursor: pointer;
vertical-align: middle;
margin: 0;
position: relative;
top: 1px;
}
`;

View File

@@ -55,30 +55,16 @@ const FilePickerEditor = ({ value, onChange, collection, isSingleFilePicker = fa
return filenames.length > 0 ? (
<div
className={buttonClass}
style={{
fontWeight: 400,
width: '100%',
display: 'flex',
alignItems: 'center',
overflow: 'hidden'
}}
style={{ fontWeight: 400, width: '100%', textOverflow: 'ellipsis', overflowX: 'hidden' }}
title={title}
>
{!readOnly && (
<button className="align-middle" onClick={clear} style={{ flexShrink: 0 }}>
<button className="align-middle" onClick={clear}>
<IconX size={18} />
</button>
)}
{!readOnly && <>&nbsp;</>}
<span style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flex: 1
}}
>
{renderButtonText(filenames)}
</span>
{renderButtonText(filenames)}
</div>
) : (
<button className={buttonClass} style={{ width: '100%' }} onClick={!readOnly ? browse : undefined} disabled={readOnly}>

View File

@@ -206,7 +206,7 @@ const Auth = ({ collection, folder }) => {
Configures authentication for the entire folder. This applies to all requests using the{' '}
<span className="font-medium">Inherit</span> option in the <span className="font-medium">Auth</span> tab.
</div>
<div className="flex flex-grow justify-start items-center">
<div className="flex flex-grow justify-start items-center mb-4">
<AuthMode collection={collection} folder={folder} />
</div>
{getAuthView()}

View File

@@ -1,20 +1,16 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
font-size: ${(props) => props.theme.font.size.base};
.auth-mode-selector {
background: transparent;
.auth-mode-label {
color: ${(props) => props.theme.colors.text.yellow};
.caret {
color: rgb(140, 140, 140);
fill: rgb(140, 140, 140);
}
border: 1px solid ${({ theme }) => theme.colors.border};
padding: 4px 8px;
border-radius: 4px;
font-size: ${(props) => props.theme.font.size.base};
}
.auth-mode-label {
color: ${({ theme }) => theme.colors.text};
}
}
`;
export default StyledWrapper;

View File

@@ -1,7 +1,7 @@
import React, { useMemo, useCallback } from 'react';
import React, { useRef, forwardRef } from 'react';
import get from 'lodash/get';
import { IconCaretDown } from '@tabler/icons';
import MenuDropdown from 'ui/MenuDropdown';
import Dropdown from 'components/Dropdown';
import { useDispatch } from 'react-redux';
import { updateFolderAuthMode } from 'providers/ReduxStore/slices/collections';
import { humanizeRequestAuthMode } from 'utils/collections';
@@ -9,9 +9,19 @@ import StyledWrapper from './StyledWrapper';
const AuthMode = ({ collection, folder }) => {
const dispatch = useDispatch();
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const authMode = folder.draft ? get(folder, 'draft.request.auth.mode') : get(folder, 'root.request.auth.mode');
const onModeChange = useCallback((value) => {
const Icon = forwardRef((props, ref) => {
return (
<div ref={ref} className="flex items-center justify-center auth-mode-label select-none">
{humanizeRequestAuthMode(authMode)} <IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
</div>
);
});
const onModeChange = (value) => {
dispatch(
updateFolderAuthMode({
mode: value,
@@ -19,74 +29,103 @@ const AuthMode = ({ collection, folder }) => {
folderUid: folder.uid
})
);
}, [dispatch, collection.uid, folder.uid]);
const menuItems = useMemo(() => [
{
id: 'awsv4',
label: 'AWS Sig v4',
onClick: () => onModeChange('awsv4')
},
{
id: 'basic',
label: 'Basic Auth',
onClick: () => onModeChange('basic')
},
{
id: 'bearer',
label: 'Bearer Token',
onClick: () => onModeChange('bearer')
},
{
id: 'digest',
label: 'Digest Auth',
onClick: () => onModeChange('digest')
},
{
id: 'ntlm',
label: 'NTLM Auth',
onClick: () => onModeChange('ntlm')
},
{
id: 'oauth2',
label: 'OAuth 2.0',
onClick: () => onModeChange('oauth2')
},
{
id: 'wsse',
label: 'WSSE Auth',
onClick: () => onModeChange('wsse')
},
{
id: 'apikey',
label: 'API Key',
onClick: () => onModeChange('apikey')
},
{
id: 'inherit',
label: 'Inherit',
onClick: () => onModeChange('inherit')
},
{
id: 'none',
label: 'No Auth',
onClick: () => onModeChange('none')
}
], [onModeChange]);
};
return (
<StyledWrapper>
<div className="inline-flex items-center cursor-pointer auth-mode-selector">
<MenuDropdown
items={menuItems}
placement="bottom-end"
selectedItemId={authMode}
showTickMark={true}
>
<div className="flex items-center justify-center auth-mode-label select-none">
{humanizeRequestAuthMode(authMode)} <IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
<div className="inline-flex items-center cursor-pointer">
<Dropdown onCreate={onDropdownCreate} icon={<Icon />} placement="bottom-end">
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('awsv4');
}}
>
AWS Sig v4
</div>
</MenuDropdown>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('basic');
}}
>
Basic Auth
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('bearer');
}}
>
Bearer Token
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('digest');
}}
>
Digest Auth
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('ntlm');
}}
>
NTLM Auth
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('oauth2');
}}
>
OAuth 2.0
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('wsse');
}}
>
WSSE Auth
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('apikey');
}}
>
API Key
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('inherit');
}}
>
Inherit
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('none');
}}
>
No Auth
</div>
</Dropdown>
</div>
</StyledWrapper>
);

View File

@@ -1,82 +1,76 @@
import React, { useState, useCallback } from 'react';
import React, { useState } 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 { setFolderHeaders } from 'providers/ReduxStore/slices/collections';
import { addFolderHeader, updateFolderHeader, deleteFolderHeader, 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 handleHeadersChange = useCallback((updatedHeaders) => {
dispatch(setFolderHeaders({
collectionUid: collection.uid,
folderUid: folder.uid,
headers: updatedHeaders
}));
}, [dispatch, collection.uid, folder.uid]);
const handleBulkHeadersChange = (newHeaders) => {
dispatch(setFolderHeaders({ collectionUid: collection.uid, folderUid: folder.uid, headers: newHeaders }));
};
const addHeader = () => {
dispatch(
addFolderHeader({
collectionUid: collection.uid,
folderUid: folder.uid
})
);
};
const handleSave = () => dispatch(saveFolderRoot(collection.uid, 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 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 defaultRow = {
name: '',
value: '',
description: ''
const handleRemoveHeader = (header) => {
dispatch(
deleteFolderHeader({
headerUid: header.uid,
collectionUid: collection.uid,
folderUid: folder.uid
})
);
};
if (isBulkEditMode) {
@@ -87,7 +81,7 @@ const Headers = ({ collection, folder }) => {
</div>
<BulkEditor
params={headers}
onChange={handleHeadersChange}
onChange={handleBulkHeadersChange}
onToggle={toggleBulkEditMode}
onSave={handleSave}
/>
@@ -100,17 +94,87 @@ 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>
<EditableTable
columns={columns}
rows={headers}
onChange={handleHeadersChange}
defaultRow={defaultRow}
/>
<div className="flex justify-end mt-2">
<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>
<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
@@ -119,5 +183,4 @@ const Headers = ({ collection, folder }) => {
</StyledWrapper>
);
};
export default Headers;

View File

@@ -8,7 +8,7 @@ const StyledWrapper = styled.div`
padding: 6px 0px;
border: none;
border-bottom: solid 2px transparent;
margin-right: ${(props) => props.theme.tabs.marginRight};
margin-right: 1.25rem;
color: var(--color-tab-inactive);
cursor: pointer;
@@ -22,7 +22,6 @@ 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;
}

View File

@@ -1,87 +1,160 @@
import React, { useCallback } from 'react';
import React from 'react';
import cloneDeep from 'lodash/cloneDeep';
import { IconTrash } from '@tabler/icons';
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 { setFolderVars } from 'providers/ReduxStore/slices/collections/index';
import { addFolderVar, deleteFolderVar, updateFolderVar } 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;
const handleVarsChange = useCallback((updatedVars) => {
dispatch(setFolderVars({
collectionUid: collection.uid,
folderUid: folder.uid,
vars: updatedVars,
type: varType
}));
}, [dispatch, collection.uid, folder.uid, varType]);
if (variableNameRegex.test(value) === false) {
toast.error(
'Variable contains invalid characters! Variables must only contain alpha-numeric characters, "-", "_", "."'
);
return;
}
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, "-", "_", "."';
_var.name = value;
break;
}
case 'value': {
_var.value = e.target.value;
break;
}
case 'enabled': {
_var.enabled = e.target.checked;
break;
}
}
return null;
}, []);
dispatch(
updateFolderVar({
type: varType,
var: _var,
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 } : {})
const handleRemoveVar = (_var) => {
dispatch(
deleteFolderVar({
type: varType,
varUid: _var.uid,
folderUid: folder.uid,
collectionUid: collection.uid
})
);
};
return (
<StyledWrapper className="w-full">
<EditableTable
columns={columns}
rows={vars}
onChange={handleVarsChange}
defaultRow={defaultRow}
getRowError={getRowError}
/>
<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>
</StyledWrapper>
);
};
export default VarsTable;

View File

@@ -8,19 +8,13 @@ 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

View File

@@ -28,8 +28,7 @@ const FolderSettings = ({ collection, folder }) => {
const activeHeadersCount = headers.filter((header) => header.enabled).length;
const requestVars = folderRoot?.request?.vars?.req || [];
const responseVars = folderRoot?.request?.vars?.res || [];
const activeVarsCount = requestVars.filter((v) => v.enabled).length + responseVars.filter((v) => v.enabled).length;
const activeVarsCount = requestVars.filter((v) => v.enabled).length;
const auth = get(folderRoot, 'request.auth.mode');
const hasAuth = auth && auth !== 'none';

View File

@@ -11,7 +11,6 @@ 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) {
@@ -59,8 +58,8 @@ const Wrapper = styled.div`
input[type='checkbox'] {
cursor: pointer;
vertical-align: middle;
margin: 0;
position: relative;
top: 1px;
}
`;

View File

@@ -1,16 +1,16 @@
import React from 'react';
const ExampleIcon = ({ color = 'currentColor', size = 16, ...props }) => {
const ExampleIcon = ({ color = 'white', size = 16, ...props }) => {
return (
<svg width={size} height={size} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<g clipPath="url(#clip0_486_1191)">
<path d="M2.66699 3.33329C2.66699 3.15648 2.73723 2.98691 2.86225 2.86189C2.98728 2.73686 3.15685 2.66663 3.33366 2.66663H12.667C12.8438 2.66663 13.0134 2.73686 13.1384 2.86189C13.2634 2.98691 13.3337 3.15648 13.3337 3.33329V12.6666C13.3337 12.8434 13.2634 13.013 13.1384 13.138C13.0134 13.2631 12.8438 13.3333 12.667 13.3333H3.33366C3.15685 13.3333 2.98728 13.2631 2.86225 13.138C2.73723 13.013 2.66699 12.8434 2.66699 12.6666V3.33329Z" stroke={color} strokeWidth="1" strokeLinecap="round" strokeLinejoin="round" />
<path d="M9.33366 5.33337H6.66699V10.6667H9.33366" stroke={color} strokeWidth="1" strokeLinecap="round" strokeLinejoin="round" />
<path d="M9.33366 8H6.66699" stroke={color} strokeWidth="1" strokeLinecap="round" strokeLinejoin="round" />
<path d="M2.66699 3.33329C2.66699 3.15648 2.73723 2.98691 2.86225 2.86189C2.98728 2.73686 3.15685 2.66663 3.33366 2.66663H12.667C12.8438 2.66663 13.0134 2.73686 13.1384 2.86189C13.2634 2.98691 13.3337 3.15648 13.3337 3.33329V12.6666C13.3337 12.8434 13.2634 13.013 13.1384 13.138C13.0134 13.2631 12.8438 13.3333 12.667 13.3333H3.33366C3.15685 13.3333 2.98728 13.2631 2.86225 13.138C2.73723 13.013 2.66699 12.8434 2.66699 12.6666V3.33329Z" stroke={color} stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round" />
<path d="M9.33366 5.33337H6.66699V10.6667H9.33366" stroke={color} stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round" />
<path d="M9.33366 8H6.66699" stroke={color} stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round" />
</g>
<defs>
<clipPath id="clip0_486_1191">
<rect width={size} height={size} fill={color} />
<rect width={size} height={size} fill="white" />
</clipPath>
</defs>
</svg>

View File

@@ -1,16 +0,0 @@
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;

View File

@@ -1,55 +0,0 @@
import React, { useState } from 'react';
import Portal from 'components/Portal/index';
import Modal from 'components/Modal/index';
import toast from 'react-hot-toast';
import { useDispatch } from 'react-redux';
import { IconFolder } from '@tabler/icons';
import { closeWorkspaceAction } from 'providers/ReduxStore/slices/workspaces/actions';
const DeleteWorkspace = ({ onClose, workspace }) => {
const dispatch = useDispatch();
const [isDeleting, setIsDeleting] = useState(false);
const onConfirm = async () => {
if (isDeleting) return;
try {
setIsDeleting(true);
await dispatch(closeWorkspaceAction(workspace.uid));
onClose();
} catch (error) {
toast.error(error?.message || 'An error occurred while removing the workspace');
setIsDeleting(false);
}
};
return (
<Portal>
<Modal
size="sm"
title="Remove Workspace"
confirmText={isDeleting ? 'Removing...' : 'Remove'}
handleConfirm={onConfirm}
handleCancel={onClose}
confirmDisabled={isDeleting}
confirmButtonClass="btn-danger"
>
<div className="flex items-center">
<IconFolder size={18} strokeWidth={1.5} />
<span className="ml-2 mr-4 font-semibold">{workspace?.name}</span>
</div>
{workspace?.pathname && (
<div className="break-words text-xs mt-1">{workspace.pathname}</div>
)}
<div className="mt-4">
Are you sure you want to remove workspace <span className="font-semibold">{workspace?.name}</span>?
</div>
<div className="mt-4">
The workspace will still be available in the file system and can be re-opened later.
</div>
</Modal>
</Portal>
);
};
export default DeleteWorkspace;

View File

@@ -1,95 +0,0 @@
import React, { useEffect, useRef } from 'react';
import Portal from 'components/Portal/index';
import Modal from 'components/Modal/index';
import toast from 'react-hot-toast';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import { useDispatch, useSelector } from 'react-redux';
import { renameWorkspaceAction } from 'providers/ReduxStore/slices/workspaces/actions';
const RenameWorkspace = ({ onClose, workspace }) => {
const dispatch = useDispatch();
const { workspaces } = useSelector((state) => state.workspaces);
const inputRef = useRef();
const formik = useFormik({
enableReinitialize: true,
initialValues: {
name: workspace.name
},
validationSchema: Yup.object({
name: Yup.string()
.min(1, 'must be at least 1 character')
.max(255, 'must be 255 characters or less')
.required('name is required')
.test('unique-name', 'A workspace with this name already exists', function (value) {
if (!value) return true;
return !workspaces.some((w) =>
w.uid !== workspace.uid && w.name.toLowerCase() === value.toLowerCase()
);
})
}),
onSubmit: (values) => {
if (values.name === workspace.name) {
onClose();
return;
}
dispatch(renameWorkspaceAction(workspace.uid, values.name))
.then(() => {
onClose();
})
.catch((error) => {
toast.error(error?.message || 'An error occurred while renaming the workspace');
});
}
});
useEffect(() => {
if (inputRef && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [inputRef]);
const onSubmit = () => {
formik.handleSubmit();
};
return (
<Portal>
<Modal
size="sm"
title="Rename Workspace"
confirmText="Rename"
handleConfirm={onSubmit}
handleCancel={onClose}
>
<form className="bruno-form" onSubmit={(e) => e.preventDefault()}>
<div>
<label htmlFor="workspace-name" className="block font-semibold">
Workspace Name
</label>
<input
id="workspace-name"
type="text"
name="name"
ref={inputRef}
className="block textbox mt-2 w-full"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
onChange={formik.handleChange}
value={formik.values.name || ''}
/>
{formik.touched.name && formik.errors.name ? (
<div className="text-red-500">{formik.errors.name}</div>
) : null}
</div>
</form>
</Modal>
</Portal>
);
};
export default RenameWorkspace;

View File

@@ -1,175 +0,0 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
height: 100%;
display: flex;
flex-direction: column;
.manage-workspace-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid ${(props) => props.theme.workspace.border};
}
.header-left {
display: flex;
align-items: center;
gap: 8px;
}
.back-button {
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
cursor: pointer;
color: ${(props) => props.theme.text};
}
.header-title {
font-size: 15px;
font-weight: 600;
color: ${(props) => props.theme.text};
}
.create-workspace-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: ${(props) => props.theme.border.radius.base};
background: ${(props) => props.theme.workspace.accent};
color: white;
font-size: ${(props) => props.theme.font.size.sm};
font-weight: 500;
cursor: pointer;
border: none;
}
.workspace-list {
flex: 1;
overflow-y: auto;
padding: 0 16px;
}
.workspace-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 0;
border-bottom: 1px solid ${(props) => props.theme.workspace.border};
}
.workspace-info {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
min-width: 0;
}
.workspace-name-row {
display: flex;
align-items: center;
gap: 6px;
}
.workspace-icon {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
&.default {
color: ${(props) => props.theme.colors.text.muted};
}
&.regular {
color: ${(props) => props.theme.workspace.accent};
}
}
.workspace-name {
font-size: ${(props) => props.theme.font.size.md};
font-weight: 500;
color: ${(props) => props.theme.text};
}
.default-badge {
padding: 1px 6px;
border-radius: ${(props) => props.theme.border.radius.sm};
background: ${(props) => props.theme.sidebar.badge.bg};
color: ${(props) => props.theme.colors.text.muted};
font-size: ${(props) => props.theme.font.size.xs};
font-weight: 500;
}
.workspace-path {
font-size: ${(props) => props.theme.font.size.xs};
color: ${(props) => props.theme.colors.text.muted};
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.workspace-actions {
display: flex;
align-items: center;
gap: 8px;
}
.action-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
background: transparent;
border: none;
color: ${(props) => props.theme.text};
font-size: ${(props) => props.theme.font.size.xs};
cursor: pointer;
}
.more-actions-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
background: transparent;
border: none;
color: ${(props) => props.theme.text};
cursor: pointer;
}
.dropdown-menu {
min-width: 120px;
}
.dropdown-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
cursor: pointer;
color: ${(props) => props.theme.text};
font-size: ${(props) => props.theme.font.size.sm};
&.danger {
color: ${(props) => props.theme.colors.text.danger};
}
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 200px;
color: ${(props) => props.theme.colors.text.muted};
font-size: ${(props) => props.theme.font.size.sm};
}
`;
export default StyledWrapper;

View File

@@ -1,162 +0,0 @@
import React, { useState, useMemo } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { IconArrowLeft, IconPlus, IconFolder, IconLock, IconDots, IconCategory, IconLogin } from '@tabler/icons';
import toast from 'react-hot-toast';
import { showHomePage } from 'providers/ReduxStore/slices/app';
import { switchWorkspace } from 'providers/ReduxStore/slices/workspaces/actions';
import { showInFolder } from 'providers/ReduxStore/slices/collections/actions';
import { sortWorkspaces } from 'utils/workspaces';
import CreateWorkspace from 'components/WorkspaceSidebar/CreateWorkspace';
import RenameWorkspace from './RenameWorkspace';
import DeleteWorkspace from './DeleteWorkspace';
import StyledWrapper from './StyledWrapper';
import MenuDropdown from 'ui/MenuDropdown/index';
const ManageWorkspace = () => {
const dispatch = useDispatch();
const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces);
const preferences = useSelector((state) => state.app.preferences);
const [createWorkspaceModalOpen, setCreateWorkspaceModalOpen] = useState(false);
const [renameWorkspaceModal, setRenameWorkspaceModal] = useState({ open: false, workspace: null });
const [deleteWorkspaceModal, setDeleteWorkspaceModal] = useState({ open: false, workspace: null });
const sortedWorkspaces = useMemo(() => {
return sortWorkspaces(workspaces, preferences);
}, [workspaces, preferences]);
const handleBack = () => {
dispatch(showHomePage());
};
const handleOpenWorkspace = (workspace) => {
dispatch(switchWorkspace(workspace.uid));
dispatch(showHomePage());
toast.success(`Switched to ${workspace.name}`);
};
const handleShowInFolder = (workspace) => {
if (workspace.pathname) {
dispatch(showInFolder(workspace.pathname)).catch(() => {
toast.error('Error opening the folder');
});
}
};
const handleRenameClick = (workspace) => {
setRenameWorkspaceModal({ open: true, workspace });
};
const handleCloseClick = (workspace) => {
if (workspace.type === 'default') {
toast.error('Cannot remove the default workspace');
return;
}
setDeleteWorkspaceModal({ open: true, workspace });
};
return (
<StyledWrapper>
{createWorkspaceModalOpen && (
<CreateWorkspace onClose={() => setCreateWorkspaceModalOpen(false)} />
)}
{renameWorkspaceModal.open && renameWorkspaceModal.workspace && (
<RenameWorkspace
workspace={renameWorkspaceModal.workspace}
onClose={() => setRenameWorkspaceModal({ open: false, workspace: null })}
/>
)}
{deleteWorkspaceModal.open && deleteWorkspaceModal.workspace && (
<DeleteWorkspace
workspace={deleteWorkspaceModal.workspace}
onClose={() => setDeleteWorkspaceModal({ open: false, workspace: null })}
/>
)}
<div className="manage-workspace-header">
<div className="header-left">
<div className="back-button" onClick={handleBack}>
<IconArrowLeft size={18} strokeWidth={1.5} />
</div>
<span className="header-title">Manage Workspace</span>
</div>
<button className="create-workspace-btn" onClick={() => setCreateWorkspaceModalOpen(true)}>
<IconPlus size={14} strokeWidth={2} />
<span>Create Workspace</span>
</button>
</div>
<div className="workspace-list">
{sortedWorkspaces.length === 0 ? (
<div className="empty-state">
<span>No workspaces found</span>
</div>
) : (
sortedWorkspaces.map((workspace) => {
const isDefault = workspace.type === 'default';
const isActive = workspace.uid === activeWorkspaceUid;
return (
<div key={workspace.uid} className="workspace-item">
<div className="workspace-info">
<div className="workspace-name-row">
<span className={`workspace-icon ${isDefault ? 'default' : 'regular'}`}>
{isDefault ? (
<IconLock size={14} strokeWidth={1.5} />
) : (
<IconCategory size={14} strokeWidth={1.5} />
)}
</span>
<span className="workspace-name">{workspace.name}</span>
{isDefault && <span className="default-badge">Default</span>}
</div>
{workspace.pathname && (
<div className="workspace-path">{workspace.pathname}</div>
)}
</div>
<div className="workspace-actions">
<button
className="action-btn"
onClick={() => handleOpenWorkspace(workspace)}
>
<IconLogin size={14} strokeWidth={1.5} />
<span>Open</span>
</button>
{workspace.pathname && workspace.type !== 'default' && (
<button
className="action-btn"
onClick={() => handleShowInFolder(workspace)}
>
<IconFolder size={14} strokeWidth={1.5} />
<span>Show in folder</span>
</button>
)}
{!isDefault && (
<MenuDropdown
placement="bottom-end"
items={[
{ id: 'rename', label: 'Rename', onClick: () => handleRenameClick(workspace) },
{ id: 'remove', label: 'Remove', onClick: () => handleCloseClick(workspace) }
]}
>
<button className="more-actions-btn">
<IconDots size={14} strokeWidth={1.5} />
</button>
</MenuDropdown>
)}
</div>
</div>
);
})
)}
</div>
</StyledWrapper>
);
};
export default ManageWorkspace;

View File

@@ -88,9 +88,7 @@ const Modal = ({
return closeModal({ type: 'esc' });
}
case ENTER_KEY_CODE: {
// 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) {
if (!shiftKey && !ctrlKey && !altKey && !metaKey && handleConfirm) {
return handleConfirm();
}
}

View File

@@ -56,9 +56,6 @@ const General = ({ close }) => {
}
return true;
}),
oauth2: Yup.object({
useSystemBrowser: Yup.boolean()
}),
defaultCollectionLocation: Yup.string().max(1024)
});
@@ -79,9 +76,6 @@ const General = ({ close }) => {
enabled: get(preferences, 'autoSave.enabled', false),
interval: get(preferences, 'autoSave.interval', 1000)
},
oauth2: {
useSystemBrowser: get(preferences, 'request.oauth2.useSystemBrowser', false)
},
defaultCollectionLocation: get(preferences, 'general.defaultCollectionLocation', '')
},
validationSchema: preferencesSchema,
@@ -110,10 +104,7 @@ const General = ({ close }) => {
},
timeout: newPreferences.timeout,
storeCookies: newPreferences.storeCookies,
sendCookies: newPreferences.sendCookies,
oauth2: {
useSystemBrowser: newPreferences.oauth2.useSystemBrowser
}
sendCookies: newPreferences.sendCookies
},
autoSave: {
enabled: newPreferences.autoSave.enabled,
@@ -267,19 +258,6 @@ const General = ({ close }) => {
Send Cookies automatically
</label>
</div>
<div className="flex items-center mt-2">
<input
id="oauth2.useSystemBrowser"
type="checkbox"
name="oauth2.useSystemBrowser"
checked={formik.values.oauth2.useSystemBrowser}
onChange={formik.handleChange}
className="mousetrap mr-0"
/>
<label className="block ml-2 select-none" htmlFor="oauth2.useSystemBrowser">
Use System Browser for OAuth2 Authorization
</label>
</div>
<div className="flex flex-col mt-6">
<label className="block select-none" htmlFor="timeout">
Request Timeout (in ms)

View File

@@ -1,171 +1,122 @@
import React, { useCallback } from 'react';
import React from 'react';
import get from 'lodash/get';
import cloneDeep from 'lodash/cloneDeep';
import { useDispatch } from 'react-redux';
import { useTheme } from 'providers/Theme';
import { moveAssertion, setRequestAssertions } from 'providers/ReduxStore/slices/collections';
import { addAssertion, updateAssertion, deleteAssertion } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import SingleLineEditor from 'components/SingleLineEditor';
import AssertionOperator from './AssertionOperator';
import EditableTable from 'components/EditableTable';
import AssertionRow from './AssertionRow';
import StyledWrapper from './StyledWrapper';
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);
import Table from 'components/Table/index';
import ReorderTable from 'components/ReorderTable/index';
import { moveAssertion } from 'providers/ReduxStore/slices/collections/index';
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 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}
/>
);
const handleAssertionChange = (e, _assertion, type) => {
const assertion = cloneDeep(_assertion);
switch (type) {
case 'name': {
assertion.name = e.target.value;
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' : ''}
/>
);
case 'value': {
assertion.value = e.target.value;
break;
}
case 'enabled': {
assertion.enabled = e.target.checked;
break;
}
}
];
dispatch(
updateAssertion({
assertion: assertion,
itemUid: item.uid,
collectionUid: collection.uid
})
);
};
const defaultRow = {
name: '',
value: 'eq ',
operator: 'eq'
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
})
);
};
return (
<StyledWrapper className="w-full">
<EditableTable
columns={columns}
rows={assertions || []}
onChange={handleAssertionsChange}
defaultRow={defaultRow}
reorderable={true}
onReorder={handleAssertionDrag}
/>
<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>
</StyledWrapper>
);
};
export default Assertions;

View File

@@ -8,12 +8,20 @@ const Wrapper = styled.div`
.auth-mode-label {
color: ${(props) => props.theme.colors.text.yellow};
.caret {
color: rgb(140, 140, 140);
fill: rgb(140, 140, 140);
}
}
.dropdown-item {
padding: 0.2rem 0.6rem !important;
}
.label-item {
padding: 0.2rem 0.6rem !important;
}
}
.caret {
color: rgb(140, 140, 140);
fill: rgb(140 140 140);
}
`;

View File

@@ -1,7 +1,7 @@
import React, { useMemo, useCallback } from 'react';
import React, { useRef, forwardRef } from 'react';
import get from 'lodash/get';
import { IconCaretDown } from '@tabler/icons';
import MenuDropdown from 'ui/MenuDropdown';
import Dropdown from 'components/Dropdown';
import { useDispatch } from 'react-redux';
import { updateRequestAuthMode } from 'providers/ReduxStore/slices/collections';
import { humanizeRequestAuthMode } from 'utils/collections';
@@ -9,9 +9,19 @@ import StyledWrapper from './StyledWrapper';
const AuthMode = ({ item, collection }) => {
const dispatch = useDispatch();
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const authMode = item.draft ? get(item, 'draft.request.auth.mode') : get(item, 'request.auth.mode');
const onModeChange = useCallback((value) => {
const Icon = forwardRef((props, ref) => {
return (
<div ref={ref} className="flex items-center justify-center auth-mode-label select-none">
{humanizeRequestAuthMode(authMode)} <IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
</div>
);
});
const onModeChange = (value) => {
dispatch(
updateRequestAuthMode({
itemUid: item.uid,
@@ -19,74 +29,102 @@ const AuthMode = ({ item, collection }) => {
mode: value
})
);
}, [dispatch, item.uid, collection.uid]);
const menuItems = useMemo(() => [
{
id: 'awsv4',
label: 'AWS Sig v4',
onClick: () => onModeChange('awsv4')
},
{
id: 'basic',
label: 'Basic Auth',
onClick: () => onModeChange('basic')
},
{
id: 'bearer',
label: 'Bearer Token',
onClick: () => onModeChange('bearer')
},
{
id: 'digest',
label: 'Digest Auth',
onClick: () => onModeChange('digest')
},
{
id: 'ntlm',
label: 'NTLM Auth',
onClick: () => onModeChange('ntlm')
},
{
id: 'oauth2',
label: 'OAuth 2.0',
onClick: () => onModeChange('oauth2')
},
{
id: 'wsse',
label: 'WSSE Auth',
onClick: () => onModeChange('wsse')
},
{
id: 'apikey',
label: 'API Key',
onClick: () => onModeChange('apikey')
},
{
id: 'inherit',
label: 'Inherit',
onClick: () => onModeChange('inherit')
},
{
id: 'none',
label: 'No Auth',
onClick: () => onModeChange('none')
}
], [onModeChange]);
};
return (
<StyledWrapper>
<div className="inline-flex items-center cursor-pointer auth-mode-selector">
<MenuDropdown
items={menuItems}
placement="bottom-end"
selectedItemId={authMode}
showTickMark={true}
>
<div className="flex items-center justify-center auth-mode-label select-none">
{humanizeRequestAuthMode(authMode)} <IconCaretDown className="caret ml-1" size={14} strokeWidth={2} />
<Dropdown onCreate={onDropdownCreate} icon={<Icon />} placement="bottom-end">
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef?.current?.hide();
onModeChange('awsv4');
}}
>
AWS Sig v4
</div>
</MenuDropdown>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef?.current?.hide();
onModeChange('basic');
}}
>
Basic Auth
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef?.current?.hide();
onModeChange('bearer');
}}
>
Bearer Token
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef?.current?.hide();
onModeChange('digest');
}}
>
Digest Auth
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef?.current?.hide();
onModeChange('ntlm');
}}
>
NTLM Auth
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef?.current?.hide();
onModeChange('oauth2');
}}
>
OAuth 2.0
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef?.current?.hide();
onModeChange('wsse');
}}
>
WSSE Auth
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef?.current?.hide();
onModeChange('apikey');
}}
>
API Key
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef?.current?.hide();
onModeChange('inherit');
}}
>
Inherit
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef?.current?.hide();
onModeChange('none');
}}
>
No Auth
</div>
</Dropdown>
</div>
</StyledWrapper>
);

View File

@@ -2,7 +2,7 @@ import React, { useRef, forwardRef } from 'react';
import { useDetectSensitiveField } from 'hooks/useDetectSensitiveField';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch, useSelector } from 'react-redux';
import { useDispatch } from 'react-redux';
import { IconCaretDown, IconSettings, IconKey, IconHelp, IconAdjustmentsHorizontal } from '@tabler/icons';
import Dropdown from 'components/Dropdown';
import SingleLineEditor from 'components/SingleLineEditor';
@@ -12,14 +12,10 @@ import Oauth2TokenViewer from '../Oauth2TokenViewer/index';
import Oauth2ActionButtons from '../Oauth2ActionButtons/index';
import AdditionalParams from '../AdditionalParams/index';
import SensitiveFieldWarning from 'components/SensitiveFieldWarning';
import { savePreferences } from 'providers/ReduxStore/slices/app';
import toast from 'react-hot-toast';
const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAuth, collection, folder }) => {
const dispatch = useDispatch();
const preferences = useSelector((state) => state.app.preferences);
const { storedTheme } = useTheme();
const useSystemBrowser = get(preferences, 'request.oauth2.useSystemBrowser', false);
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const { isSensitive } = useDetectSensitiveField(collection);
@@ -126,29 +122,6 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu
);
};
const handleUseSystemBrowserToggle = (e) => {
const newValue = e.target.checked;
dispatch(
savePreferences({
...preferences,
request: {
...preferences.request,
oauth2: {
...preferences.request.oauth2,
useSystemBrowser: newValue
}
}
})
)
.then(() => {
toast.success('Preference updated successfully');
})
.catch((err) => {
console.error(err);
toast.error('Failed to update preference');
});
};
return (
<StyledWrapper className="mt-2 flex w-full gap-4 flex-col">
<Oauth2TokenViewer handleRun={handleRun} collection={collection} item={item} url={accessTokenUrl} credentialsId={credentialsId} />
@@ -160,43 +133,6 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu
Configuration
</span>
</div>
<div className="flex items-center gap-4 w-full" key="input-callbackUrl">
<label className="block min-w-[140px]">Callback URL</label>
<div className="flex flex-col gap-1 w-full">
<div className="single-line-editor-wrapper flex-1 flex items-center">
<SingleLineEditor
value={callbackUrl}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleChange('callbackUrl', val)}
onRun={handleRun}
collection={collection}
item={item}
placeholder={useSystemBrowser ? 'https://oauth2.usebruno.com/callback' : undefined}
/>
</div>
</div>
</div>
<div className="flex items-center gap-4 w-full" key="input-use-system-browser">
<label className="block min-w-[140px]"></label>
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={Boolean(useSystemBrowser)}
onChange={handleUseSystemBrowserToggle}
className="cursor-pointer"
/>
<label
className="block cursor-pointer"
onClick={(e) => {
e.preventDefault();
handleUseSystemBrowserToggle({ target: { checked: !useSystemBrowser } });
}}
>
Use system browser for OAuth
</label>
</div>
</div>
{inputsConfig.map((input) => {
const { key, label, isSecret } = input;
const value = oAuth[key] || '';

View File

@@ -1,4 +1,8 @@
const inputsConfig = [
{
key: 'callbackUrl',
label: 'Callback URL'
},
{
key: 'authorizationUrl',
label: 'Authorization URL'

View File

@@ -1,7 +1,7 @@
import React, { useRef, forwardRef, useMemo } from 'react';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch, useSelector } from 'react-redux';
import { useDispatch } from 'react-redux';
import { IconCaretDown, IconSettings, IconKey, IconHelp, IconAdjustmentsHorizontal } from '@tabler/icons';
import Dropdown from 'components/Dropdown';
import SingleLineEditor from 'components/SingleLineEditor';
@@ -12,13 +12,9 @@ import Oauth2ActionButtons from '../Oauth2ActionButtons/index';
import AdditionalParams from '../AdditionalParams/index';
import { getAllVariables } from 'utils/collections/index';
import { interpolate } from '@usebruno/common';
import { savePreferences } from 'providers/ReduxStore/slices/app';
import toast from 'react-hot-toast';
const OAuth2Implicit = ({ save, item = {}, request, handleRun, updateAuth, collection, folder }) => {
const dispatch = useDispatch();
const preferences = useSelector((state) => state.app.preferences);
const useSystemBrowser = get(preferences, 'request.oauth2.useSystemBrowser', false);
const { storedTheme } = useTheme();
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
@@ -81,29 +77,6 @@ const OAuth2Implicit = ({ save, item = {}, request, handleRun, updateAuth, colle
handleChange('autoFetchToken', e.target.checked);
};
const handleUseSystemBrowserToggle = (e) => {
const newValue = e.target.checked;
dispatch(
savePreferences({
...preferences,
request: {
...preferences.request,
oauth2: {
...preferences.request.oauth2,
useSystemBrowser: newValue
}
}
})
)
.then(() => {
toast.success('Preference updated successfully');
})
.catch((err) => {
console.error(err);
toast.error('Failed to update preference');
});
};
return (
<Wrapper className="mt-2 flex w-full gap-4 flex-col">
<Oauth2TokenViewer handleRun={handleRun} collection={collection} item={item} url={authorizationUrl} credentialsId={credentialsId} />
@@ -115,43 +88,6 @@ const OAuth2Implicit = ({ save, item = {}, request, handleRun, updateAuth, colle
Configuration
</span>
</div>
<div className="flex items-center gap-4 w-full" key="input-callbackUrl">
<label className="block min-w-[140px]">Callback URL</label>
<div className="flex flex-col gap-1 w-full">
<div className="oauth2-input-wrapper flex-1 flex items-center">
<SingleLineEditor
value={callbackUrl}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleChange('callbackUrl', val)}
onRun={handleRun}
collection={collection}
item={item}
placeholder={useSystemBrowser ? 'https://oauth2.usebruno.com/callback' : undefined}
/>
</div>
</div>
</div>
<div className="flex items-center gap-4 w-full" key="input-use-system-browser">
<label className="block min-w-[140px]"></label>
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={Boolean(useSystemBrowser)}
onChange={handleUseSystemBrowserToggle}
className="cursor-pointer"
/>
<label
className="block cursor-pointer"
onClick={(e) => {
e.preventDefault();
handleUseSystemBrowserToggle({ target: { checked: !useSystemBrowser } });
}}
>
Use system browser for OAuth
</label>
</div>
</div>
{inputsConfig.map((input) => {
const { key, label, isSecret } = input;
return (

View File

@@ -1,4 +1,8 @@
const inputsConfig = [
{
key: 'callbackUrl',
label: 'Callback URL'
},
{
key: 'authorizationUrl',
label: 'Authorization URL'

View File

@@ -1,36 +1,18 @@
import { useMemo, useState, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useMemo, useState } from 'react';
import { useDispatch } from 'react-redux';
import toast from 'react-hot-toast';
import { cloneDeep, find, get } from 'lodash';
import { IconLoader2, IconX } from '@tabler/icons';
import { cloneDeep, find } from 'lodash';
import { IconLoader2 } from '@tabler/icons';
import { interpolate } from '@usebruno/common';
import { fetchOauth2Credentials, clearOauth2Cache, refreshOauth2Credentials, cancelOauth2AuthorizationRequest, isOauth2AuthorizationRequestInProgress } from 'providers/ReduxStore/slices/collections/actions';
import { fetchOauth2Credentials, clearOauth2Cache, refreshOauth2Credentials } from 'providers/ReduxStore/slices/collections/actions';
import { getAllVariables } from 'utils/collections/index';
const Oauth2ActionButtons = ({ item, request, collection, url: accessTokenUrl, credentialsId }) => {
const { uid: collectionUid } = collection;
const dispatch = useDispatch();
const preferences = useSelector((state) => state.app.preferences);
const [fetchingToken, toggleFetchingToken] = useState(false);
const [refreshingToken, toggleRefreshingToken] = useState(false);
const [fetchingAuthorizationCode, toggleFetchingAuthorizationCode] = useState(false);
const useSystemBrowser = get(preferences, 'request.oauth2.useSystemBrowser', false);
// Check for pending authorization when component mounts or when fetching starts
useEffect(() => {
if (useSystemBrowser && fetchingToken) {
const getRequestStatus = async () => {
try {
toggleFetchingAuthorizationCode(await dispatch(isOauth2AuthorizationRequestInProgress()));
} catch (err) {
console.error('Error checking pending authorization:', err);
}
};
getRequestStatus();
}
}, [useSystemBrowser, fetchingToken, dispatch]);
const interpolatedAccessTokenUrl = useMemo(() => {
const variables = getAllVariables(collection, item);
@@ -53,6 +35,8 @@ const Oauth2ActionButtons = ({ item, request, collection, url: accessTokenUrl, c
forceGetToken: true
}));
toggleFetchingToken(false);
// Check if the result contains error or if access_token is missing
if (!result || !result.access_token) {
const errorMessage = result?.error || 'No access token received from authorization server';
@@ -65,14 +49,8 @@ const Oauth2ActionButtons = ({ item, request, collection, url: accessTokenUrl, c
} catch (error) {
console.error('could not fetch the token!');
console.error(error);
// Don't show error toast for user cancellation
if (error?.message && error.message.includes('cancelled by user')) {
return;
}
toast.error(error?.message || 'An error occurred while fetching token!');
} finally {
toggleFetchingToken(false);
toggleFetchingAuthorizationCode(false);
toast.error(error?.message || 'An error occurred while fetching token!');
}
};
@@ -117,20 +95,6 @@ const Oauth2ActionButtons = ({ item, request, collection, url: accessTokenUrl, c
});
};
const handleCancelAuthorization = async () => {
try {
const result = await dispatch(cancelOauth2AuthorizationRequest());
if (result.success && result.cancelled) {
toast.error('Authorization cancelled');
toggleFetchingToken(false);
toggleFetchingAuthorizationCode(false);
}
} catch (err) {
console.error('Error cancelling authorization:', err);
toast.error('Failed to cancel authorization');
}
};
return (
<div className="flex flex-row gap-4 mt-4">
<button
@@ -151,16 +115,6 @@ const Oauth2ActionButtons = ({ item, request, collection, url: accessTokenUrl, c
</button>
)
: null}
{useSystemBrowser && fetchingAuthorizationCode
? (
<button
onClick={handleCancelAuthorization}
className="submit btn btn-sm btn-secondary w-fit flex flex-row items-center"
>
<IconX size={16} className="mr-1" />
Cancel Authorization
</button>
) : null}
<button onClick={handleClearCache} className="submit btn btn-sm btn-secondary w-fit">
Clear Cache
</button>

View File

@@ -1,5 +1,6 @@
import React from 'react';
import get from 'lodash/get';
import AuthMode from './AuthMode';
import AwsV4Auth from './AwsV4Auth';
import BearerAuth from './BearerAuth';
import BasicAuth from './BasicAuth';
@@ -72,9 +73,6 @@ const Auth = ({ item, collection }) => {
const getAuthView = () => {
switch (authMode) {
case 'none': {
return <div className="mt-2">No Auth</div>;
}
case 'awsv4': {
return <AwsV4Auth collection={collection} item={item} request={request} save={save} updateAuth={updateAuth} />;
}
@@ -115,6 +113,9 @@ const Auth = ({ item, collection }) => {
return (
<StyledWrapper className="w-full mt-1 overflow-auto">
<div className="flex flex-grow justify-start items-center">
<AuthMode item={item} collection={collection} />
</div>
{getAuthView()}
</StyledWrapper>
);

View File

@@ -1,86 +1,153 @@
import React, { useCallback } from 'react';
import React 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 {
moveFormUrlEncodedParam,
setFormUrlEncodedParams
addFormUrlEncodedParam,
updateFormUrlEncodedParam,
deleteFormUrlEncodedParam,
moveFormUrlEncodedParam
} 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 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' : ''}
/>
)
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;
}
}
];
dispatch(
updateFormUrlEncodedParam({
param: param,
itemUid: item.uid,
collectionUid: collection.uid
})
);
};
const defaultRow = {
name: '',
value: '',
description: ''
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
})
);
};
return (
<StyledWrapper className="w-full">
<EditableTable
columns={columns}
rows={params || []}
onChange={handleParamsChange}
defaultRow={defaultRow}
reorderable={true}
onReorder={handleParamDrag}
/>
<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>
</StyledWrapper>
);
};
export default FormUrlEncodedParams;

View File

@@ -0,0 +1,30 @@
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;

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState, useCallback, useMemo, useRef } from 'react';
import React, { useEffect, useState } from 'react';
import find from 'lodash/find';
import get from 'lodash/get';
import classnames from 'classnames';
@@ -15,86 +15,54 @@ 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 ResponsiveTabs from 'ui/ResponsiveTabs';
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 schemaActionsRef = useRef(null);
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
const requestPaneTab = focusedTab?.requestPaneTab;
const preferences = useSelector((state) => state.app.preferences);
useEffect(() => {
onSchemaLoad(schema);
}, [schema, onSchemaLoad]);
}, [schema]);
const onQueryChange = useCallback(
(value) => {
dispatch(
updateRequestGraphqlQuery({
query: value,
itemUid: item.uid,
collectionUid: collection.uid
})
);
},
[dispatch, item.uid, collection.uid]
);
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 onRun = useCallback(
() => dispatch(sendRequest(item, collection.uid)),
[dispatch, item, collection.uid]
);
const selectTab = (tab) => {
dispatch(
updateRequestPaneTab({
uid: item.uid,
requestPaneTab: tab
})
);
};
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':
const getTabPanel = (tab) => {
switch (tab) {
case 'query': {
return (
<QueryEditor
collection={collection}
@@ -109,55 +77,94 @@ 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 || !focusedTab?.uid || !requestPaneTab) {
if (!activeTabUid) {
return <div>Something went wrong</div>;
}
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
if (!focusedTab || !focusedTab.uid || !focusedTab.requestPaneTab) {
return <div className="pb-4 px-4">An error occurred!</div>;
}
const isMultipleContentTab = MULTIPLE_CONTENT_TABS.has(requestPaneTab);
const rightContent = (
<div ref={schemaActionsRef}>
<GraphQLSchemaActions item={item} collection={collection} onSchemaLoad={setSchema} toggleDocs={toggleDocs} />
</div>
);
const getTabClassname = (tabName) => {
return classnames(`tab select-none ${tabName}`, {
active: tabName === focusedTab.requestPaneTab
});
};
return (
<div className="flex flex-col h-full relative">
<ResponsiveTabs
tabs={allTabs}
activeTab={requestPaneTab}
onTabSelect={selectTab}
rightContent={rightContent}
rightContentRef={schemaActionsRef}
/>
<section className={classnames('flex w-full flex-1', { 'mt-5': !isMultipleContentTab })}>
<HeightBoundContainer>{tabPanel}</HeightBoundContainer>
<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>
</section>
</div>
</StyledWrapper>
);
};

View File

@@ -1,7 +1,7 @@
import styled from 'styled-components';
const Wrapper = styled.div`
height: 2.1rem;
height: 2.3rem;
border: ${(props) => props.theme.requestTabPanel.url.border};
border-radius: ${(props) => props.theme.border.radius.base};

View File

@@ -1,7 +1,7 @@
import React, { useMemo, useCallback } from 'react';
import React, { useRef, forwardRef } from 'react';
import get from 'lodash/get';
import { IconCaretDown } from '@tabler/icons';
import MenuDropdown from 'ui/MenuDropdown';
import Dropdown from 'components/Dropdown';
import { useDispatch } from 'react-redux';
import { updateRequestAuthMode } from 'providers/ReduxStore/slices/collections';
import { humanizeRequestAuthMode } from 'utils/collections';
@@ -9,9 +9,50 @@ import StyledWrapper from '../../../Auth/AuthMode/StyledWrapper';
const GrpcAuthMode = ({ item, collection }) => {
const dispatch = useDispatch();
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const authMode = item.draft ? get(item, 'draft.request.auth.mode') : get(item, 'request.auth.mode');
const onModeChange = useCallback((value) => {
const authModes = [
{
name: 'Basic Auth',
mode: 'basic'
},
{
name: 'Bearer Token',
mode: 'bearer'
},
{
name: 'API Key',
mode: 'apikey'
},
{
name: 'OAuth2',
mode: 'oauth2'
},
{
name: 'WSSE Auth',
mode: 'wsse'
},
{
name: 'Inherit',
mode: 'inherit'
},
{
name: 'No Auth',
mode: 'none'
}
];
const Icon = forwardRef((props, ref) => {
return (
<div ref={ref} className="flex items-center justify-center auth-mode-label select-none">
{humanizeRequestAuthMode(authMode)} <IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
</div>
);
});
const onModeChange = (value) => {
dispatch(
updateRequestAuthMode({
itemUid: item.uid,
@@ -19,59 +60,27 @@ const GrpcAuthMode = ({ item, collection }) => {
mode: value
})
);
}, [dispatch, item.uid, collection.uid]);
};
const menuItems = useMemo(() => [
{
id: 'basic',
label: 'Basic Auth',
onClick: () => onModeChange('basic')
},
{
id: 'bearer',
label: 'Bearer Token',
onClick: () => onModeChange('bearer')
},
{
id: 'apikey',
label: 'API Key',
onClick: () => onModeChange('apikey')
},
{
id: 'oauth2',
label: 'OAuth 2.0',
onClick: () => onModeChange('oauth2')
},
{
id: 'wsse',
label: 'WSSE Auth',
onClick: () => onModeChange('wsse')
},
{
id: 'inherit',
label: 'Inherit',
onClick: () => onModeChange('inherit')
},
{
id: 'none',
label: 'No Auth',
onClick: () => onModeChange('none')
}
], [onModeChange]);
const onClickHandler = (mode) => {
dropdownTippyRef?.current?.hide();
onModeChange(mode);
};
return (
<StyledWrapper>
<div className="inline-flex items-center cursor-pointer auth-mode-selector">
<MenuDropdown
items={menuItems}
placement="bottom-end"
selectedItemId={authMode}
showTickMark={true}
>
<div className="flex items-center justify-center auth-mode-label select-none">
{humanizeRequestAuthMode(authMode)} <IconCaretDown className="caret ml-1" size={14} strokeWidth={2} />
</div>
</MenuDropdown>
<Dropdown onCreate={onDropdownCreate} icon={<Icon />} placement="bottom-end">
{authModes.map((authMode) => (
<div
key={authMode.mode}
className="dropdown-item"
onClick={() => onClickHandler(authMode.mode)}
>
{authMode.name}
</div>
))}
</Dropdown>
</div>
</StyledWrapper>
);

View File

@@ -6,7 +6,7 @@ const StyledWrapper = styled.div`
padding: 6px 0px;
border: none;
border-bottom: solid 2px transparent;
margin-right: ${(props) => props.theme.tabs.marginRight};
margin-right: 1.25rem;
color: var(--color-tab-inactive);
cursor: pointer;
@@ -20,7 +20,6 @@ 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;
}

View File

@@ -0,0 +1,34 @@
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;
}
.content-indicator {
color: ${(props) => props.theme.text}
}
}
}
`;
export default StyledWrapper;

View File

@@ -1,7 +1,6 @@
import React, { useEffect, useRef, useCallback, useMemo } from 'react';
import React 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';
@@ -12,149 +11,175 @@ 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 Settings from 'components/RequestPane/Settings';
import StyledWrapper from './StyledWrapper';
import { find, get } from 'lodash';
import Documentation from 'components/Documentation/index';
import StatusDot from 'components/StatusDot';
import ResponsiveTabs from 'ui/ResponsiveTabs';
import HeightBoundContainer from 'ui/HeightBoundContainer';
import AuthMode from '../Auth/AuthMode/index';
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
};
import { useEffect } from 'react';
import StatusDot from 'components/StatusDot';
import Settings from 'components/RequestPane/Settings';
const HttpRequestPane = ({ item, collection }) => {
const dispatch = useDispatch();
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const rightContentRef = useRef(null);
const initialAutoSelectDone = useRef(false);
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 focusedTab = find(tabs, (t) => t.uid === activeTabUid);
const requestPaneTab = focusedTab?.requestPaneTab;
const getProperty = useCallback(
(key) => (item.draft ? get(item, `draft.${key}`, []) : get(item, key, [])),
[item.draft, item]
);
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) {
if (!focusedTab || !focusedTab.uid || !focusedTab.requestPaneTab) {
return <div className="pb-4 px-4">An error occurred!</div>;
}
const isMultipleContentTab = MULTIPLE_CONTENT_TABS.has(requestPaneTab);
const getTabClassname = (tabName) => {
return classnames(`tab select-none ${tabName}`, {
active: tabName === focusedTab.requestPaneTab
});
};
const rightContent = requestPaneTab === 'body' ? (
<div ref={rightContentRef}>
<RequestBodyMode item={item} collection={collection} />
</div>
) : requestPaneTab === 'auth' ? (
<div ref={rightContentRef} className="flex flex-grow justify-start items-center">
<AuthMode item={item} collection={collection} />
</div>
) : null;
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');
}
}, []);
return (
<div className="flex flex-col h-full relative">
<ResponsiveTabs
tabs={allTabs}
activeTab={requestPaneTab}
onTabSelect={selectTab}
rightContent={rightContent}
rightContentRef={rightContent ? rightContentRef : null}
delayedTabs={['body']}
/>
<section className={classnames('flex w-full flex-1', { 'mt-3': !isMultipleContentTab })}>
<HeightBoundContainer>{tabPanel}</HeightBoundContainer>
<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>
</section>
</div>
</StyledWrapper>
);
};

View File

@@ -1,41 +1,48 @@
import styled from 'styled-components';
const Wrapper = styled.div`
.upload-btn,
.clear-file-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
color: ${(props) => props.theme.colors.text.muted};
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};
background: transparent;
border: none;
&:focus {
outline: none !important;
border: solid 1px transparent;
}
}
input[type='checkbox'] {
cursor: pointer;
border-radius: 4px;
transition: color 0.15s ease;
&:hover {
color: ${(props) => props.theme.colors.text.link};
}
}
.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;
}
position: relative;
top: 1px;
}
`;

View File

@@ -1,216 +1,216 @@
import React, { useCallback } from 'react';
import React 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 {
moveMultipartFormParam,
setMultipartFormParams
addMultipartFormParam,
updateMultipartFormParam,
deleteMultipartFormParam,
moveMultipartFormParam
} 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 path from 'utils/common/path';
import { isWindowsOS } from 'utils/common/platform';
import FilePickerEditor from 'components/FilePickerEditor';
import Table from 'components/Table/index';
import ReorderTable from 'components/ReorderTable/index';
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 onSave = () => dispatch(saveRequest(item.uid, collection.uid));
const handleRun = () => dispatch(sendRequest(item, collection.uid));
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);
const addParam = () => {
dispatch(
addMultipartFormParam({
itemUid: item.uid,
collectionUid: collection.uid,
type: 'text',
value: ''
})
.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);
}
}, [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 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 addFile = () => {
dispatch(
addMultipartFormParam({
itemUid: item.uid,
collectionUid: collection.uid,
type: 'file',
value: []
})
);
};
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>
);
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;
}
},
{
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}
/>
)
}
];
dispatch(
updateMultipartFormParam({
param: param,
itemUid: item.uid,
collectionUid: collection.uid
})
);
};
const defaultRow = {
name: '',
value: '',
contentType: '',
type: 'text'
const handleRemoveParams = (param) => {
dispatch(
deleteMultipartFormParam({
paramUid: param.uid,
itemUid: item.uid,
collectionUid: collection.uid
})
);
};
const handleParamDrag = ({ updateReorderedItem }) => {
dispatch(
moveMultipartFormParam({
collectionUid: collection.uid,
itemUid: item.uid,
updateReorderedItem
})
);
};
return (
<StyledWrapper className="w-full">
<EditableTable
columns={columns}
rows={params || []}
onChange={handleParamsChange}
defaultRow={defaultRow}
reorderable={true}
onReorder={handleParamDrag}
/>
<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>
</StyledWrapper>
);
};
export default MultipartFormParams;

View File

@@ -1,17 +1,24 @@
import React, { useState, useCallback } from 'react';
import React, { useState } from 'react';
import get from 'lodash/get';
import cloneDeep from 'lodash/cloneDeep';
import InfoTip from 'components/InfoTip';
import { useDispatch } from 'react-redux';
import { IconTrash } from '@tabler/icons';
import { useDispatch, useSelector } 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 }) => {
@@ -23,100 +30,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 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]);
const handleQueryParamChange = (e, data, key) => {
let value;
const handlePathParamChange = useCallback((rowUid, key, value) => {
const pathParam = pathParams.find((p) => p.uid === rowUid);
if (pathParam) {
dispatch(updatePathParam({
pathParam: { ...pathParam, [key]: value },
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,
itemUid: item.uid,
collectionUid: collection.uid
}));
}
}, [dispatch, pathParams, item.uid, collection.uid]);
})
);
};
const handleQueryParamDrag = useCallback(({ updateReorderedItem }) => {
dispatch(moveQueryParam({
collectionUid: collection.uid,
itemUid: item.uid,
updateReorderedItem
}));
}, [dispatch, collection.uid, item.uid]);
const handlePathParamChange = (e, data) => {
let value = e.target.value;
let pathParam = cloneDeep(data);
if (pathParam['value'] === value) {
return;
}
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 toggleBulkEditMode = () => {
setIsBulkEditMode(!isBulkEditMode);
};
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'
const handleBulkParamsChange = (newParams) => {
const paramsWithType = newParams.map((item) => ({ ...item, type: 'query' }));
dispatch(setQueryParams({ collectionUid: collection.uid, itemUid: item.uid, params: paramsWithType }));
};
if (isBulkEditMode) {
@@ -124,7 +131,7 @@ const QueryParams = ({ item, collection }) => {
<StyledWrapper className="w-full mt-3">
<BulkEditor
params={queryParams}
onChange={handleQueryParamsChange}
onChange={handleBulkParamsChange}
onToggle={toggleBulkEditMode}
onSave={onSave}
onRun={handleRun}
@@ -137,20 +144,69 @@ 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>
<EditableTable
columns={queryColumns}
rows={queryParams || []}
onChange={handleQueryParamsChange}
defaultRow={defaultQueryRow}
reorderable={true}
onReorder={handleQueryParamDrag}
/>
<div className="flex justify-end mt-2">
<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}>
+&nbsp;<span>Add Param</span>
</button>
<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">
@@ -164,22 +220,58 @@ const QueryParams = ({ item, collection }) => {
</div>
</InfoTip>
</div>
{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>
)}
<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}
</div>
</StyledWrapper>
);
};
export default QueryParams;

View File

@@ -18,10 +18,6 @@ const Wrapper = styled.div`
.dropdown-item {
padding: 0.25rem 0.6rem !important;
}
.text-link {
color: ${(props) => props.theme.textLink};
}
}
input {
@@ -44,9 +40,6 @@ const Wrapper = styled.div`
overflow: hidden;
white-space: nowrap;
display: inline-block;
text-align: center;
font-size: ${(props) => props.theme.font.size.sm};
font-weight: 500;
}
.caret {

View File

@@ -1,6 +1,6 @@
import React, { useState, useRef, useMemo, useCallback } from 'react';
import React, { useState, useRef, forwardRef } from 'react';
import { IconCaretDown } from '@tabler/icons';
import MenuDropdown from 'ui/MenuDropdown';
import Dropdown from 'components/Dropdown';
import StyledWrapper from './StyledWrapper';
const STANDARD_METHODS = Object.freeze(['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS', 'HEAD', 'TRACE', 'CONNECT']);
@@ -9,27 +9,58 @@ const KEY = Object.freeze({ ENTER: 'Enter', ESCAPE: 'Escape' });
const DEFAULT_METHOD = 'GET';
const TriggerButton = ({ method, ...props }) => {
function Verb({ verb, onSelect }) {
return (
<button
type="button"
className="cursor-pointer flex items-center text-left w-full pr-4 select-none"
{...props}
>
<span
className="px-3 truncate method-span"
id="create-new-request-method"
title={method}
>
{method}
</span>
<IconCaretDown className="caret" size={16} strokeWidth={2} />
</button>
<div className="dropdown-item" onClick={() => onSelect(verb)}>
{verb}
</div>
);
};
}
const Icon = forwardRef(function IconComponent(
{ isCustomMode, inputValue, handleInputChange, handleBlur, handleKeyDown, inputRef },
ref
) {
if (isCustomMode) {
return (
<div className="flex flex-col w-full">
<input
ref={inputRef}
type="text"
className="px-2 w-full focus:bg-transparent"
value={inputValue}
onChange={handleInputChange}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
title={inputValue}
autoFocus
/>
</div>
);
}
return (
<div ref={ref} className="flex pr-4 select-none">
<button
type="button"
className="cursor-pointer flex items-center text-left w-full"
>
<span
className="px-2 truncate method-span"
id="create-new-request-method"
title={inputValue}
>
{inputValue}
</span>
<IconCaretDown className="caret" size={16} strokeWidth={2} />
</button>
</div>
);
});
const HttpMethodSelector = ({ method = DEFAULT_METHOD, onMethodSelect }) => {
const [isCustomMode, setIsCustomMode] = useState(false);
const dropdownTippyRef = useRef();
const inputRef = useRef();
const blurInput = () => inputRef.current?.blur();
@@ -39,110 +70,74 @@ const HttpMethodSelector = ({ method = DEFAULT_METHOD, onMethodSelect }) => {
onMethodSelect(val);
};
const handleMethodSelect = useCallback((verb) => {
const handleDropdownSelect = (verb) => {
onMethodSelect(verb);
setIsCustomMode(false);
dropdownTippyRef.current?.hide();
blurInput();
}, [onMethodSelect]);
};
const handleBlur = (e) => {
// Keep the current value when blurring
const currentValue = e.target.value ? e.target.value.toUpperCase() : method;
onMethodSelect(currentValue);
const handleBlur = () => {
setIsCustomMode(false);
};
const handleAddCustomMethod = useCallback(() => {
const handleAddCustomMethod = () => {
setIsCustomMode(true);
onMethodSelect('');
dropdownTippyRef.current?.hide();
setTimeout(() => {
inputRef.current?.focus();
inputRef.current?.select();
}, 0);
}, [onMethodSelect]);
};
const handleKeyDown = (e) => {
switch (e.key) {
case KEY.ESCAPE: {
case KEY.ESCAPE:
setIsCustomMode(false);
blurInput();
e.preventDefault();
e.stopPropagation();
return;
}
case KEY.ENTER: {
case KEY.ENTER:
onMethodSelect(e.target.value ? e.target.value.toUpperCase() : DEFAULT_METHOD);
setIsCustomMode(false);
blurInput();
return;
}
default: {
default:
return;
}
}
};
// Convert STANDARD_METHODS to MenuDropdown items format
const menuItems = useMemo(() => {
const items = STANDARD_METHODS.map((verb) => ({
id: verb.toLowerCase(),
label: verb,
onClick: () => handleMethodSelect(verb)
}));
// Add "Add Custom" item
items.push({
id: 'add-custom',
label: '+ Add Custom',
onClick: handleAddCustomMethod,
className: 'font-normal mt-1 text-link'
});
return items;
}, [handleMethodSelect, handleAddCustomMethod]);
// Determine selected item ID (only if method is a standard method)
const selectedItemId = useMemo(() => {
if (isCustomMode || !STANDARD_METHODS.includes(method)) {
return null;
}
return method.toLowerCase();
}, [method, isCustomMode]);
// If in custom mode, render input field instead of dropdown
if (isCustomMode) {
return (
<StyledWrapper>
<div className="flex method-selector">
<div className="flex flex-col w-full">
<input
ref={inputRef}
type="text"
className="px-2 w-full focus:bg-transparent"
value={method}
onChange={handleInputChange}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
title={method}
autoFocus
/>
</div>
</div>
</StyledWrapper>
);
}
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
return (
<StyledWrapper>
<div className="flex method-selector">
<MenuDropdown
items={menuItems}
<Dropdown
onCreate={onDropdownCreate}
icon={(
<Icon
isCustomMode={isCustomMode}
inputValue={method}
handleInputChange={handleInputChange}
handleBlur={handleBlur}
handleKeyDown={handleKeyDown}
inputRef={inputRef}
/>
)}
placement="bottom-start"
selectedItemId={selectedItemId}
>
<TriggerButton method={method} />
</MenuDropdown>
<div>
{STANDARD_METHODS.map((verb) => (
<Verb key={verb} verb={verb} onSelect={handleDropdownSelect} />
))}
<div className="dropdown-item font-normal mt-1" onClick={handleAddCustomMethod}>
<span className="text-link">+ Add Custom</span>
</div>
</div>
</Dropdown>
</div>
</StyledWrapper>
);

View File

@@ -57,13 +57,13 @@ describe('HttpMethodSelector', () => {
await waitFor(() => {
const standardMethods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS', 'HEAD', 'TRACE', 'CONNECT'];
const dropdownItems = screen.getAllByRole('menuitem');
const renderedMethods = dropdownItems.map((item) => item.textContent.trim());
const dropdownItems = screen.getAllByText((content, element) => {
return element?.classList.contains('dropdown-item');
});
const renderedMethods = dropdownItems.map((item) => item.textContent);
standardMethods.forEach((method, index) => {
// GET should have a checkmark (✓) since it's the default selected method
const expectedText = index === 0 ? method + '✓' : method;
expect(renderedMethods).toContain(expectedText);
standardMethods.forEach((method) => {
expect(renderedMethods).toContain(method);
});
});
});
@@ -77,8 +77,7 @@ describe('HttpMethodSelector', () => {
await waitFor(() => {
const addCustomSpan = screen.getByText('+ Add Custom');
expect(addCustomSpan).toBeInTheDocument();
// The className is applied to the parent dropdown-item div, not the label span
expect(addCustomSpan.closest('.dropdown-item')).toHaveClass('text-link');
expect(addCustomSpan).toHaveClass('text-link');
});
});
@@ -89,13 +88,10 @@ describe('HttpMethodSelector', () => {
fireEvent.click(button);
await waitFor(() => {
const postMethod = screen.getByRole('menuitem', { name: /^POST/ });
expect(postMethod).toBeInTheDocument();
const postMethod = screen.getByText('POST');
fireEvent.click(postMethod);
});
const postMethod = screen.getByRole('menuitem', { name: /^POST/ });
fireEvent.click(postMethod);
expect(mockOnMethodSelect).toHaveBeenCalledWith('POST');
});
});

View File

@@ -1,7 +1,7 @@
import styled from 'styled-components';
const Wrapper = styled.div`
height: 2.1rem;
height: 2.3rem;
border: ${(props) => props.theme.requestTabPanel.url.border};
border-radius: ${(props) => props.theme.border.radius.base};

View File

@@ -1,19 +1,8 @@
import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import React, { useState, useEffect, useRef, useMemo } from 'react';
import get from 'lodash/get';
import { useDispatch } from 'react-redux';
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 { requestUrlChanged, updateRequestMethod } from 'providers/ReduxStore/slices/collections';
import { cancelRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import HttpMethodSelector from './HttpMethodSelector';
import { useTheme } from 'providers/Theme';
import { IconDeviceFloppy, IconArrowRight, IconCode, IconSquareRoundedX } from '@tabler/icons';
@@ -92,289 +81,12 @@ 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">
@@ -398,12 +110,10 @@ 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}
@@ -417,7 +127,7 @@ const QueryUrl = ({ item, collection, handleRun }) => {
handleGenerateCode(e);
}}
>
<IconCode color={theme.requestTabs.icon.color} strokeWidth={1.5} size={20} className="cursor-pointer" />
<IconCode color={theme.requestTabs.icon.color} strokeWidth={1.5} size={22} className="cursor-pointer" />
<span className="infotiptext text-xs">Generate Code</span>
</div>
<div
@@ -432,7 +142,7 @@ const QueryUrl = ({ item, collection, handleRun }) => {
<IconDeviceFloppy
color={hasChanges ? theme.colors.text.yellow : theme.requestTabs.icon.color}
strokeWidth={1.5}
size={20}
size={22}
className={`${hasChanges ? 'cursor-pointer' : 'cursor-default'}`}
/>
<span className="infotiptext text-xs">
@@ -443,7 +153,7 @@ const QueryUrl = ({ item, collection, handleRun }) => {
<IconSquareRoundedX
color={theme.requestTabPanel.url.iconDanger}
strokeWidth={1.5}
size={20}
size={22}
data-testid="cancel-request-icon"
onClick={handleCancelRequest}
/>
@@ -451,7 +161,7 @@ const QueryUrl = ({ item, collection, handleRun }) => {
<IconArrowRight
color={theme.requestTabPanel.url.icon}
strokeWidth={1.5}
size={20}
size={22}
data-testid="send-arrow-icon"
/>
)}

View File

@@ -2,12 +2,20 @@ import styled from 'styled-components';
const Wrapper = styled.div`
font-size: ${(props) => props.theme.font.size.base};
white-space: nowrap;
.body-mode-selector {
background: transparent;
border-radius: 3px;
.dropdown-item {
padding: 0.2rem 0.6rem !important;
padding-left: 1.5rem !important;
}
.label-item {
padding: 0.2rem 0.6rem !important;
}
.selected-body-mode {
color: ${(props) => props.theme.colors.text.yellow};
}
@@ -15,7 +23,7 @@ const Wrapper = styled.div`
.caret {
color: rgb(140, 140, 140);
fill: rgb(140, 140, 140);
fill: rgb(140 140 140);
}
`;

View File

@@ -1,4 +1,4 @@
import React, { useMemo, useCallback } from 'react';
import React, { useRef, forwardRef } from 'react';
import get from 'lodash/get';
import {
IconCaretDown,
@@ -10,7 +10,7 @@ import {
IconFile,
IconX
} from '@tabler/icons';
import MenuDropdown from 'ui/MenuDropdown';
import Dropdown from 'components/Dropdown';
import { useDispatch } from 'react-redux';
import { updateRequestBodyMode } from 'providers/ReduxStore/slices/collections';
import { humanizeRequestBodyMode } from 'utils/collections';
@@ -20,38 +20,22 @@ import { toastError } from 'utils/common/error';
import { prettifyJsonString } from 'utils/common/index';
import xmlFormat from 'xml-formatter';
const DEFAULT_MODES = [
{
name: 'Form',
options: [
{ id: 'multipartForm', label: 'Multipart Form', leftSection: IconForms },
{ id: 'formUrlEncoded', label: 'Form URL Encoded', leftSection: IconForms }
]
},
{
name: 'Raw',
options: [
{ id: 'json', label: 'JSON', leftSection: IconBraces },
{ id: 'xml', label: 'XML', leftSection: IconCode },
{ id: 'text', label: 'TEXT', leftSection: IconFileText },
{ id: 'sparql', label: 'SPARQL', leftSection: IconDatabase }
]
},
{
name: 'Other',
options: [
{ id: 'file', label: 'File / Binary', leftSection: IconFile },
{ id: 'none', label: 'No Body', leftSection: IconX }
]
}
];
const RequestBodyMode = ({ item, collection }) => {
const dispatch = useDispatch();
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const body = item.draft ? get(item, 'draft.request.body') : get(item, 'request.body');
const bodyMode = body?.mode;
const onModeChange = useCallback((value) => {
const Icon = forwardRef((props, ref) => {
return (
<div ref={ref} className="flex items-center justify-center pl-3 py-1 select-none selected-body-mode">
{humanizeRequestBodyMode(bodyMode)} <IconCaretDown className="caret ml-2" size={14} strokeWidth={2} />
</div>
);
});
const onModeChange = (value) => {
dispatch(
updateRequestBodyMode({
itemUid: item.uid,
@@ -59,7 +43,7 @@ const RequestBodyMode = ({ item, collection }) => {
mode: value
})
);
}, [dispatch, item.uid, collection.uid]);
};
const onPrettify = () => {
if (body?.json && bodyMode === 'json') {
@@ -91,30 +75,110 @@ const RequestBodyMode = ({ item, collection }) => {
}
};
const menuItems = useMemo(() => {
return DEFAULT_MODES.map((group) => ({
...group,
options: group.options.map((option) => ({
...option,
onClick: () => onModeChange(option.id)
}))
}));
}, [onModeChange]);
return (
<StyledWrapper>
<div className="inline-flex items-center cursor-pointer body-mode-selector">
<MenuDropdown
items={menuItems}
placement="bottom-end"
selectedItemId={bodyMode}
showGroupDividers={false}
groupStyle="select"
>
<div className="flex items-center justify-center pl-3 py-1 select-none selected-body-mode">
{humanizeRequestBodyMode(bodyMode)} <IconCaretDown className="caret ml-1" size={14} strokeWidth={2} />
<Dropdown onCreate={onDropdownCreate} icon={<Icon />} placement="bottom-end">
<div className="label-item">Form</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('multipartForm');
}}
>
<span className="dropdown-icon">
<IconForms size={16} strokeWidth={2} />
</span>
Multipart Form
</div>
</MenuDropdown>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('formUrlEncoded');
}}
>
<span className="dropdown-icon">
<IconForms size={16} strokeWidth={2} />
</span>
Form URL Encoded
</div>
<div className="label-item">Raw</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('json');
}}
>
<span className="dropdown-icon">
<IconBraces size={16} strokeWidth={2} />
</span>
JSON
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('xml');
}}
>
<span className="dropdown-icon">
<IconCode size={16} strokeWidth={2} />
</span>
XML
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('text');
}}
>
<span className="dropdown-icon">
<IconFileText size={16} strokeWidth={2} />
</span>
TEXT
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('sparql');
}}
>
<span className="dropdown-icon">
<IconDatabase size={16} strokeWidth={2} />
</span>
SPARQL
</div>
<div className="label-item">Other</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('file');
}}
>
<span className="dropdown-icon">
<IconFile size={16} strokeWidth={2} />
</span>
File / Binary
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('none');
}}
>
<span className="dropdown-icon">
<IconX size={16} strokeWidth={2} />
</span>
No Body
</div>
</Dropdown>
</div>
{(bodyMode === 'json' || bodyMode === 'xml') && (
<button className="ml-2" onClick={onPrettify}>

View File

@@ -1,14 +1,17 @@
import React, { useState, useCallback } from 'react';
import React, { useState } from 'react';
import get from 'lodash/get';
import { useDispatch } from 'react-redux';
import cloneDeep from 'lodash/cloneDeep';
import { IconTrash } from '@tabler/icons';
import { useDispatch, useSelector } from 'react-redux';
import { useTheme } from 'providers/Theme';
import { moveRequestHeader, setRequestHeaders } from 'providers/ReduxStore/slices/collections';
import { addRequestHeader, updateRequestHeader, deleteRequestHeader, 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);
@@ -17,76 +20,74 @@ 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);
const handleHeadersChange = useCallback((updatedHeaders) => {
dispatch(setRequestHeaders({
collectionUid: collection.uid,
itemUid: item.uid,
headers: updatedHeaders
}));
}, [dispatch, collection.uid, item.uid]);
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 handleHeaderDrag = useCallback(({ updateReorderedItem }) => {
dispatch(moveRequestHeader({
collectionUid: collection.uid,
itemUid: item.uid,
updateReorderedItem
}));
}, [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 toggleBulkEditMode = () => {
setIsBulkEditMode(!isBulkEditMode);
};
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: ''
const handleBulkHeadersChange = (newHeaders) => {
dispatch(setRequestHeaders({ collectionUid: collection.uid, itemUid: item.uid, headers: newHeaders }));
};
if (isBulkEditMode) {
@@ -94,7 +95,7 @@ const RequestHeaders = ({ item, collection, addHeaderText }) => {
<StyledWrapper className="w-full mt-3">
<BulkEditor
params={headers}
onChange={handleHeadersChange}
onChange={handleBulkHeadersChange}
onToggle={toggleBulkEditMode}
onSave={onSave}
onRun={handleRun}
@@ -105,15 +106,83 @@ const RequestHeaders = ({ item, collection, addHeaderText }) => {
return (
<StyledWrapper className="w-full">
<EditableTable
columns={columns}
rows={headers || []}
onChange={handleHeadersChange}
defaultRow={defaultRow}
reorderable={true}
onReorder={handleHeaderDrag}
/>
<div className="flex justify-end mt-2">
<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>
<button className="btn-action text-link select-none" onClick={toggleBulkEditMode}>
Bulk Edit
</button>
@@ -121,5 +190,4 @@ const RequestHeaders = ({ item, collection, addHeaderText }) => {
</StyledWrapper>
);
};
export default RequestHeaders;

View File

@@ -2,7 +2,6 @@ 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';
@@ -27,7 +26,6 @@ const Tags = ({ item, collection }) => {
collectionUid: collection.uid
})
);
dispatch(makeTabPermanent({ uid: item.uid }));
}
}, [dispatch, tags, item.uid, collection.uid]);
@@ -39,7 +37,6 @@ const Tags = ({ item, collection }) => {
collectionUid: collection.uid
})
);
dispatch(makeTabPermanent({ uid: item.uid }));
}, [dispatch, item.uid, collection.uid]);
const handleRequestSave = () => {

View File

@@ -1,100 +1,170 @@
import React, { useCallback } from 'react';
import React from 'react';
import cloneDeep from 'lodash/cloneDeep';
import { IconTrash } from '@tabler/icons';
import { useDispatch } from 'react-redux';
import { useTheme } from 'providers/Theme';
import { moveVar, setRequestVars } from 'providers/ReduxStore/slices/collections';
import { addVar, updateVar, deleteVar, moveVar } 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;
const handleVarsChange = useCallback((updatedVars) => {
dispatch(setRequestVars({
collectionUid: collection.uid,
itemUid: item.uid,
vars: updatedVars,
type: varType
}));
}, [dispatch, collection.uid, item.uid, varType]);
if (variableNameRegex.test(value) === false) {
toast.error(
'Variable contains invalid characters! Variables must only contain alpha-numeric characters, "-", "_", "."'
);
return;
}
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, "-", "_", "."';
_var.name = value;
break;
}
case 'value': {
_var.value = e.target.value;
break;
}
case 'enabled': {
_var.enabled = e.target.checked;
break;
}
}
return null;
}, []);
dispatch(
updateVar({
type: varType,
var: _var,
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 handleRemoveVar = (_var) => {
dispatch(
deleteVar({
type: varType,
varUid: _var.uid,
itemUid: item.uid,
collectionUid: collection.uid
})
);
};
const defaultRow = {
name: '',
value: '',
...(varType === 'response' ? { local: false } : {})
const handleVarDrag = ({ updateReorderedItem }) => {
dispatch(
moveVar({
type: varType,
collectionUid: collection.uid,
itemUid: item.uid,
updateReorderedItem
})
);
};
return (
<StyledWrapper className="w-full">
<EditableTable
columns={columns}
rows={vars || []}
onChange={handleVarsChange}
defaultRow={defaultRow}
getRowError={getRowError}
reorderable={true}
onReorder={handleVarDrag}
/>
<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>
</StyledWrapper>
);
};
export default VarsTable;

View File

@@ -2,6 +2,7 @@ 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');
@@ -10,13 +11,8 @@ 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>
);
};

View File

@@ -6,7 +6,7 @@ const StyledWrapper = styled.div`
padding: 6px 0px;
border: none;
border-bottom: solid 2px transparent;
margin-right: ${(props) => props.theme.tabs.marginRight};
margin-right: 1.25rem;
color: var(--color-tab-inactive);
cursor: pointer;

View File

@@ -1,70 +1,82 @@
import React, { useMemo, useCallback } from 'react';
import React, { useRef, forwardRef } from 'react';
import get from 'lodash/get';
import { IconCaretDown } from '@tabler/icons';
import MenuDropdown from 'ui/MenuDropdown';
import Dropdown from 'components/Dropdown';
import { useDispatch } from 'react-redux';
import { updateRequestAuthMode } from 'providers/ReduxStore/slices/collections';
import { humanizeRequestAuthMode } from 'utils/collections';
import StyledWrapper from '../../../Auth/AuthMode/StyledWrapper';
const AUTH_MODES = [
{
name: 'Basic Auth',
mode: 'basic'
},
{
name: 'Bearer Token',
mode: 'bearer'
},
{
name: 'API Key',
mode: 'apikey'
},
{
name: 'OAuth2',
mode: 'oauth2'
},
{
name: 'Inherit',
mode: 'inherit'
},
{
name: 'No Auth',
mode: 'none'
}
];
const WSAuthMode = ({ item, collection }) => {
const dispatch = useDispatch();
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const authMode = item.draft ? get(item, 'draft.request.auth.mode') : get(item, 'request.auth.mode');
const onModeChange = useCallback((value) => {
const Icon = forwardRef((props, ref) => {
return (
<div ref={ref} className="flex items-center justify-center auth-mode-label select-none">
{humanizeRequestAuthMode(authMode)}
{' '}
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
</div>
);
});
const onModeChange = (value) => {
dispatch(updateRequestAuthMode({
itemUid: item.uid,
collectionUid: collection.uid,
mode: value
}));
}, [dispatch, item.uid, collection.uid]);
};
const menuItems = useMemo(() => [
{
id: 'basic',
label: 'Basic Auth',
onClick: () => onModeChange('basic')
},
{
id: 'bearer',
label: 'Bearer Token',
onClick: () => onModeChange('bearer')
},
{
id: 'apikey',
label: 'API Key',
onClick: () => onModeChange('apikey')
},
{
id: 'oauth2',
label: 'OAuth 2.0',
onClick: () => onModeChange('oauth2')
},
{
id: 'inherit',
label: 'Inherit',
onClick: () => onModeChange('inherit')
},
{
id: 'none',
label: 'No Auth',
onClick: () => onModeChange('none')
}
], [onModeChange]);
const onClickHandler = (mode) => {
dropdownTippyRef?.current?.hide();
onModeChange(mode);
};
return (
<StyledWrapper>
<div className="inline-flex items-center cursor-pointer auth-mode-selector">
<MenuDropdown
items={menuItems}
placement="bottom-end"
selectedItemId={authMode}
showTickMark={true}
>
<div className="flex items-center justify-center auth-mode-label select-none">
{humanizeRequestAuthMode(authMode)} <IconCaretDown className="caret ml-1" size={14} strokeWidth={2} />
</div>
</MenuDropdown>
<Dropdown onCreate={onDropdownCreate} icon={<Icon />} placement="bottom-end">
{AUTH_MODES.map((authMode) => (
<div
key={authMode.mode}
className="dropdown-item"
onClick={() => onClickHandler(authMode.mode)}
>
{authMode.name}
</div>
))}
</Dropdown>
</div>
</StyledWrapper>
);

View File

@@ -1,7 +1,7 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
height: 2.1rem;
height: 2.3rem;
position: relative;
border: ${(props) => props.theme.requestTabPanel.url.border};
border-radius: ${(props) => props.theme.border.radius.base};

View File

@@ -9,14 +9,6 @@ const StyledWrapper = styled.div`
}
}
.request-pane {
flex-shrink: 0;
}
.response-pane {
min-width: 0;
}
div.dragbar-wrapper {
display: flex;
align-items: center;

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import find from 'lodash/find';
import toast from 'react-hot-toast';
import { useSelector, useDispatch } from 'react-redux';
@@ -36,7 +36,7 @@ import ResponseExample from 'components/ResponseExample';
import WorkspaceHome from 'components/WorkspaceHome';
const MIN_LEFT_PANE_WIDTH = 300;
const MIN_RIGHT_PANE_WIDTH = 480;
const MIN_RIGHT_PANE_WIDTH = 350;
const MIN_TOP_PANE_HEIGHT = 150;
const MIN_BOTTOM_PANE_HEIGHT = 150;
@@ -52,17 +52,10 @@ const RequestTabPanel = () => {
const _collections = useSelector((state) => state.collections.collections);
const preferences = useSelector((state) => state.app.preferences);
const isVerticalLayout = preferences?.layout?.responsePaneOrientation === 'vertical';
const isConsoleOpen = useSelector((state) => state.logs.isConsoleOpen);
// Use ref to avoid stale closure in event handlers
const isVerticalLayoutRef = useRef(isVerticalLayout);
useEffect(() => {
isVerticalLayoutRef.current = isVerticalLayout;
}, [isVerticalLayout]);
// merge `globalEnvironmentVariables` into the active collection and rebuild `collections` immer proxy object
const collections = produce(_collections, (draft) => {
const collection = find(draft, (c) => c.uid === focusedTab?.collectionUid);
let collections = produce(_collections, (draft) => {
let collection = find(draft, (c) => c.uid === focusedTab?.collectionUid);
if (collection) {
// add selected global env variables to the collection object
@@ -76,66 +69,62 @@ const RequestTabPanel = () => {
}
});
const collection = find(collections, (c) => c.uid === focusedTab?.collectionUid);
let collection = find(collections, (c) => c.uid === focusedTab?.collectionUid);
const [dragging, setDragging] = useState(false);
const draggingRef = useRef(false);
const dragOffset = useRef({ x: 0, y: 0 });
const { left: leftPaneWidth, top: topPaneHeight, reset: resetPaneBoundaries, setTop: setTopPaneHeight, setLeft: setLeftPaneWidth } = useTabPaneBoundaries(activeTabUid);
const previousTopPaneHeight = useRef(null); // Store height before devtools opens
// Not a recommended pattern here to have the child component
// make a callback to set state, but treating this as an exception
const docExplorerRef = useRef(null);
const mainSectionRef = useRef(null);
const [schema, setSchema] = useState(null);
const [showGqlDocs, setShowGqlDocs] = useState(false);
const onSchemaLoad = useCallback((schema) => setSchema(schema), []);
const toggleDocs = useCallback(() => setShowGqlDocs((prev) => !prev), []);
const handleGqlClickReference = useCallback((reference) => {
const onSchemaLoad = (schema) => setSchema(schema);
const toggleDocs = () => setShowGqlDocs((showGqlDocs) => !showGqlDocs);
const handleGqlClickReference = (reference) => {
if (docExplorerRef.current) {
docExplorerRef.current.showDocForReference(reference);
}
if (!showGqlDocs) {
setShowGqlDocs(true);
}
}, []);
};
const handleMouseMove = useCallback((e) => {
if (!draggingRef.current || !mainSectionRef.current) return;
e.preventDefault();
const mainRect = mainSectionRef.current.getBoundingClientRect();
if (isVerticalLayoutRef.current) {
const newHeight = e.clientY - mainRect.top;
const maxHeight = mainRect.height - MIN_BOTTOM_PANE_HEIGHT;
// Clamp to bounds instead of returning early
const clampedHeight = Math.max(MIN_TOP_PANE_HEIGHT, Math.min(newHeight, maxHeight));
setTopPaneHeight(clampedHeight);
} else {
const newWidth = e.clientX - mainRect.left;
const maxWidth = mainRect.width - MIN_RIGHT_PANE_WIDTH;
// Clamp to bounds instead of returning early
const clampedWidth = Math.max(MIN_LEFT_PANE_WIDTH, Math.min(newWidth, maxWidth));
setLeftPaneWidth(clampedWidth);
}
}, [setTopPaneHeight, setLeftPaneWidth]);
const handleMouseUp = useCallback((e) => {
if (draggingRef.current) {
const handleMouseMove = (e) => {
if (dragging && mainSectionRef.current) {
e.preventDefault();
const mainRect = mainSectionRef.current.getBoundingClientRect();
if (isVerticalLayout) {
const newHeight = e.clientY - mainRect.top - dragOffset.current.y;
if (newHeight < MIN_TOP_PANE_HEIGHT || newHeight > mainRect.height - MIN_BOTTOM_PANE_HEIGHT) {
return;
}
setTopPaneHeight(newHeight);
} else {
const newWidth = e.clientX - mainRect.left - dragOffset.current.x;
if (newWidth < MIN_LEFT_PANE_WIDTH || newWidth > mainRect.width - MIN_RIGHT_PANE_WIDTH) {
return;
}
setLeftPaneWidth(newWidth);
}
}
};
const handleMouseUp = (e) => {
if (dragging) {
e.preventDefault();
draggingRef.current = false;
setDragging(false);
}
}, []);
};
const handleDragbarMouseDown = useCallback((e) => {
const handleDragbarMouseDown = (e) => {
e.preventDefault();
draggingRef.current = true;
setDragging(true);
}, []);
};
useEffect(() => {
document.addEventListener('mouseup', handleMouseUp);
@@ -145,31 +134,7 @@ const RequestTabPanel = () => {
document.removeEventListener('mouseup', handleMouseUp);
document.removeEventListener('mousemove', handleMouseMove);
};
}, [handleMouseUp, handleMouseMove]);
// When devtools opens in vertical layout, reduce request pane height to ensure response pane is visible
// When devtools closes, restore the previous height
useEffect(() => {
if (!isVerticalLayout) return;
if (isConsoleOpen) {
// Store current height before reducing
if (previousTopPaneHeight.current === null) {
previousTopPaneHeight.current = topPaneHeight;
}
// Reduce request pane height to make room for response pane when devtools is open
const maxHeight = 200;
if (topPaneHeight > maxHeight) {
setTopPaneHeight(maxHeight);
}
} else {
// Restore previous height when devtools closes
if (previousTopPaneHeight.current !== null) {
setTopPaneHeight(previousTopPaneHeight.current);
previousTopPaneHeight.current = null;
}
}
}, [isConsoleOpen, isVerticalLayout]);
}, [dragging]);
if (!activeTabUid) {
return <WorkspaceHome />;
@@ -239,6 +204,8 @@ const RequestTabPanel = () => {
}
const handleRun = async () => {
const isGrpcRequest = item?.type === 'grpc-request';
const isWsRequest = item?.type === 'ws-request';
const request = item.draft ? item.draft.request : item.request;
if (isGrpcRequest && !request.url) {
@@ -269,76 +236,56 @@ const RequestTabPanel = () => {
}
};
const renderQueryUrl = () => {
if (isGrpcRequest) {
return <GrpcQueryUrl item={item} collection={collection} handleRun={handleRun} />;
}
if (isWsRequest) {
return <WsQueryUrl item={item} collection={collection} handleRun={handleRun} />;
}
return <QueryUrl item={item} collection={collection} handleRun={handleRun} />;
};
const renderRequestPane = () => {
switch (item.type) {
case 'graphql-request':
return (
<GraphQLRequestPane
item={item}
collection={collection}
onSchemaLoad={onSchemaLoad}
toggleDocs={toggleDocs}
handleGqlClickReference={handleGqlClickReference}
/>
);
case 'http-request':
return <HttpRequestPane item={item} collection={collection} />;
case 'grpc-request':
return <GrpcRequestPane item={item} collection={collection} handleRun={handleRun} />;
case 'ws-request':
return <WSRequestPane item={item} collection={collection} handleRun={handleRun} />;
default:
return null;
}
};
const renderResponsePane = () => {
switch (item.type) {
case 'grpc-request':
return <GrpcResponsePane item={item} collection={collection} response={item.response} />;
case 'ws-request':
return <WSResponsePane item={item} collection={collection} response={item.response} />;
default:
return <ResponsePane item={item} collection={collection} response={item.response} />;
}
};
const requestPaneStyle = isVerticalLayout
? {
height: `${Math.max(topPaneHeight, MIN_TOP_PANE_HEIGHT)}px`,
minHeight: `${MIN_TOP_PANE_HEIGHT}px`,
width: '100%'
}
: {
width: `${Math.max(leftPaneWidth, MIN_LEFT_PANE_WIDTH)}px`
};
// TODO: reaper, improve selection of panes
return (
<StyledWrapper
className={`flex flex-col flex-grow relative ${dragging ? 'dragging' : ''} ${
isVerticalLayout ? 'vertical-layout' : ''
}`}
>
<div className="pt-3 pb-3 px-4">
{renderQueryUrl()}
<div className="pt-4 pb-3 px-4">
{
isGrpcRequest
? <GrpcQueryUrl item={item} collection={collection} handleRun={handleRun} />
: isWsRequest
? <WsQueryUrl item={item} collection={collection} handleRun={handleRun} />
: <QueryUrl item={item} collection={collection} handleRun={handleRun} />
}
</div>
<section ref={mainSectionRef} className={`main flex ${isVerticalLayout ? 'flex-col' : ''} flex-grow pb-4 relative overflow-auto`}>
<section className="request-pane">
<div
className="px-4 h-full"
style={requestPaneStyle}
style={isVerticalLayout ? {
height: `${Math.max(topPaneHeight, MIN_TOP_PANE_HEIGHT)}px`,
minHeight: `${MIN_TOP_PANE_HEIGHT}px`,
width: '100%'
} : {
width: `${Math.max(leftPaneWidth, MIN_LEFT_PANE_WIDTH)}px`
}}
>
{renderRequestPane()}
{item.type === 'graphql-request' ? (
<GraphQLRequestPane
item={item}
collection={collection}
onSchemaLoad={onSchemaLoad}
toggleDocs={toggleDocs}
handleGqlClickReference={handleGqlClickReference}
/>
) : null}
{item.type === 'http-request' ? (
<HttpRequestPane item={item} collection={collection} />
) : null}
{isGrpcRequest ? (
<GrpcRequestPane item={item} collection={collection} handleRun={handleRun} />
) : null}
{isWsRequest ? (
<WSRequestPane item={item} collection={collection} handleRun={handleRun} />
) : null}
</div>
</section>
@@ -354,7 +301,25 @@ const RequestTabPanel = () => {
</div>
<section className="response-pane flex-grow overflow-x-auto">
{renderResponsePane()}
{item.type === 'grpc-request' ? (
<GrpcResponsePane
item={item}
collection={collection}
response={item.response}
/>
) : item.type === 'ws-request' ? (
<WSResponsePane
item={item}
collection={collection}
response={item.response}
/>
) : (
<ResponsePane
item={item}
collection={collection}
response={item.response}
/>
)}
</section>
</section>

View File

@@ -1,13 +1,12 @@
import React from 'react';
import { uuid } from 'utils/common';
import { IconBox, IconRun, IconEye, IconSettings } from '@tabler/icons';
import { IconFiles, IconRun, IconEye, IconSettings } from '@tabler/icons';
import EnvironmentSelector from 'components/Environments/EnvironmentSelector';
import { addTab } from 'providers/ReduxStore/slices/tabs';
import { useDispatch } from 'react-redux';
import ToolHint from 'components/ToolHint';
import StyledWrapper from './StyledWrapper';
import JsSandboxMode from 'components/SecuritySettings/JsSandboxMode';
import ActionIcon from 'ui/ActionIcon';
const CollectionToolBar = ({ collection }) => {
const dispatch = useDispatch();
@@ -44,31 +43,31 @@ const CollectionToolBar = ({ collection }) => {
return (
<StyledWrapper>
<div className="flex items-center justify-between gap-2 py-2 px-4">
<button className="flex items-center cursor-pointer hover:underline bg-transparent border-none p-0 text-inherit" onClick={viewCollectionSettings}>
<IconBox size={18} strokeWidth={1.5} />
<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} />
<span className="ml-2 mr-4 font-medium">{collection?.name}</span>
</button>
<div className="flex flex-grow gap-1 items-center justify-end">
<ToolHint text="Runner" toolhintId="RunnerToolhintId" place="bottom">
<ActionIcon onClick={handleRun} aria-label="Runner" size="sm">
<IconRun size={16} strokeWidth={1.5} />
</ActionIcon>
</ToolHint>
<ToolHint text="Variables" toolhintId="VariablesToolhintId">
<ActionIcon onClick={viewVariables} aria-label="Variables" size="sm">
<IconEye size={16} strokeWidth={1.5} />
</ActionIcon>
</ToolHint>
<ToolHint text="Collection Settings" toolhintId="CollectionSettingsToolhintId">
<ActionIcon onClick={viewCollectionSettings} aria-label="Collection Settings" size="sm">
<IconSettings size={16} strokeWidth={1.5} />
</ActionIcon>
</ToolHint>
<ToolHint text="Javascript Sandbox" toolhintId="JavascriptSandboxToolhintId" place="bottom">
</div>
<div className="flex flex-3 items-center justify-end">
<span className="mr-2">
<JsSandboxMode collection={collection} />
</ToolHint>
<span className="ml-2">
</span>
<span className="mr-3">
<ToolHint text="Runner" toolhintId="RunnnerToolhintId" place="bottom">
<IconRun className="cursor-pointer" size={18} 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} />
</ToolHint>
</span>
<span className="mr-3">
<ToolHint text="Collection Settings" toolhintId="CollectionSettingsToolhintId">
<IconSettings className="cursor-pointer" size={18} strokeWidth={1.5} onClick={viewCollectionSettings} />
</ToolHint>
</span>
<span>
<EnvironmentSelector collection={collection} />
</span>
</div>

View File

@@ -8,7 +8,8 @@ import ExampleIcon from 'components/Icons/ExampleIcon';
import ConfirmRequestClose from '../RequestTab/ConfirmRequestClose';
import RequestTabNotFound from '../RequestTab/RequestTabNotFound';
import StyledWrapper from '../RequestTab/StyledWrapper';
import GradientCloseButton from '../RequestTab/GradientCloseButton';
import CloseTabIcon from '../RequestTab/CloseTabIcon';
import DraftTabIcon from '../RequestTab/DraftTabIcon';
const ExampleTab = ({ tab, collection }) => {
const dispatch = useDispatch();
@@ -58,7 +59,7 @@ const ExampleTab = ({ tab, collection }) => {
if (!item || !example) {
return (
<StyledWrapper
className="flex items-center justify-between tab-container px-3"
className="flex items-center justify-between tab-container px-1"
onMouseUp={(e) => {
if (e.button === 1) {
e.preventDefault();
@@ -74,7 +75,7 @@ const ExampleTab = ({ tab, collection }) => {
}
return (
<StyledWrapper className="flex items-center justify-between tab-container px-2">
<StyledWrapper className="flex items-center justify-between tab-container px-1">
{showConfirmClose && (
<ConfirmRequestClose
item={item}
@@ -102,7 +103,7 @@ const ExampleTab = ({ tab, collection }) => {
/>
)}
<div
className={`flex items-center tab-label ${tab.preview ? 'italic' : ''}`}
className={`flex items-center tab-label pl-2 ${tab.preview ? 'italic' : ''}`}
onContextMenu={handleRightClick}
onDoubleClick={() => dispatch(makeTabPermanent({ uid: tab.uid }))}
onMouseUp={(e) => {
@@ -115,13 +116,13 @@ const ExampleTab = ({ tab, collection }) => {
}
}}
>
<ExampleIcon size={14} color="currentColor" className="mr-1.5 text-gray-500 flex-shrink-0" />
<ExampleIcon size={16} color="currentColor" className="mr-2 text-gray-500 flex-shrink-0" />
<span className="tab-name" title={example.name}>
{example.name}
</span>
</div>
<GradientCloseButton
hasChanges={hasChanges}
<div
className="flex px-2 close-icon-container"
onClick={(e) => {
if (!hasChanges) {
return handleCloseClick(e);
@@ -131,7 +132,13 @@ const ExampleTab = ({ tab, collection }) => {
e.preventDefault();
setShowConfirmClose(true);
}}
/>
>
{!hasChanges ? (
<CloseTabIcon />
) : (
<DraftTabIcon />
)}
</div>
</StyledWrapper>
);
};

View File

@@ -1,105 +0,0 @@
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;
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;

View File

@@ -1,21 +0,0 @@
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;

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react';
import { IconAlertTriangle } from '@tabler/icons';
import GradientCloseButton from './GradientCloseButton';
import CloseTabIcon from './CloseTabIcon';
const RequestTabNotFound = ({ handleCloseClick }) => {
const [showErrorMessage, setShowErrorMessage] = useState(false);
@@ -28,7 +28,9 @@ const RequestTabNotFound = ({ handleCloseClick }) => {
</>
) : null}
</div>
<GradientCloseButton onClick={handleCloseClick} hasChanges={true} />
<div className="flex px-2 close-icon-container" onClick={(e) => handleCloseClick(e)}>
<CloseTabIcon />
</div>
</>
);
};

Some files were not shown because too many files have changed in this diff Show More