Compare commits

..

3 Commits

Author SHA1 Message Date
Anoop M D
ffba0a58e4 chore: fixed lint issues 2025-12-08 14:12:55 +05:30
Anoop M D
b0ba1d8837 chore: fixed lint issues 2025-12-08 14:11:26 +05:30
Anoop M D
5f5cfbeeac feat: design updates 2025-12-08 14:06:30 +05:30
232 changed files with 4392 additions and 9640 deletions

173
package-lock.json generated
View File

@@ -10825,90 +10825,54 @@
"license": "MIT"
},
"node_modules/body-parser": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz",
"integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==",
"version": "1.20.3",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
"integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==",
"license": "MIT",
"dependencies": {
"bytes": "^3.1.2",
"content-type": "^1.0.5",
"debug": "^4.4.0",
"http-errors": "^2.0.0",
"iconv-lite": "^0.6.3",
"on-finished": "^2.4.1",
"qs": "^6.14.0",
"raw-body": "^3.0.0",
"type-is": "^2.0.0"
"bytes": "3.1.2",
"content-type": "~1.0.5",
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
"http-errors": "2.0.0",
"iconv-lite": "0.4.24",
"on-finished": "2.4.1",
"qs": "6.13.0",
"raw-body": "2.5.2",
"type-is": "~1.6.18",
"unpipe": "1.0.0"
},
"engines": {
"node": ">=18"
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/body-parser/node_modules/debug": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
"integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
"node_modules/body-parser/node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
"safer-buffer": ">= 2.1.2 < 3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
"node": ">=0.10.0"
}
},
"node_modules/body-parser/node_modules/media-typer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/body-parser/node_modules/mime-db": {
"version": "1.54.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/body-parser/node_modules/mime-types": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz",
"integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==",
"license": "MIT",
"node_modules/body-parser/node_modules/qs": {
"version": "6.13.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
"integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==",
"license": "BSD-3-Clause",
"dependencies": {
"mime-db": "^1.54.0"
"side-channel": "^1.0.6"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/body-parser/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/body-parser/node_modules/type-is": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
"license": "MIT",
"dependencies": {
"content-type": "^1.0.5",
"media-typer": "^1.1.0",
"mime-types": "^3.0.0"
"node": ">=0.6"
},
"engines": {
"node": ">= 0.6"
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/boolbase": {
@@ -22980,20 +22944,32 @@
}
},
"node_modules/raw-body": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz",
"integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==",
"version": "2.5.2",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz",
"integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==",
"license": "MIT",
"dependencies": {
"bytes": "3.1.2",
"http-errors": "2.0.0",
"iconv-lite": "0.6.3",
"iconv-lite": "0.4.24",
"unpipe": "1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/raw-body/node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react": {
"version": "19.0.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz",
@@ -33852,7 +33828,7 @@
"license": "MIT",
"dependencies": {
"axios": "^1.8.3",
"body-parser": "2.2.0",
"body-parser": "1.20.3",
"cookie-parser": "^1.4.6",
"cors": "^2.8.5",
"express": "^4.21.2",
@@ -33921,30 +33897,6 @@
"url": "https://opencollective.com/express"
}
},
"packages/bruno-tests/node_modules/express/node_modules/body-parser": {
"version": "1.20.3",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
"integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==",
"license": "MIT",
"dependencies": {
"bytes": "3.1.2",
"content-type": "~1.0.5",
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
"http-errors": "2.0.0",
"iconv-lite": "0.4.24",
"on-finished": "2.4.1",
"qs": "6.13.0",
"raw-body": "2.5.2",
"type-is": "~1.6.18",
"unpipe": "1.0.0"
},
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"packages/bruno-tests/node_modules/fast-xml-parser": {
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.0.9.tgz",
@@ -33963,18 +33915,6 @@
"fxparser": "src/cli/cli.js"
}
},
"packages/bruno-tests/node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
},
"engines": {
"node": ">=0.10.0"
}
},
"packages/bruno-tests/node_modules/jsonwebtoken": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
@@ -34045,21 +33985,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"packages/bruno-tests/node_modules/raw-body": {
"version": "2.5.2",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz",
"integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==",
"license": "MIT",
"dependencies": {
"bytes": "3.1.2",
"http-errors": "2.0.0",
"iconv-lite": "0.4.24",
"unpipe": "1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"packages/bruno-tests/node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",

View File

@@ -29,6 +29,7 @@ const Wrapper = styled.div`
.workspace-name-container,
.dropdown-item,
.home-button,
.env-selector-trigger,
.dropdown,
button {
-webkit-app-region: no-drag;
@@ -48,6 +49,25 @@ const Wrapper = styled.div`
margin-left: 0px;
}
/* Home button */
.home-button {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
background: transparent;
border-radius: 6px;
cursor: pointer;
color: ${(props) => props.theme.sidebar.color};
transition: background 0.15s ease;
&:hover {
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
}
}
/* Workspace Name Dropdown Trigger */
.workspace-name-container {
display: flex;
@@ -92,7 +112,7 @@ const Wrapper = styled.div`
.bruno-text {
font-size: 13px;
font-weight: 600;
color: ${(props) => props.theme.text};
color: ${(props) => props.theme.sidebar.muted};
letter-spacing: 0.5px;
}
}
@@ -105,6 +125,36 @@ const Wrapper = styled.div`
flex-shrink: 0;
}
/* Action buttons in right section */
.titlebar-action-button {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
background: transparent;
border-radius: 6px;
cursor: pointer;
color: ${(props) => props.theme.sidebar.color};
transition: background 0.15s ease;
&:hover {
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
}
svg {
color: ${(props) => props.theme.sidebar.color};
}
}
/* Draggable region */
.drag-region {
flex: 1;
height: 100%;
-webkit-app-region: drag;
}
/* Workspace Dropdown Styles */
.workspace-item {
display: flex;

View File

@@ -1,6 +1,5 @@
import React from 'react';
import { IconCheck, IconChevronDown, IconFolder, IconHome, IconLayoutColumns, IconLayoutRows, IconPin, IconPinned, IconPlus } from '@tabler/icons';
import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react';
import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import toast from 'react-hot-toast';
import { useDispatch, useSelector } from 'react-redux';
@@ -10,8 +9,7 @@ import { openWorkspaceDialog, switchWorkspace } from 'providers/ReduxStore/slice
import { sortWorkspaces, toggleWorkspacePin } from 'utils/workspaces';
import Bruno from 'components/Bruno';
import MenuDropdown from 'ui/MenuDropdown';
import ActionIcon from 'ui/ActionIcon';
import Dropdown from 'components/Dropdown';
import IconSidebarToggle from 'components/Icons/IconSidebarToggle';
import CreateWorkspace from 'components/WorkspaceSidebar/CreateWorkspace';
@@ -55,10 +53,13 @@ const AppTitleBar = () => {
}, [workspaces, preferences]);
const [createWorkspaceModalOpen, setCreateWorkspaceModalOpen] = useState(false);
const [showWorkspaceDropdown, setShowWorkspaceDropdown] = useState(false);
const workspaceDropdownTippyRef = useRef();
const onWorkspaceDropdownCreate = (ref) => (workspaceDropdownTippyRef.current = ref);
const WorkspaceName = forwardRef((props, ref) => {
return (
<div ref={ref} className="workspace-name-container" {...props}>
<div ref={ref} className="workspace-name-container" onClick={() => setShowWorkspaceDropdown(!showWorkspaceDropdown)}>
<span className="workspace-name">{toTitleCase(activeWorkspace?.name) || 'Default Workspace'}</span>
<IconChevronDown size={14} stroke={1.5} className="chevron-icon" />
</div>
@@ -71,10 +72,12 @@ const AppTitleBar = () => {
const handleWorkspaceSwitch = (workspaceUid) => {
dispatch(switchWorkspace(workspaceUid));
setShowWorkspaceDropdown(false);
toast.success(`Switched to ${workspaces.find((w) => w.uid === workspaceUid)?.name}`);
};
const handleOpenWorkspace = async () => {
setShowWorkspaceDropdown(false);
try {
await dispatch(openWorkspaceDialog());
toast.success('Workspace opened successfully');
@@ -84,6 +87,7 @@ const AppTitleBar = () => {
};
const handleCreateWorkspace = () => {
setShowWorkspaceDropdown(false);
setCreateWorkspaceModalOpen(true);
};
@@ -120,59 +124,6 @@ const AppTitleBar = () => {
dispatch(savePreferences(updatedPreferences));
};
// Build workspace menu items
const workspaceMenuItems = useMemo(() => {
const items = sortedWorkspaces.map((workspace) => {
const isActive = workspace.uid === activeWorkspaceUid;
const isPinned = preferences?.workspaces?.pinnedWorkspaceUids?.includes(workspace.uid);
return {
id: workspace.uid,
label: toTitleCase(workspace.name),
onClick: () => handleWorkspaceSwitch(workspace.uid),
className: `workspace-item ${isActive ? 'active' : ''}`,
rightSection: (
<div className="workspace-actions">
{workspace.type !== 'default' && (
<ActionIcon
className={`pin-btn ${isPinned ? 'pinned' : ''}`}
onClick={(e) => handlePinWorkspace(workspace.uid, e)}
label={isPinned ? 'Unpin workspace' : 'Pin workspace'}
size="sm"
>
{isPinned ? (
<IconPinned size={14} stroke={1.5} />
) : (
<IconPin size={14} stroke={1.5} />
)}
</ActionIcon>
)}
{isActive && <IconCheck size={16} stroke={1.5} className="check-icon" />}
</div>
)
};
});
// Add label and action items
items.push(
{ type: 'label', label: 'Workspaces' },
{
id: 'create-workspace',
leftSection: IconPlus,
label: 'Create workspace',
onClick: handleCreateWorkspace
},
{
id: 'open-workspace',
leftSection: IconFolder,
label: 'Open workspace',
onClick: handleOpenWorkspace
}
);
return items;
}, [sortedWorkspaces, activeWorkspaceUid, preferences, handlePinWorkspace]);
return (
<StyledWrapper className={`app-titlebar ${isFullScreen ? 'fullscreen' : ''}`}>
{createWorkspaceModalOpen && (
@@ -182,24 +133,61 @@ const AppTitleBar = () => {
<div className="titlebar-content">
{/* Left section: Home + Workspace */}
<div className="titlebar-left">
<ActionIcon
onClick={handleHomeClick}
label="Home"
size="lg"
className="home-button"
>
<button className="home-button" onClick={handleHomeClick} title="Home">
<IconHome size={16} stroke={1.5} />
</ActionIcon>
</button>
{/* Workspace Dropdown */}
<MenuDropdown
data-testid="workspace-menu"
items={workspaceMenuItems}
<Dropdown
onCreate={onWorkspaceDropdownCreate}
icon={<WorkspaceName />}
placement="bottom-start"
selectedItemId={activeWorkspaceUid}
style="new"
visible={showWorkspaceDropdown}
onClickOutside={() => setShowWorkspaceDropdown(false)}
>
<WorkspaceName />
</MenuDropdown>
{sortedWorkspaces.map((workspace) => {
const isActive = workspace.uid === activeWorkspaceUid;
const isPinned = preferences?.workspaces?.pinnedWorkspaceUids?.includes(workspace.uid);
return (
<div
key={workspace.uid}
className={`dropdown-item workspace-item ${isActive ? 'active' : ''}`}
onClick={() => handleWorkspaceSwitch(workspace.uid)}
>
<span className="workspace-name">{toTitleCase(workspace.name)}</span>
<div className="workspace-actions">
{workspace.type !== 'default' && (
<button
className={`pin-btn ${isPinned ? 'pinned' : ''}`}
onClick={(e) => handlePinWorkspace(workspace.uid, e)}
title={isPinned ? 'Unpin workspace' : 'Pin workspace'}
>
{isPinned ? (
<IconPinned size={14} stroke={1.5} />
) : (
<IconPin size={14} stroke={1.5} />
)}
</button>
)}
{isActive && <IconCheck size={16} stroke={1.5} className="check-icon" />}
</div>
</div>
);
})}
<div className="label-item border-top">Workspaces</div>
<div className="dropdown-item" onClick={handleCreateWorkspace}>
<IconPlus size={16} stroke={1.5} className="icon" />
Create workspace
</div>
<div className="dropdown-item" onClick={handleOpenWorkspace}>
<IconFolder size={16} stroke={1.5} className="icon" />
Open workspace
</div>
</Dropdown>
</div>
{/* Center section: Bruno logo + text */}
@@ -211,30 +199,33 @@ const AppTitleBar = () => {
{/* Right section: Action buttons */}
<div className="titlebar-right">
{/* Toggle sidebar */}
<ActionIcon
<button
className="titlebar-action-button"
onClick={handleToggleSidebar}
label={sidebarCollapsed ? 'Show sidebar' : 'Hide sidebar'}
size="lg"
title={sidebarCollapsed ? 'Show sidebar' : 'Hide sidebar'}
aria-label="Toggle Sidebar"
data-testid="toggle-sidebar-button"
>
<IconSidebarToggle collapsed={sidebarCollapsed} size={16} strokeWidth={1.5} />
</ActionIcon>
</button>
{/* Toggle devtools */}
<ActionIcon
<button
className="titlebar-action-button"
onClick={handleToggleDevtools}
label={isConsoleOpen ? 'Hide devtools' : 'Show devtools'}
size="lg"
title={isConsoleOpen ? 'Hide devtools' : 'Show devtools'}
aria-label="Toggle Devtools"
data-testid="toggle-devtools-button"
>
<IconBottombarToggle collapsed={!isConsoleOpen} size={16} strokeWidth={1.5} />
</ActionIcon>
</button>
{/* Toggle vertical layout */}
<ActionIcon
<button
className="titlebar-action-button"
onClick={handleToggleVerticalLayout}
label={orientation === 'horizontal' ? 'Switch to vertical layout' : 'Switch to horizontal layout'}
size="lg"
title={orientation === 'horizontal' ? 'Switch to vertical layout' : 'Switch to horizontal layout'}
aria-label="Toggle Vertical Layout"
data-testid="toggle-vertical-layout-button"
>
{orientation === 'horizontal' ? (
@@ -242,7 +233,8 @@ const AppTitleBar = () => {
) : (
<IconLayoutRows size={16} stroke={1.5} />
)}
</ActionIcon>
</button>
</div>
</div>
</StyledWrapper>

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

@@ -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

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

View File

@@ -1,172 +0,0 @@
import React, { useRef, forwardRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import Dropdown from 'components/Dropdown';
import { newHttpRequest, newGrpcRequest, newWsRequest } from 'providers/ReduxStore/slices/collections/actions';
import { generateUniqueRequestName } from 'utils/collections';
import { sanitizeName } from 'utils/common/regex';
import toast from 'react-hot-toast';
import { IconApi, IconBrandGraphql, IconPlugConnected, IconCode, IconPlus } from '@tabler/icons';
const CreateUntitledRequest = ({ collectionUid, itemUid = null, onRequestCreated, placement = 'bottom' }) => {
const dispatch = useDispatch();
const collections = useSelector((state) => state.collections.collections);
const collection = collections?.find((c) => c.uid === collectionUid);
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
if (!collection) {
return null;
}
const handleCreateHttpRequest = async () => {
dropdownTippyRef.current?.hide();
const uniqueName = await generateUniqueRequestName(collection, 'Untitled', itemUid);
const filename = sanitizeName(uniqueName);
dispatch(
newHttpRequest({
requestName: uniqueName,
filename: filename,
requestType: 'http-request',
requestUrl: '',
requestMethod: 'GET',
collectionUid: collection.uid,
itemUid: itemUid
})
)
.then(() => {
toast.success('New request created!');
onRequestCreated?.();
})
.catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request'));
};
const handleCreateGraphQLRequest = async () => {
dropdownTippyRef.current?.hide();
const uniqueName = await generateUniqueRequestName(collection, 'Untitled', itemUid);
const filename = sanitizeName(uniqueName);
dispatch(
newHttpRequest({
requestName: uniqueName,
filename: filename,
requestType: 'graphql-request',
requestUrl: '',
requestMethod: 'POST',
collectionUid: collection.uid,
itemUid: itemUid,
body: {
mode: 'graphql',
graphql: {
query: '',
variables: ''
}
}
})
)
.then(() => {
toast.success('New request created!');
onRequestCreated?.();
})
.catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request'));
};
const handleCreateWebSocketRequest = async () => {
dropdownTippyRef.current?.hide();
const uniqueName = await generateUniqueRequestName(collection, 'Untitled', itemUid);
const filename = sanitizeName(uniqueName);
dispatch(
newWsRequest({
requestName: uniqueName,
filename: filename,
requestUrl: '',
requestMethod: 'ws',
collectionUid: collection.uid,
itemUid: itemUid
})
)
.then(() => {
toast.success('New request created!');
onRequestCreated?.();
})
.catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request'));
};
const handleCreateGrpcRequest = async () => {
dropdownTippyRef.current?.hide();
const uniqueName = await generateUniqueRequestName(collection, 'Untitled', itemUid);
const filename = sanitizeName(uniqueName);
dispatch(
newGrpcRequest({
requestName: uniqueName,
filename: filename,
requestUrl: '',
collectionUid: collection.uid,
itemUid: itemUid
})
)
.then(() => {
toast.success('New request created!');
onRequestCreated?.();
})
.catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request'));
};
return (
<Dropdown onCreate={onDropdownCreate} icon={<IconPlus size={16} strokeWidth={2} />} placement={placement}>
<div
className="dropdown-item"
onClick={(e) => {
dropdownTippyRef.current.hide();
handleCreateHttpRequest();
}}
>
<span className="dropdown-icon">
<IconApi size={16} strokeWidth={2} />
</span>
HTTP
</div>
<div
className="dropdown-item"
onClick={(e) => {
dropdownTippyRef.current.hide();
handleCreateGraphQLRequest();
}}
>
<span className="dropdown-icon">
<IconBrandGraphql size={16} strokeWidth={2} />
</span>
GraphQL
</div>
<div
className="dropdown-item"
onClick={(e) => {
dropdownTippyRef.current.hide();
handleCreateWebSocketRequest();
}}
>
<span className="dropdown-icon">
<IconPlugConnected size={16} strokeWidth={2} />
</span>
WebSocket
</div>
<div
className="dropdown-item"
onClick={(e) => {
dropdownTippyRef.current.hide();
handleCreateGrpcRequest();
}}
>
<span className="dropdown-icon">
<IconCode size={16} strokeWidth={2} />
</span>
gRPC
</div>
</Dropdown>
);
};
export default CreateUntitledRequest;

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,16 +25,6 @@ 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;
@@ -69,10 +59,6 @@ const Wrapper = styled.div`
}
}
.dropdown-label {
flex: 1;
}
.dropdown-icon {
flex-shrink: 0;
width: 16px;
@@ -84,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;

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;
&:nth-child(1) {
width: 25px !important;
border-right: none;
}
&:last-child {
border-right: none;
}
}
}
tbody {
tr {
transition: background 0.1s ease;
&:last-child td {
border-bottom: none;
}
td {
padding: 2px 10px;
border-top: none !important;
border-left: none !important;
border-bottom: ${(props) => props.theme.workspace.environments.indentBorder};
border-right: ${(props) => props.theme.workspace.environments.indentBorder};
vertical-align: middle;
&:nth-child(1) {
width: 25px;
border-right: none;
text-align: center;
vertical-align: middle;
line-height: 1;
input[type='checkbox'] {
vertical-align: baseline;
display: inline-block;
}
}
&:last-child {
border-right: none;
}
}
}
}
.tooltip-mod {
font-size: 11px !important;
max-width: 200px !important;
}
input[type='text'] {
width: 100%;
outline: none !important;
background-color: transparent;
color: ${(props) => props.theme.text};
padding: 0;
font-size: 12px;
border-radius: 4px;
transition: all 0.15s ease;
&:focus {
outline: none !important;
}
}
input[type='checkbox'] {
cursor: pointer;
width: 14px;
height: 14px;
accent-color: ${(props) => props.theme.workspace.accent};
vertical-align: middle;
margin: 0;
}
button {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 4px;
color: ${(props) => props.theme.colors.text.muted};
background: transparent;
border: none;
cursor: pointer;
border-radius: 4px;
transition: color 0.15s ease, background 0.15s ease;
&:hover {
color: ${(props) => props.theme.colors.text.danger};
}
}
.drag-handle {
.icon-grip,
.icon-minus {
color: ${(props) => props.theme.colors.text.muted};
}
}
select {
background-color: transparent;
color: ${(props) => props.theme.text};
border: none;
outline: none;
padding: 2px 8px;
font-size: 12px;
cursor: pointer;
option {
background-color: ${(props) => props.theme.bg};
color: ${(props) => props.theme.text};
}
}
`;
export default StyledWrapper;

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>
<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

@@ -3,54 +3,45 @@ 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;
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

@@ -162,7 +162,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 +174,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>
);
});

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,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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -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: ${(props) => props.theme.tabs.marginRight};
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 RequestPaneTabs from 'components/RequestPane/RequestPaneTabs';
const MULTIPLE_CONTENT_TABS = new Set(['script', 'vars', 'auth', 'docs']);
const TAB_CONFIG = [
{ key: 'query', label: 'Query' },
{ key: 'variables', label: 'Variables' },
{ key: 'headers', label: 'Headers' },
{ key: 'auth', label: 'Auth' },
{ key: 'vars', label: 'Vars' },
{ key: 'script', label: 'Script' },
{ key: 'assert', label: 'Assert' },
{ key: 'tests', label: 'Tests' },
{ key: 'docs', label: 'Docs' },
{ key: 'settings', label: 'Settings' }
];
const GraphQLRequestPane = ({ item, collection, onSchemaLoad, toggleDocs, handleGqlClickReference }) => {
const dispatch = useDispatch();
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const preferences = useSelector((state) => state.app.preferences);
const query = item.draft
? get(item, 'draft.request.body.graphql.query', '')
: get(item, 'request.body.graphql.query', '');
const variables = item.draft
? get(item, 'draft.request.body.graphql.variables')
: get(item, 'request.body.graphql.variables');
const { displayedTheme } = useTheme();
const [schema, setSchema] = useState(null);
const 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">
<RequestPaneTabs
tabs={allTabs}
activeTab={requestPaneTab}
onTabSelect={selectTab}
rightContent={rightContent}
rightContentRef={schemaActionsRef}
/>
<section className={classnames('flex w-full flex-1', { 'mt-5': !isMultipleContentTab })}>
<HeightBoundContainer>{tabPanel}</HeightBoundContainer>
<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

@@ -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,25 +1,14 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
&.tabs {
div.more-tabs {
color: var(--color-tab-inactive) !important;
border-bottom: solid 2px transparent;
}
div.tabs {
div.tab {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 6px 0px;
border: none;
border-bottom: solid 2px transparent;
margin-right: ${(props) => props.theme.tabs.marginRight};
color: var(--color-tab-inactive);
cursor: pointer;
white-space: nowrap;
vertical-align: middle;
flex-shrink: 0;
&:focus,
&:active,
@@ -31,21 +20,12 @@ const StyledWrapper = styled.div`
}
&.active {
font-weight: ${(props) => props.theme.tabs.active.fontWeight} !important;
color: ${(props) => props.theme.tabs.active.color} !important;
border-bottom: solid 2px ${(props) => props.theme.tabs.active.border} !important;
}
.content-indicator {
color: ${(props) => props.theme.text};
}
sup {
display: inline-flex;
align-items: center;
line-height: 1;
vertical-align: baseline;
margin-left: 0;
color: ${(props) => props.theme.text}
}
}
}

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,144 +11,178 @@ 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 RequestPaneTabs from 'components/RequestPane/RequestPaneTabs';
import HeightBoundContainer from 'ui/HeightBoundContainer';
const MULTIPLE_CONTENT_TABS = new Set(['params', 'script', 'vars', 'auth', 'docs']);
const TAB_CONFIG = [
{ key: 'params', label: 'Params' },
{ key: 'body', label: 'Body' },
{ key: 'headers', label: 'Headers' },
{ key: 'auth', label: 'Auth' },
{ key: 'vars', label: 'Vars' },
{ key: 'script', label: 'Script' },
{ key: 'assert', label: 'Assert' },
{ key: 'tests', label: 'Tests' },
{ key: 'docs', label: 'Docs' },
{ key: 'settings', label: 'Settings' }
];
const TAB_PANELS = {
params: QueryParams,
body: RequestBody,
headers: RequestHeaders,
auth: Auth,
vars: Vars,
assert: Assertions,
script: Script,
tests: Tests,
docs: Documentation,
settings: Settings
};
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 bodyModeRef = 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={bodyModeRef}>
<RequestBodyMode 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 responseVars = getPropertyFromDraftOrRequest('request.vars.res');
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
+ responseVars.filter((response) => response.enabled).length;
useEffect(() => {
if (activeParamsLength === 0 && body.mode !== 'none') {
selectTab('body');
}
}, []);
return (
<div className="flex flex-col h-full relative">
<RequestPaneTabs
tabs={allTabs}
activeTab={requestPaneTab}
onTabSelect={selectTab}
rightContent={rightContent}
rightContentRef={bodyModeRef}
delayedTabs={['body']}
/>
<section className={classnames('flex w-full flex-1', { 'mt-3': !isMultipleContentTab })}>
<HeightBoundContainer>{tabPanel}</HeightBoundContainer>
<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

@@ -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}

View File

@@ -2,7 +2,6 @@ import styled from 'styled-components';
const Wrapper = styled.div`
font-size: ${(props) => props.theme.font.size.base};
min-width: 125px;
.body-mode-selector {
background: transparent;

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

@@ -1,178 +0,0 @@
import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
import classnames from 'classnames';
import Dropdown from 'components/Dropdown';
import { IconChevronDown } from '@tabler/icons';
import StyledWrapper from './StyledWrapper';
const DROPDOWN_WIDTH = 60;
const CALCULATION_DELAY_DEFAULT = 20;
const CALCULATION_DELAY_EXTENDED = 150;
const RequestPaneTabs = ({
tabs,
activeTab,
onTabSelect,
rightContent,
rightContentRef,
delayedTabs = []
}) => {
const [visibleTabs, setVisibleTabs] = useState([]);
const [overflowTabs, setOverflowTabs] = useState([]);
const tabsContainerRef = useRef(null);
const tabRefsMap = useRef({});
const dropdownTippyRef = useRef(null);
const handleTabSelect = useCallback(
(tabKey) => {
onTabSelect(tabKey);
dropdownTippyRef.current?.hide();
},
[onTabSelect]
);
const calculateTabVisibility = useCallback(() => {
const container = tabsContainerRef.current;
if (!container || !tabs.length) return;
const containerWidth = container.offsetWidth;
const rightContentWidth = rightContentRef?.current
? rightContentRef.current.offsetWidth + 20
: 0;
const availableWidth = containerWidth - rightContentWidth - DROPDOWN_WIDTH;
const visible = [];
const overflow = [];
let currentWidth = 0;
for (const tab of tabs) {
const tabElement = tabRefsMap.current[tab.key];
const tabWidth = tabElement ? tabElement.offsetWidth + 20 : 100;
if (currentWidth + tabWidth <= availableWidth && !overflow.length) {
visible.push(tab);
currentWidth += tabWidth;
} else {
overflow.push(tab);
}
}
if (!visible.some((t) => t.key === activeTab) && overflow.length) {
const activeTabIndex = overflow.findIndex((t) => t.key === activeTab);
if (activeTabIndex !== -1) {
const [activeTabItem] = overflow.splice(activeTabIndex, 1);
const lastVisible = visible.pop();
if (lastVisible) overflow.unshift(lastVisible);
visible.push(activeTabItem);
}
}
setVisibleTabs(visible);
setOverflowTabs(overflow);
}, [tabs, activeTab, rightContentRef]);
const renderTab = useCallback(
(tab, isInDropdown = false) => {
const isActive = tab.key === activeTab;
if (isInDropdown) {
return (
<div
key={tab.key}
className={classnames('dropdown-item', { active: isActive })}
role="tab"
onClick={() => handleTabSelect(tab.key)}
>
<span className="flex items-center gap-1">
{tab.label}
{tab.indicator}
</span>
</div>
);
}
return (
<div
key={tab.key}
className={classnames('tab select-none', tab.key, { active: isActive })}
role="tab"
onClick={() => handleTabSelect(tab.key)}
ref={(el) => el && (tabRefsMap.current[tab.key] = el)}
>
{tab.label}
{tab.indicator}
</div>
);
},
[activeTab, handleTabSelect]
);
useEffect(() => {
const delay = delayedTabs.includes(activeTab) ? CALCULATION_DELAY_EXTENDED : CALCULATION_DELAY_DEFAULT;
const timeoutId = setTimeout(() => requestAnimationFrame(calculateTabVisibility), delay);
return () => clearTimeout(timeoutId);
}, [calculateTabVisibility, activeTab, delayedTabs]);
useEffect(() => {
let frameId = null;
const observer = new ResizeObserver(() => {
if (frameId) cancelAnimationFrame(frameId);
frameId = requestAnimationFrame(calculateTabVisibility);
});
if (tabsContainerRef.current) observer.observe(tabsContainerRef.current);
if (rightContentRef?.current) observer.observe(rightContentRef.current);
return () => {
if (frameId) cancelAnimationFrame(frameId);
observer.disconnect();
};
}, [calculateTabVisibility, rightContentRef]);
const hiddenStyle = useMemo(
() => ({ visibility: 'hidden', position: 'absolute', display: 'flex', pointerEvents: 'none' }),
[]
);
return (
<StyledWrapper ref={tabsContainerRef} className="flex items-center tabs" role="tablist">
<div style={hiddenStyle}>
{tabs.map((tab) => (
<div
key={tab.key}
className={classnames('tab select-none', tab.key, { active: tab.key === activeTab })}
ref={(el) => el && (tabRefsMap.current[tab.key] = el)}
>
{tab.label}
{tab.indicator}
</div>
))}
</div>
{visibleTabs.map((tab) => renderTab(tab))}
{overflowTabs.length > 0 && (
<Dropdown
icon={(
<div className="more-tabs select-none flex items-center cursor-pointer gap-1">
<span>More</span>
<IconChevronDown size={14} strokeWidth={2} />
</div>
)}
placement="bottom-start"
onCreate={(instance) => (dropdownTippyRef.current = instance)}
>
<div style={{ minWidth: '150px' }}>{overflowTabs.map((tab) => renderTab(tab, true))}</div>
</Dropdown>
)}
{rightContent && (
<div className="flex flex-grow justify-end items-center">
{rightContent}
</div>
)}
</StyledWrapper>
);
};
export default RequestPaneTabs;

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

@@ -49,24 +49,22 @@ const CollectionToolBar = ({ collection }) => {
<span className="ml-2 mr-4 font-medium">{collection?.name}</span>
</div>
<div className="flex flex-3 items-center justify-end">
<span className="mr-2">
<JsSandboxMode collection={collection} />
</span>
<span className="mr-3">
<ToolHint text="Runner" toolhintId="RunnnerToolhintId" place="bottom">
<IconRun className="cursor-pointer" size={16} strokeWidth={1.5} onClick={handleRun} />
<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={16} strokeWidth={1.5} onClick={viewVariables} />
<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={16} strokeWidth={1.5} onClick={viewCollectionSettings} />
</ToolHint>
</span>
<span className="mr-2">
<ToolHint text="Javascript Sandbox" toolhintId="JavascriptSandboxToolhintId" place="bottom">
<JsSandboxMode collection={collection} />
<IconSettings className="cursor-pointer" size={18} strokeWidth={1.5} onClick={viewCollectionSettings} />
</ToolHint>
</span>
<span>

View File

@@ -74,7 +74,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-3">
{showConfirmClose && (
<ConfirmRequestClose
item={item}
@@ -102,7 +102,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) => {

View File

@@ -24,9 +24,6 @@ const StyledWrapper = styled.div`
overflow: hidden;
white-space: nowrap;
font-size: 0.8125rem;
// so that the name does not cutoff when italicized
padding-right: 2px;
}
`;

View File

@@ -33,7 +33,7 @@ const Wrapper = styled.div`
}
ul {
padding: 0 3px;
padding: 0 2px;
margin: 0;
display: flex;
align-items: flex-end;
@@ -122,13 +122,13 @@ const Wrapper = styled.div`
&::before {
content: '';
position: absolute;
bottom: 1px;
bottom: -1px;
left: -8px;
width: 8px;
height: 8px;
background: transparent;
border-bottom-right-radius: 6px;
box-shadow: 3px 3px 0 0 ${(props) => props.theme.bg || '#ffffff'};
border-bottom-right-radius: 8px;
box-shadow: 2px 2px 0 0 ${(props) => props.theme.bg || '#ffffff'};
border-right: 1px solid ${(props) => props.theme.requestTabs.bottomBorder};
border-bottom: 1px solid ${(props) => props.theme.requestTabs.bottomBorder};
}
@@ -136,13 +136,13 @@ const Wrapper = styled.div`
&::after {
content: '';
position: absolute;
bottom: 1px;
bottom: -1px;
right: -8px;
width: 8px;
height: 8px;
background: transparent;
border-bottom-left-radius: 6px;
box-shadow: -3px 3px 0 0 ${(props) => props.theme.bg || '#ffffff'};
border-bottom-left-radius: 8px;
box-shadow: -2px 2px 0 0 ${(props) => props.theme.bg || '#ffffff'};
border-left: 1px solid ${(props) => props.theme.requestTabs.bottomBorder};
border-bottom: 1px solid ${(props) => props.theme.requestTabs.bottomBorder};
}

View File

@@ -10,7 +10,6 @@ import CollectionToolBar from './CollectionToolBar';
import RequestTab from './RequestTab';
import StyledWrapper from './StyledWrapper';
import DraggableTab from './DraggableTab';
import CreateUntitledRequest from 'components/CreateUntitledRequest';
const RequestTabs = () => {
const dispatch = useDispatch();
@@ -79,6 +78,8 @@ const RequestTabs = () => {
);
};
const createNewTab = () => setNewRequestModalOpen(true);
if (!activeTabUid) {
return null;
}
@@ -177,16 +178,19 @@ const RequestTabs = () => {
</div>
</li>
) : null}
<div className="flex items-center cursor-pointer short-tab">
{activeCollection && (
<CreateUntitledRequest
collectionUid={activeCollection.uid}
itemUid={null}
placement="bottom-start"
/>
)}
</div>
<li className="select-none short-tab" id="create-new-tab" onClick={createNewTab}>
<div className="flex items-center">
<svg
xmlns="http://www.w3.org/2000/svg"
width="22"
height="22"
fill="currentColor"
viewBox="0 0 16 16"
>
<path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z" />
</svg>
</div>
</li>
{/* Moved to post mvp */}
{/* <li className="select-none new-tab choose-request">
<div className="flex items-center">

View File

@@ -8,7 +8,6 @@ import { updateResponseExampleMultipartFormParams } from 'providers/ReduxStore/s
import mime from 'mime-types';
import path from 'utils/common/path';
import MultiLineEditor from 'components/MultiLineEditor';
import SingleLineEditor from 'components/SingleLineEditor';
import StyledWrapper from './StyledWrapper';
import FilePickerEditor from 'components/FilePickerEditor';
import Table from 'components/Table-v2';
@@ -207,7 +206,7 @@ const ResponseExampleMultipartFormParams = ({ item, collection, exampleUid, edit
</td>
<td>
<div className="flex items-center justify-center pl-4">
<SingleLineEditor
<MultiLineEditor
onSave={() => {}}
theme={storedTheme}
placeholder="Auto"

View File

@@ -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

@@ -15,7 +15,7 @@ const ClearTimeline = ({ collection, item }) => {
);
return (
<StyledWrapper className="flex items-center">
<StyledWrapper className="ml-2 flex items-center">
<button onClick={clearResponse} className="text-link hover:underline" title="Clear Timeline">
Clear Timeline
</button>

View File

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

@@ -25,7 +25,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

@@ -10,6 +10,7 @@ import GrpcStatusCode from './GrpcStatusCode';
import ResponseTime from '../ResponseTime/index';
import Timeline from '../Timeline';
import ClearTimeline from '../ClearTimeline';
import ResponseSave from '../ResponseSave';
import ResponseClear from '../ResponseClear';
import StyledWrapper from './StyledWrapper';
import ResponseTrailers from './ResponseTrailers';

View File

@@ -1,16 +0,0 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
border-radius: 4px;
border: 1px solid ${(props) => props.theme.console.border};
.query-response-content {
border-top: 1px solid ${(props) => props.theme.console.border};
}
`;
export default StyledWrapper;

View File

@@ -1,60 +0,0 @@
import React, { useEffect, useState } from 'react';
import QueryResult from '../QueryResult';
import { useInitialResponseFormat, useResponsePreviewFormatOptions } from '../QueryResult/index';
import QueryResultTypeSelector from '../QueryResult/QueryResultTypeSelector/index';
import StyledWrapper from './StyledWrapper';
import classnames from 'classnames';
const QueryResponse = ({
item,
collection,
data,
dataBuffer,
disableRunEventListener,
headers,
error
}) => {
const { initialFormat, initialTab } = useInitialResponseFormat(dataBuffer, headers);
const previewFormatOptions = useResponsePreviewFormatOptions(dataBuffer, headers);
const [selectedFormat, setSelectedFormat] = useState('raw');
const [selectedTab, setSelectedTab] = useState('editor');
useEffect(() => {
if (initialFormat !== null && initialTab !== null) {
setSelectedFormat(initialFormat);
setSelectedTab(initialTab);
}
}, [initialFormat, initialTab]);
return (
<StyledWrapper>
<div className="flex items-center justify-end p-2">
<QueryResultTypeSelector
formatOptions={previewFormatOptions}
formatValue={selectedFormat}
onFormatChange={(newFormat) => {
setSelectedFormat(newFormat);
}}
onPreviewTabSelect={() => {
setSelectedTab((prev) => prev === 'editor' ? 'preview' : 'editor');
}}
selectedTab={selectedTab}
/>
</div>
<div className={classnames('flex-1 query-response-content', selectedTab === 'editor' ? 'px-2 py-1' : '')}>
<QueryResult
item={item}
collection={collection}
data={data}
dataBuffer={dataBuffer}
disableRunEventListener={disableRunEventListener}
headers={headers}
error={error}
selectedFormat={selectedFormat}
selectedTab={selectedTab}
/>
</div>
</StyledWrapper>
);
};
export default QueryResponse;

View File

@@ -1,78 +0,0 @@
import React, { useRef, useState, useEffect } from 'react';
import { isValidHtml } from 'utils/common/index';
import { escapeHtml, isValidHtmlSnippet } from 'utils/response/index';
const HtmlPreview = React.memo(({ data, baseUrl }) => {
const webviewContainerRef = useRef(null);
const [isDragging, setIsDragging] = useState(false);
useEffect(() => {
if (!webviewContainerRef.current) return;
const checkDragging = () => {
const hasDraggingParent = webviewContainerRef.current?.closest('.dragging');
setIsDragging(!!hasDraggingParent);
};
// Watch from a common ancestor where .dragging gets added
const watchTarget = webviewContainerRef.current.closest('.main-section')
|| document.body;
const mutationObserver = new MutationObserver(checkDragging);
mutationObserver.observe(watchTarget, {
attributes: true,
attributeFilter: ['class'],
subtree: true
});
// Check initial state
checkDragging();
return () => mutationObserver.disconnect();
}, []);
if (isValidHtml(data) || isValidHtmlSnippet(data)) {
const htmlContent = data.includes('<head>')
? data.replace('<head>', `<head><base href="${escapeHtml(baseUrl)}">`)
: `<head><base href="${escapeHtml(baseUrl)}"></head>${data}`;
const dragStyles = isDragging ? { pointerEvents: 'none', userSelect: 'none' } : {};
return (
<div
ref={webviewContainerRef}
className="h-full bg-white webview-container"
style={dragStyles}
>
<webview
src={`data:text/html; charset=utf-8,${encodeURIComponent(htmlContent)}`}
webpreferences="disableDialogs=true, javascript=yes"
className="h-full bg-white"
style={dragStyles}
/>
</div>
);
}
// For all other data types, render safely as formatted text
let displayContent = '';
if (data === null || data === undefined) {
displayContent = String(data);
} else if (typeof data === 'object') {
displayContent = JSON.stringify(data, null);
} else if (typeof data === 'string') {
displayContent = data;
} else {
displayContent = String(data);
}
return (
<pre
className="bg-white font-mono text-[13px] whitespace-pre-wrap break-words overflow-auto overflow-x-hidden p-4 text-[#24292f] w-full max-w-full h-full box-border relative"
>
{displayContent}
</pre>
);
});
export default HtmlPreview;

View File

@@ -1,63 +0,0 @@
import React from 'react';
import ReactJson from 'react-json-view';
import ErrorAlert from 'ui/ErrorAlert/index';
const JsonPreview = ({ data, displayedTheme }) => {
// Helper function to validate and parse JSON data
const validateJsonData = (data) => {
// If data is already an object or array, use it directly
if (typeof data === 'object' && data !== null) {
return { data, error: null };
}
// If data is a string, try to parse it
if (typeof data === 'string') {
try {
const parsed = JSON.parse(data);
return { data: parsed, error: null };
} catch (e) {
return { data: null, error: `Invalid JSON format: ${e.message}` };
}
}
// For other types, return error
return { data: null, error: 'Invalid input. Expected a JSON object, array, or valid JSON string.' };
};
// Validate and parse JSON data
const jsonData = validateJsonData(data);
// Show error if parsing failed
if (jsonData.error) {
return <ErrorAlert title="Cannot preview as JSON" message={jsonData.error} />;
}
// Validate that data can be rendered as JSON tree
if (jsonData.data === null || jsonData.data === undefined) {
return <ErrorAlert title="Cannot preview as JSON" message="Data is null or undefined. Expected a valid JSON object or array." />;
}
if (typeof jsonData.data !== 'object') {
return <ErrorAlert title="Cannot preview as JSON" message="Data cannot be rendered as a JSON tree. Expected a JSON object or array." />;
}
return (
<ReactJson
src={jsonData.data}
theme={displayedTheme === 'light' ? 'rjv-default' : 'monokai'}
collapsed={1}
displayDataTypes={false}
displayObjectSize={true}
enableClipboard={true}
name={false}
style={{
backgroundColor: 'transparent',
fontSize: '12px',
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
padding: '16px'
}}
/>
);
};
export default JsonPreview;

View File

@@ -1,25 +0,0 @@
import React, { memo, useMemo } from 'react';
const TextPreview = memo(({ data }) => {
const displayData = useMemo(() => {
if (data === null || data === undefined) {
return String(data);
}
if (typeof data === 'object') {
try {
return JSON.stringify(data);
} catch {
return String(data);
}
}
return String(data);
}, [data]);
return (
<div className="p-4 font-mono text-[13px] whitespace-pre-wrap break-words overflow-auto overflow-x-hidden w-full max-w-full h-full">
{displayData}
</div>
);
});
export default TextPreview;

View File

@@ -1,31 +0,0 @@
import React from 'react';
import { useEffect, useState } from 'react';
import ReactPlayer from 'react-player';
const VideoPreview = React.memo(({ contentType, dataBuffer }) => {
const [videoUrl, setVideoUrl] = useState(null);
useEffect(() => {
const videoType = contentType.split(';')[0];
const byteArray = Buffer.from(dataBuffer, 'base64');
const blob = new Blob([byteArray], { type: videoType });
const url = URL.createObjectURL(blob);
setVideoUrl(url);
return () => URL.revokeObjectURL(url);
}, [contentType, dataBuffer]);
if (!videoUrl) return <div>Loading video...</div>;
return (
<ReactPlayer
url={videoUrl}
controls
muted={true}
width="100%"
height="100%"
onError={(e) => console.error('Error loading video:', e)}
/>
);
});
export default VideoPreview;

View File

@@ -1,77 +0,0 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
font-size: 12px;
line-height: 20px;
padding: 16px;
overflow: auto;
color: ${(props) => props.theme.text};
.xml-container {
color: ${(props) => props.theme.text};
}
.xml-node-name {
color: ${(props) => props.theme.codemirror.tokens.property};
font-weight: 500;
}
.xml-separator {
color: ${(props) => props.theme.codemirror.tokens.operator};
margin: 0 8px;
}
.xml-value {
color: ${(props) => props.theme.codemirror.tokens.string};
white-space: pre-wrap;
word-break: break-all;
}
.xml-empty-value {
color: ${(props) => props.theme.codemirror.tokens.comment};
}
.xml-count {
color: ${(props) => props.theme.codemirror.tokens.comment};
margin-left: 8px;
}
.xml-toggle-button {
margin-right: 8px;
cursor: pointer;
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
color: ${(props) => props.theme.codemirror.tokens.atom};
flex-shrink: 0;
border-radius: 4px;
transition: background-color 0.2s;
&:hover {
background-color: ${(props) => props.theme.console.buttonHoverBg};
}
}
.xml-array-toggle-button {
margin-right: 8px;
cursor: pointer;
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
color: ${(props) => props.theme.codemirror.tokens.atom};
flex-shrink: 0;
border-radius: 4px;
transition: background-color 0.2s;
&:hover {
background-color: ${(props) => props.theme.console.buttonHoverBg};
}
}
`;
export default StyledWrapper;

View File

@@ -1,396 +0,0 @@
import ErrorAlert from 'ui/ErrorAlert/index';
import React, { useState, useMemo } from 'react';
import StyledWrapper from './StyledWrapper';
// The expected "data" prop must be an XML string.
export default function XmlPreview({ data, defaultExpanded = true }) {
// Parse XML string
const parsedData = useMemo(() => {
if (typeof data !== 'string') {
return { error: 'Invalid input. Expected an XML string.' };
}
const parsed = parseXMLString(data);
if (parsed === null) {
return { error: 'Failed to parse XML string. Invalid XML format.' };
}
return parsed;
}, [data]);
// Check for parsing error
if (parsedData && typeof parsedData === 'object' && parsedData.error) {
return (
<ErrorAlert title="Cannot preview as XML" message={parsedData.error} />
);
}
// Validate that data can be rendered as a tree
const isValidTreeData = (data) => {
if (data === null || data === undefined) return false;
if (typeof data === 'object' && !Array.isArray(data)) return true;
if (Array.isArray(data)) return true;
return false;
};
if (!isValidTreeData(parsedData)) {
return (
<ErrorAlert title="Cannot preview as XML" message="Data cannot be rendered as a tree. Expected a valid XML string." />
);
}
// If root is an object with a single key, unwrap it to show the actual root element
let rootNode = parsedData;
let rootNodeName = '';
if (typeof parsedData === 'object' && !Array.isArray(parsedData) && parsedData !== null) {
const keys = Object.keys(parsedData).filter((k) => k !== '$' && k !== '@_' && k !== '#text');
if (keys.length === 1) {
rootNodeName = keys[0];
rootNode = parsedData[keys[0]];
} else if (keys.length === 0) {
// Empty object with no children
return (
<ErrorAlert title="Cannot preview as XML" message="Cannot render XML tree. Root object is empty." />
);
}
}
return (
<StyledWrapper>
<div className="xml-container">
<XmlNode
node={rootNode}
nodeName={rootNodeName}
isRoot={true}
isLast={true}
defaultExpanded={defaultExpanded}
/>
</div>
</StyledWrapper>
);
}
// Component for rendering array entries with expand/collapse functionality
const XmlArrayNode = ({ arrayKey, items, depth, defaultExpanded = true }) => {
const [expanded, setExpanded] = useState(defaultExpanded);
const toggle = (e) => {
e.stopPropagation();
setExpanded((v) => !v);
};
return (
<div style={{ paddingLeft: `${(depth + 1) * 20}px` }}>
<div className="flex items-center mb-1">
<button
onClick={toggle}
className="xml-array-toggle-button"
tabIndex={-1}
aria-expanded={expanded}
>
{expanded ? '▼' : '▶'}
</button>
<span className="xml-node-name">{arrayKey}</span>
<span className="xml-count">[{items.length}]</span>
</div>
{expanded && (
<div className="array-content">
{items.map((item, itemIdx) => (
<XmlNode
key={`${arrayKey}-${itemIdx}`}
node={item}
nodeName={`${itemIdx}`}
isLast={itemIdx === items.length - 1}
defaultExpanded={false}
depth={depth + 2}
/>
))}
</div>
)}
</div>
);
};
const XmlNode = ({
node,
nodeName = '',
isRoot = false,
isLast = true,
defaultExpanded = true,
depth = 0
}) => {
const [expanded, setExpanded] = useState(defaultExpanded);
let displayNodeName = nodeName;
if (Array.isArray(node)) {
// For repeated XML elements with same name (e.g. <item>...</item><item>...</item>)
return (
<>
{node.map((item, idx) => (
<XmlNode
key={idx}
node={item}
nodeName={displayNodeName}
isRoot={false}
isLast={idx === node.length - 1}
defaultExpanded={false}
depth={depth}
/>
))}
</>
);
}
const childEntries = getChildrenEntries(node);
const childCount = getChildCount(node);
const isLeaf = isTextNode(node) || (typeof node === 'object' && childCount === 0);
const toggle = (e) => {
e.stopPropagation();
setExpanded((v) => !v);
};
// For leaf nodes with text content or attributes with empty values
if (isLeaf && isTextNode(node)) {
const value = String(node);
return (
<div className="flex items-start mb-1" style={{ paddingLeft: `${depth * 20}px` }}>
{displayNodeName && (
<>
<span className="xml-node-name">{displayNodeName}</span>
<span className="xml-separator">:</span>
</>
)}
<span className="xml-value">{value}</span>
</div>
);
}
// For empty leaf nodes (attributes without values, etc)
if (isLeaf && !isTextNode(node)) {
// Check if this is an attribute-only node with _text
if (typeof node === 'object' && node !== null && '_text' in node) {
// This node has both attributes and text, handle in expandable section
// Fall through to expandable node rendering
} else {
return (
<div className="flex items-center mb-1" style={{ paddingLeft: `${depth * 20}px` }}>
{displayNodeName && (
<>
<span className="xml-node-name">{displayNodeName}</span>
<span className="xml-separator">:</span>
<span className="xml-empty-value">{'{}'}</span>
</>
)}
</div>
);
}
}
// For expandable nodes - show as tree structure
// If no node name at root level, render children directly
if (!displayNodeName && depth === 0) {
if (childEntries.length > 0) {
return (
<div>
{childEntries.map(([key, value], idx) => (
<XmlNode
key={key + idx}
node={value}
nodeName={key}
isLast={idx === childEntries.length - 1}
defaultExpanded={defaultExpanded}
depth={0}
/>
))}
</div>
);
}
return null;
}
// If no display name at non-root level, use a fallback
if (!displayNodeName) {
displayNodeName = '(unnamed)';
}
// Determine if this node's value is an array
const hasArrayValue = Array.isArray(node);
const arrayLength = hasArrayValue ? node.length : 0;
return (
<div style={{ paddingLeft: `${depth * 20}px` }}>
<div className="flex items-center mb-1">
<button
onClick={toggle}
className="xml-toggle-button"
tabIndex={-1}
aria-expanded={expanded}
>
{expanded ? '▼' : '▶'}
</button>
<span className="xml-node-name">
{displayNodeName}
</span>
{childCount > 0 && (
<span className="xml-count">
{`{${childCount}}`}
</span>
)}
</div>
{expanded && childEntries.length > 0 && (
<div>
{childEntries.map(([key, value], idx) => {
// Check if this is an attribute (starts with _)
const isAttribute = key.startsWith('_');
// Handle attributes
if (isAttribute) {
const displayValue = value === '' ? 'value' : value;
return (
<div key={key + idx} className="flex items-start mb-1" style={{ paddingLeft: `${(depth + 1) * 20}px` }}>
<span className="xml-node-name">{key}</span>
<span className="xml-separator">:</span>
<span className={value === '' ? 'xml-empty-value' : 'xml-value'}>{displayValue}</span>
</div>
);
}
// Check if this child is an array
const isArrayChild = Array.isArray(value);
if (isArrayChild) {
return (
<XmlArrayNode
key={`${key}-${idx}`}
arrayKey={key}
items={value}
depth={depth}
defaultExpanded={true}
/>
);
}
return (
<XmlNode
key={key + idx}
node={value}
nodeName={key}
isLast={idx === childEntries.length - 1}
defaultExpanded={false}
depth={depth + 1}
/>
);
})}
</div>
)}
</div>
);
};
// Helper function to parse XML string to object
function parseXMLString(xmlString) {
if (typeof xmlString !== 'string') return null;
try {
const parser = new DOMParser();
// Parse as XML only
const xmlDoc = parser.parseFromString(xmlString, 'text/xml');
// Check for parsing errors
const parserError = xmlDoc.querySelector('parsererror');
if (parserError) {
return null;
}
// Convert XML DOM to object
function xmlToObject(node) {
if (node.nodeType !== 1) return null; // Not an element node
const result = {};
// Get attributes - store them directly with underscore prefix
if (node.attributes && node.attributes.length > 0) {
for (let i = 0; i < node.attributes.length; i++) {
const attr = node.attributes[i];
result[`_${attr.name}`] = attr.value || '';
}
}
// Get child nodes
const childNodes = Array.from(node.childNodes);
const elementChildren = childNodes.filter((child) => child.nodeType === 1);
const textChildren = childNodes.filter((child) => child.nodeType === 3 && child.textContent.trim());
// If only text children and no element children, return text content
if (elementChildren.length === 0 && textChildren.length > 0) {
const textContent = textChildren.map((t) => t.textContent.trim()).join(' ').trim();
// If has attributes, store text as a special property
if (Object.keys(result).length > 0) {
result['_text'] = textContent;
return result;
}
return textContent || null;
}
// Process element children
if (elementChildren.length > 0) {
const childMap = {};
elementChildren.forEach((child) => {
const childName = child.nodeName; // Preserve original casing
const childValue = xmlToObject(child);
if (childValue !== null || elementChildren.filter((c) => c.nodeName.toLowerCase() === childName).length > 1) {
if (childMap[childName]) {
// Multiple children with same name - convert to array
if (!Array.isArray(childMap[childName])) {
childMap[childName] = [childMap[childName]];
}
childMap[childName].push(childValue);
} else {
childMap[childName] = childValue;
}
}
});
// Merge children into result
Object.assign(result, childMap);
}
return Object.keys(result).length > 0 ? result : null;
}
const rootElement = xmlDoc.documentElement;
if (!rootElement) return null;
const parsed = xmlToObject(rootElement);
return parsed ? { [rootElement.nodeName]: parsed } : null;
} catch (error) {
return null;
}
}
function isTextNode(node) {
return typeof node === 'string' || typeof node === 'number' || node === null;
}
function getChildrenEntries(node) {
// Given an XML-like JS object, return an array of [key, value] for all properties
// This includes attributes (with _ prefix) and child elements
if (typeof node !== 'object' || node === null) return [];
return Object.entries(node);
}
function getChildCount(node) {
if (Array.isArray(node)) {
return node.length;
}
const children = getChildrenEntries(node);
return children.length;
}

View File

@@ -1,4 +1,4 @@
import React, { useState, useMemo } from 'react';
import React, { useState, useEffect } from 'react';
import CodeEditor from 'components/CodeEditor/index';
import { get } from 'lodash';
import find from 'lodash/find';
@@ -11,22 +11,44 @@ import 'react-pdf/dist/esm/Page/AnnotationLayer.css';
import 'react-pdf/dist/esm/Page/TextLayer.css';
import { GlobalWorkerOptions } from 'pdfjs-dist/build/pdf';
GlobalWorkerOptions.workerSrc = 'pdfjs-dist/legacy/build/pdf.worker.min.mjs';
import XmlPreview from './XmlPreview/index';
import TextPreview from './TextPreview';
import HtmlPreview from './HtmlPreview';
import VideoPreview from './VideoPreview';
import JsonPreview from './JsonPreview';
import ReactPlayer from 'react-player';
const VideoPreview = React.memo(({ contentType, dataBuffer }) => {
const [videoUrl, setVideoUrl] = useState(null);
useEffect(() => {
const videoType = contentType.split(';')[0];
const byteArray = Buffer.from(dataBuffer, 'base64');
const blob = new Blob([byteArray], { type: videoType });
const url = URL.createObjectURL(blob);
setVideoUrl(url);
return () => URL.revokeObjectURL(url);
}, [contentType, dataBuffer]);
if (!videoUrl) return <div>Loading video...</div>;
return (
<ReactPlayer
url={videoUrl}
controls
muted={true}
width="100%"
height="100%"
onError={(e) => console.error('Error loading video:', e)}
/>
);
});
const QueryResultPreview = ({
selectedTab,
previewTab,
allowedPreviewModes,
data,
dataBuffer,
formattedData,
item,
contentType,
collection,
codeMirrorMode,
previewMode,
mode,
disableRunEventListener,
displayedTheme
}) => {
@@ -41,6 +63,10 @@ const QueryResultPreview = ({
function onDocumentLoadSuccess({ numPages }) {
setNumPages(numPages);
}
// Fail safe, so we don't render anything with an invalid tab
if (!allowedPreviewModes.find((previewMode) => previewMode?.uid == previewTab?.uid)) {
return null;
}
const onRun = () => {
if (disableRunEventListener) {
@@ -61,31 +87,19 @@ const QueryResultPreview = ({
);
};
if (selectedTab === 'editor') {
return (
<CodeEditor
collection={collection}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
theme={displayedTheme}
onRun={onRun}
onSave={onSave}
onScroll={onScroll}
value={formattedData}
mode={codeMirrorMode}
initialScroll={focusedTab.responsePaneScrollPosition || 0}
readOnly
/>
);
}
switch (previewMode) {
switch (previewTab?.mode) {
case 'preview-web': {
const baseUrl = item.requestSent?.url || '';
return <HtmlPreview data={data} baseUrl={baseUrl} />;
const webViewSrc = data.replace('<head>', `<head><base href="${item.requestSent?.url || ''}">`);
return (
<webview
src={`data:text/html; charset=utf-8,${encodeURIComponent(webViewSrc)}`}
webpreferences="disableDialogs=true, javascript=yes"
className="h-full bg-white"
/>
);
}
case 'preview-image': {
return <img src={`data:${contentType.replace(/\;(.*)/, '')};base64,${dataBuffer}`} />;
return <img src={`data:${contentType.replace(/\;(.*)/, '')};base64,${dataBuffer}`} className="mx-auto" />;
}
case 'preview-pdf': {
return (
@@ -106,29 +120,24 @@ const QueryResultPreview = ({
case 'preview-video': {
return <VideoPreview contentType={contentType} dataBuffer={dataBuffer} />;
}
case 'preview-json': {
return <JsonPreview data={data} displayedTheme={displayedTheme} />;
}
case 'preview-text': {
return <TextPreview data={data} />;
}
case 'preview-xml': {
return <XmlPreview data={data} />;
}
default:
case 'raw': {
return (
<div className="p-4 flex flex-col items-center justify-center h-full text-center">
<div className="text-lg font-semibold text-gray-700 dark:text-gray-200 mb-2">
No Preview Available
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
Sorry, no preview is available for this content type.
</div>
</div>
<CodeEditor
collection={collection}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
theme={displayedTheme}
onRun={onRun}
onSave={onSave}
onScroll={onScroll}
value={formattedData}
mode={mode}
initialScroll={focusedTab.responsePaneScrollPosition || 0}
readOnly
/>
);
}
}
};

View File

@@ -1,13 +0,0 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.active {
color: ${(props) => props.theme.colors.text.yellow};
}
.preview-response-tab-label {
color: ${(props) => props.theme.colors.text.muted};
}
`;
export default StyledWrapper;

View File

@@ -1,46 +0,0 @@
import React from 'react';
import { IconEye } from '@tabler/icons';
import ButtonDropdown from 'ui/ButtonDropdown';
import ToggleSwitch from 'components/ToggleSwitch';
import StyledWrapper from './StyledWrapper';
const QueryResultTypeSelector = ({
formatOptions,
formatValue,
onFormatChange,
onPreviewTabSelect,
selectedTab
}) => {
const header = (
<div className="flex items-center justify-between gap-3 py-[0.35rem] px-[0.6rem]">
<span className="text-[0.8125rem] preview-response-tab-label">Preview</span>
<ToggleSwitch
isOn={selectedTab === 'preview'}
handleToggle={(e) => {
e.preventDefault();
// e.stopPropagation();
onPreviewTabSelect();
}}
size="2xs"
data-testid="preview-response-tab"
title={selectedTab === 'preview' ? 'Turn off Preview Mode' : 'Turn on Preview Mode'}
/>
</div>
);
return (
<StyledWrapper>
<ButtonDropdown
label={formatValue}
options={formatOptions}
value={formatValue}
onChange={onFormatChange}
header={header}
className="h-[20px] text-[11px]"
data-testid="format-response-tab"
suffix={selectedTab === 'preview' ? <IconEye size={14} strokeWidth={2} className="active mr-[2px]" /> : null}
/>
</StyledWrapper>
);
};
export default QueryResultTypeSelector;

View File

@@ -1,8 +1,9 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
display: flex;
flex-direction: column;
display: grid;
grid-template-columns: 100%;
grid-template-rows: 1.25rem 1fr;
/* This is a hack to force Codemirror to use all available space */
> div {

View File

@@ -1,34 +1,15 @@
import { debounce } from 'lodash';
import { useTheme } from 'providers/Theme/index';
import React, { useMemo, useState } from 'react';
import { formatResponse, getContentType } from 'utils/common';
import { getEncoding } from 'utils/common/index';
import { getDefaultResponseFormat } from 'utils/response';
import LargeResponseWarning from '../LargeResponseWarning';
import QueryResultFilter from './QueryResultFilter';
import React from 'react';
import classnames from 'classnames';
import { getContentType, formatResponse } from 'utils/common';
import { getCodeMirrorModeBasedOnContentType } from 'utils/common/codemirror';
import QueryResultPreview from './QueryResultPreview';
import StyledWrapper from './StyledWrapper';
import { detectContentTypeFromBuffer } from 'utils/response/index';
const PREVIEW_FORMAT_OPTIONS = [
{
// name: 'Structured',
options: [
{ label: 'JSON', value: 'json', codeMirrorMode: 'application/ld+json' },
{ label: 'HTML', value: 'html', codeMirrorMode: 'xml' },
{ label: 'XML', value: 'xml', codeMirrorMode: 'xml' },
{ label: 'JavaScript', value: 'javascript', codeMirrorMode: 'javascript' }
]
},
{
// name: 'Raw',
options: [
{ label: 'Raw', value: 'raw', codeMirrorMode: 'text/plain' },
{ label: 'Hex', value: 'hex', codeMirrorMode: 'text/plain' },
{ label: 'Base64', value: 'base64', codeMirrorMode: 'text/plain' }
]
}
];
import { useState, useMemo, useEffect } from 'react';
import { useTheme } from 'providers/Theme/index';
import { getEncoding, uuid } from 'utils/common/index';
import LargeResponseWarning from '../LargeResponseWarning';
const formatErrorMessage = (error) => {
if (!error) return 'Something went wrong';
@@ -43,87 +24,9 @@ const formatErrorMessage = (error) => {
return error;
};
// Custom hook to determine the initial format and tab based on the data buffer and headers
export const useInitialResponseFormat = (dataBuffer, headers) => {
return useMemo(() => {
let buffer = null;
try {
buffer = dataBuffer ? Buffer.from(dataBuffer, 'base64') : null;
} catch (error) {
console.error('Error converting dataBuffer to Buffer:', error);
buffer = null;
}
const detectedContentType = detectContentTypeFromBuffer(buffer);
const contentType = getContentType(headers);
// Wait until both content types are available
if (detectedContentType === null || contentType === undefined) {
return { initialFormat: null, initialTab: null };
}
const initial = getDefaultResponseFormat(contentType);
return { initialFormat: initial.format, initialTab: initial.tab };
}, [dataBuffer, headers]);
};
// Custom hook to determine preview format options based on content type
export const useResponsePreviewFormatOptions = (dataBuffer, headers) => {
return useMemo(() => {
let buffer = null;
try {
buffer = dataBuffer ? Buffer.from(dataBuffer, 'base64') : null;
} catch (error) {
console.error('Error converting dataBuffer to Buffer:', error);
buffer = null;
}
const detectedContentType = detectContentTypeFromBuffer(buffer);
const contentType = getContentType(headers);
const byteFormatTypes = ['image', 'video', 'audio', 'pdf', 'zip'];
const isByteFormatType = (contentType) => {
return byteFormatTypes.some((type) => contentType.includes(type));
};
const getContentTypeToCheck = () => {
if (detectedContentType) {
return detectedContentType;
}
return contentType;
};
const contentTypeToCheck = getContentTypeToCheck();
if (contentTypeToCheck && isByteFormatType(contentTypeToCheck)) {
return PREVIEW_FORMAT_OPTIONS.slice(1, 2); // Remove structured format options
}
return PREVIEW_FORMAT_OPTIONS;
}, [dataBuffer, headers]);
};
const QueryResult = ({
item,
collection,
data,
dataBuffer,
disableRunEventListener,
headers,
error,
selectedFormat, // one of the options in PREVIEW_FORMAT_OPTIONS
selectedTab // 'editor' or 'preview'
}) => {
let buffer = null;
try {
buffer = Buffer.from(dataBuffer, 'base64'); // dataBuffer is already a base64 string, convert it to actual Buffer
} catch (error) {
console.error('Error converting dataBuffer to Buffer:', error);
buffer = null;
}
const detectedContentType = detectContentTypeFromBuffer(buffer);
const QueryResult = ({ item, collection, data, dataBuffer, disableRunEventListener, headers, error }) => {
const contentType = getContentType(headers);
const mode = getCodeMirrorModeBasedOnContentType(contentType, data);
const [filter, setFilter] = useState(null);
const [showLargeResponse, setShowLargeResponse] = useState(false);
const responseEncoding = getEncoding(headers);
@@ -153,44 +56,65 @@ const QueryResult = ({
if (isLargeResponse && !showLargeResponse) {
return '';
}
return formatResponse(data, dataBuffer, selectedFormat, filter);
return formatResponse(data, dataBuffer, mode, filter);
},
[data, dataBuffer, responseEncoding, selectedFormat, filter, isLargeResponse, showLargeResponse]
[data, dataBuffer, responseEncoding, mode, filter, isLargeResponse, showLargeResponse]
);
const debouncedResultFilterOnChange = debounce((e) => {
setFilter(e.target.value);
}, 250);
const previewMode = useMemo(() => {
// Derive preview mode based on selected format
if (selectedFormat === 'html') return 'preview-web';
if (selectedFormat === 'json') return 'preview-json';
if (selectedFormat === 'xml') return 'preview-xml';
if (selectedFormat === 'raw') return 'preview-text';
if (selectedFormat === 'javascript') return 'preview-web';
const allowedPreviewModes = useMemo(() => {
// Always show raw
const allowedPreviewModes = [{ mode: 'raw', name: 'Raw', uid: uuid() }];
// For base64/hex, check content type to determine binary preview type
if (selectedFormat === 'base64' || selectedFormat === 'hex') {
if (detectedContentType) {
if (detectedContentType.includes('image')) return 'preview-image';
if (detectedContentType.includes('pdf')) return 'preview-pdf';
if (detectedContentType.includes('audio')) return 'preview-audio';
if (detectedContentType.includes('video')) return 'preview-video';
}
// for all other content types, return preview-text
return 'preview-text';
if (!mode || !contentType) return allowedPreviewModes;
if (mode?.includes('html') && typeof data === 'string') {
allowedPreviewModes.unshift({ mode: 'preview-web', name: 'Web', uid: uuid() });
} else if (mode.includes('image')) {
allowedPreviewModes.unshift({ mode: 'preview-image', name: 'Image', uid: uuid() });
} else if (contentType.includes('pdf')) {
allowedPreviewModes.unshift({ mode: 'preview-pdf', name: 'PDF', uid: uuid() });
} else if (contentType.includes('audio')) {
allowedPreviewModes.unshift({ mode: 'preview-audio', name: 'Audio', uid: uuid() });
} else if (contentType.includes('video')) {
allowedPreviewModes.unshift({ mode: 'preview-video', name: 'Video', uid: uuid() });
}
return 'preview-text';
}, [selectedFormat, detectedContentType]);
const codeMirrorMode = useMemo(() => {
return PREVIEW_FORMAT_OPTIONS
.flatMap((option) => option.options)
.find((option) => option.value === selectedFormat)?.codeMirrorMode || 'text/plain';
}, [selectedFormat]);
return allowedPreviewModes;
}, [mode, data, formattedData]);
const queryFilterEnabled = useMemo(() => codeMirrorMode.includes('json') && selectedFormat === 'json' && selectedTab === 'editor', [codeMirrorMode, selectedFormat, selectedTab]);
const [previewTab, setPreviewTab] = useState(allowedPreviewModes[0]);
// Ensure the active Tab is always allowed
useEffect(() => {
if (!allowedPreviewModes.find((previewMode) => previewMode?.uid == previewTab?.uid)) {
setPreviewTab(allowedPreviewModes[0]);
}
}, [previewTab, allowedPreviewModes]);
const tabs = useMemo(() => {
if (allowedPreviewModes.length === 1) {
return null;
}
return allowedPreviewModes.map((previewMode) => (
<div
className={classnames(
'select-none capitalize',
previewMode?.uid === previewTab?.uid ? 'active' : 'cursor-pointer'
)}
role="tab"
onClick={() => setPreviewTab(previewMode)}
key={previewMode?.uid}
>
{previewMode?.name}
</div>
));
}, [allowedPreviewModes, previewTab]);
const queryFilterEnabled = useMemo(() => mode.includes('json'), [mode]);
const hasScriptError = item.preRequestScriptErrorMessage || item.postResponseScriptErrorMessage;
return (
@@ -198,6 +122,9 @@ const QueryResult = ({
className="w-full h-full relative flex"
queryFilterEnabled={queryFilterEnabled}
>
<div className="flex justify-end gap-2 text-xs" role="tablist">
{tabs}
</div>
{error ? (
<div>
{hasScriptError ? null : (
@@ -220,23 +147,21 @@ const QueryResult = ({
) : (
<div className="h-full flex flex-col">
<div className="flex-1 relative">
<div className="absolute top-0 left-0 h-full w-full" data-testid="response-preview-container">
<QueryResultPreview
selectedTab={selectedTab}
data={data}
dataBuffer={dataBuffer}
formattedData={formattedData}
item={item}
contentType={contentType}
previewMode={previewMode}
codeMirrorMode={codeMirrorMode}
collection={collection}
disableRunEventListener={disableRunEventListener}
displayedTheme={displayedTheme}
/>
</div>
<QueryResultPreview
previewTab={previewTab}
data={data}
dataBuffer={dataBuffer}
formattedData={formattedData}
item={item}
contentType={contentType}
mode={mode}
collection={collection}
allowedPreviewModes={allowedPreviewModes}
disableRunEventListener={disableRunEventListener}
displayedTheme={displayedTheme}
/>
{queryFilterEnabled && (
<QueryResultFilter filter={filter} onChange={debouncedResultFilterOnChange} mode={codeMirrorMode} />
<QueryResultFilter filter={filter} onChange={debouncedResultFilterOnChange} mode={mode} />
)}
</div>
</div>

View File

@@ -3,7 +3,7 @@ import { IconDots } from '@tabler/icons';
import Dropdown from 'components/Dropdown';
import StyledWrapper from './StyledWrapper';
import ResponseClear from 'src/components/ResponsePane/ResponseClear';
import ResponseDownload from 'src/components/ResponsePane/ResponseDownload';
import ResponseSave from 'src/components/ResponsePane/ResponseSave';
const ResponseActions = ({ collection, item }) => {
const menuDropdownTippyRef = useRef();
@@ -26,7 +26,7 @@ const ResponseActions = ({ collection, item }) => {
<StyledWrapper className="ml-2 flex items-center">
<Dropdown onCreate={onMenuDropdownCreate} icon={<MenuIcon />} placement="bottom-end">
<ResponseClear item={item} collection={collection} asDropdownItem onClose={handleClose} />
<ResponseDownload item={item} asDropdownItem onClose={handleClose} />
<ResponseSave item={item} asDropdownItem onClose={handleClose} />
</Dropdown>
</StyledWrapper>
);

View File

@@ -3,13 +3,6 @@ import styled from 'styled-components';
const StyledWrapper = styled.div`
display: flex;
align-items: center;
color: ${(props) => props.theme.codemirror.variable.info.iconColor};
border-radius: 4px;
&:hover {
background-color: ${(props) => props.theme.workspace.button.bg};
color: ${(props) => props.theme.text};
}
`;
export default StyledWrapper;

View File

@@ -24,51 +24,27 @@ const getTitleText = ({ isResponseTooLarge, isStreamingResponse }) => {
return 'Save current response as example';
};
const ResponseBookmark = ({ item, collection, responseSize, children }) => {
const ResponseBookmark = ({ item, collection, responseSize }) => {
const dispatch = useDispatch();
const [showSaveResponseExampleModal, setShowSaveResponseExampleModal] = useState(false);
const response = item.response || {};
const isResponseTooLarge = responseSize >= 5 * 1024 * 1024; // 5 MB
const isStreamingResponse = response.stream;
const isDisabled = isResponseTooLarge || isStreamingResponse;
// Only show for HTTP requests
if (item.type !== 'http-request') {
return null;
}
const handleKeyDown = (e) => {
if (isDisabled) {
e.preventDefault();
e.stopPropagation();
return;
}
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleSaveClick(e);
}
};
const handleSaveClick = (e) => {
const handleSaveClick = () => {
if (!response || response.error) {
toast.error('No valid response to save as example');
e.preventDefault();
e.stopPropagation();
return;
}
if (isResponseTooLarge) {
toast.error('Response size exceeds 5MB limit. Cannot save as example.');
e.preventDefault();
e.stopPropagation();
return;
}
if (isDisabled) {
e.preventDefault();
e.stopPropagation();
return;
}
@@ -140,28 +116,21 @@ const ResponseBookmark = ({ item, collection, responseSize, children }) => {
return (
<>
<div
role={!!children ? 'button' : undefined}
tabIndex={isDisabled ? -1 : 0}
aria-disabled={isDisabled}
onKeyDown={handleKeyDown}
onClick={handleSaveClick}
title={
!children ? disabledMessage : (isDisabled ? disabledMessage : null)
}
className={classnames({
'opacity-50 cursor-not-allowed': isDisabled
})}
data-testid="response-bookmark-btn"
>
{children ?? (
<StyledWrapper className="flex items-center">
<button className="p-1">
<IconBookmark size={16} strokeWidth={2} />
</button>
</StyledWrapper>
)}
</div>
<StyledWrapper className="ml-2 flex items-center">
<button
onClick={handleSaveClick}
disabled={isResponseTooLarge || isStreamingResponse}
title={
disabledMessage
}
className={classnames('p-1', {
'opacity-50 cursor-not-allowed': isResponseTooLarge || isStreamingResponse
})}
data-testid="response-bookmark-btn"
>
<IconBookmark size={16} strokeWidth={1.5} />
</button>
</StyledWrapper>
<CreateExampleModal
isOpen={showSaveResponseExampleModal}

View File

@@ -2,13 +2,7 @@ import styled from 'styled-components';
const StyledWrapper = styled.div`
font-size: ${(props) => props.theme.font.size.base};
color: ${(props) => props.theme.codemirror.variable.info.iconColor};
border-radius: 4px;
&:hover {
background-color: ${(props) => props.theme.workspace.button.bg};
color: ${(props) => props.theme.text};
}
color: ${(props) => props.theme.requestTabPanel.responseStatus};
`;
export default StyledWrapper;

View File

@@ -4,11 +4,11 @@ import { useDispatch } from 'react-redux';
import StyledWrapper from './StyledWrapper';
import { responseCleared } from 'providers/ReduxStore/slices/collections/index';
// Hook to get clear response function
export const useResponseClear = (item, collection) => {
const ResponseClear = ({ collection, item, asDropdownItem, onClose }) => {
const dispatch = useDispatch();
const clearResponse = () => {
if (onClose) onClose();
dispatch(
responseCleared({
itemUid: item.uid,
@@ -18,29 +18,21 @@ export const useResponseClear = (item, collection) => {
);
};
return { clearResponse };
};
const ResponseClear = ({ collection, item, children }) => {
const { clearResponse } = useResponseClear(item, collection);
const handleKeyDown = (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
clearResponse();
}
};
if (asDropdownItem) {
return (
<div className="dropdown-item" onClick={clearResponse}>
<IconEraser size={16} strokeWidth={1.5} className="icon mr-2" />
Clear
</div>
);
}
return (
<div role={!!children ? 'button' : undefined} tabIndex={0} onClick={clearResponse} title={!children ? 'Clear response' : null} onKeyDown={handleKeyDown} data-testid="response-clear-button">
{children ? children : (
<StyledWrapper className="flex items-center">
<button className="p-1">
<IconEraser size={16} strokeWidth={2} />
</button>
</StyledWrapper>
)}
</div>
<StyledWrapper className="ml-2 flex items-center">
<button onClick={clearResponse} title="Clear response">
<IconEraser size={16} strokeWidth={1.5} />
</button>
</StyledWrapper>
);
};
export default ResponseClear;

View File

@@ -2,13 +2,7 @@ import styled from 'styled-components';
const StyledWrapper = styled.div`
font-size: 0.8125rem;
color: ${(props) => props.theme.codemirror.variable.info.iconColor};
border-radius: 4px;
&:hover {
background-color: ${(props) => props.theme.workspace.button.bg};
color: ${(props) => props.theme.text};
}
color: ${(props) => props.theme.requestTabPanel.responseStatus};
`;
export default StyledWrapper;

View File

@@ -3,8 +3,7 @@ import StyledWrapper from './StyledWrapper';
import toast from 'react-hot-toast';
import { IconCopy, IconCheck } from '@tabler/icons';
// Hook to get copy response function
export const useResponseCopy = (item) => {
const ResponseCopy = ({ item }) => {
const response = item.response || {};
const [copied, setCopied] = useState(false);
@@ -31,39 +30,16 @@ export const useResponseCopy = (item) => {
}
};
return { copyResponse, copied, hasData: !!response.data };
};
const ResponseCopy = ({ item, children }) => {
const { copyResponse, copied, hasData } = useResponseCopy(item);
const handleKeyDown = (e) => {
if ((e.key === 'Enter' || e.key === ' ') && hasData) {
e.preventDefault();
copyResponse();
}
};
const handleClick = () => {
if (hasData) {
copyResponse();
}
};
return (
<div role={!!children ? 'button' : undefined} tabIndex={0} onClick={handleClick} title={!children ? 'Copy response to clipboard' : null} onKeyDown={handleKeyDown} data-testid="response-copy-btn">
{children ? children : (
<StyledWrapper className="flex items-center">
<button className="p-1" disabled={!hasData}>
{copied ? (
<IconCheck size={16} strokeWidth={2} />
) : (
<IconCopy size={16} strokeWidth={2} />
)}
</button>
</StyledWrapper>
)}
</div>
<StyledWrapper className="ml-2 flex items-center">
<button onClick={copyResponse} disabled={!response.data} title="Copy response to clipboard">
{copied ? (
<IconCheck size={16} strokeWidth={1.5} />
) : (
<IconCopy size={16} strokeWidth={1.5} />
)}
</button>
</StyledWrapper>
);
};

View File

@@ -1,14 +0,0 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
font-size: ${(props) => props.theme.font.size.base};
color: ${(props) => props.theme.codemirror.variable.info.iconColor};
border-radius: 4px;
&:hover {
background-color: ${(props) => props.theme.workspace.button.bg};
color: ${(props) => props.theme.text};
}
`;
export default StyledWrapper;

View File

@@ -1,63 +0,0 @@
import React from 'react';
import StyledWrapper from './StyledWrapper';
import toast from 'react-hot-toast';
import get from 'lodash/get';
import { IconDownload } from '@tabler/icons';
import classnames from 'classnames';
const ResponseDownload = ({ item, children }) => {
const { ipcRenderer } = window;
const response = item.response || {};
const isDisabled = !response.dataBuffer;
const saveResponseToFile = () => {
if (isDisabled) {
return;
}
return new Promise((resolve, reject) => {
ipcRenderer
.invoke('renderer:save-response-to-file', response, item?.requestSent?.url, item.pathname)
.then(resolve)
.catch((err) => {
toast.error(get(err, 'error.message') || 'Something went wrong!');
reject(err);
});
});
};
const handleKeyDown = (e) => {
if (isDisabled) {
e.preventDefault();
e.stopPropagation();
return;
}
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
saveResponseToFile();
}
};
return (
<div
role={!!children ? 'button' : undefined}
tabIndex={isDisabled ? -1 : 0}
aria-disabled={isDisabled}
onClick={saveResponseToFile}
onKeyDown={handleKeyDown}
title={!children ? 'Save response to file' : null}
className={classnames({
'opacity-50 cursor-not-allowed': isDisabled
})}
>
{children ? children : (
<StyledWrapper className="flex items-center">
<button className="p-1">
<IconDownload size={16} strokeWidth={2} />
</button>
</StyledWrapper>
)}
</div>
);
};
export default ResponseDownload;

View File

@@ -8,13 +8,7 @@ const Wrapper = styled.div`
background: transparent;
border: none;
cursor: pointer;
color: ${(props) => props.theme.codemirror.variable.info.iconColor};
border-radius: 4px;
&:hover {
background-color: ${(props) => props.theme.workspace.button.bg};
color: ${(props) => props.theme.text};
}
color: ${(props) => props.theme.colors.text.muted};
}
`;

View File

@@ -3,14 +3,14 @@ import { useDispatch, useSelector } from 'react-redux';
import { savePreferences } from 'providers/ReduxStore/slices/app';
import StyledWrapper from './StyledWrapper';
export const IconDockToBottom = () => {
const IconDockToBottom = () => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
strokeWidth="2"
strokeWidth="1.5"
stroke="currentColor"
fill="none"
>
@@ -25,14 +25,14 @@ export const IconDockToBottom = () => {
);
};
export const IconDockToRight = () => {
const IconDockToRight = () => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
strokeWidth="2"
strokeWidth="1.5"
stroke="currentColor"
fill="none"
>
@@ -48,8 +48,7 @@ export const IconDockToRight = () => {
);
};
// Hook to get orientation and toggle function
export const useResponseLayoutToggle = () => {
const ResponseLayoutToggle = () => {
const dispatch = useDispatch();
const preferences = useSelector((state) => state.app.preferences);
const orientation = preferences?.layout?.responsePaneOrientation || 'horizontal';
@@ -66,42 +65,19 @@ export const useResponseLayoutToggle = () => {
dispatch(savePreferences(updatedPreferences));
};
return { orientation, toggleOrientation };
};
const ResponseLayoutToggle = ({ children }) => {
const { orientation, toggleOrientation } = useResponseLayoutToggle();
const handleKeyDown = (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggleOrientation();
}
};
const title = !children ? (orientation === 'horizontal' ? 'Switch to vertical layout' : 'Switch to horizontal layout') : null;
return (
<div
role="button"
tabIndex={0}
onClick={toggleOrientation}
title={title}
onKeyDown={handleKeyDown}
data-testid="response-layout-toggle-button"
>
{children ? children : (
<StyledWrapper className="flex items-center w-full">
<button className="p-1">
{orientation === 'horizontal' ? (
<IconDockToBottom />
) : (
<IconDockToRight />
)}
</button>
</StyledWrapper>
)}
</div>
<StyledWrapper className="ml-2 flex items-center">
<button
onClick={toggleOrientation}
title={orientation === 'horizontal' ? 'Switch to vertical layout' : 'Switch to horizontal layout'}
>
{orientation === 'horizontal' ? (
<IconDockToBottom />
) : (
<IconDockToRight />
)}
</button>
</StyledWrapper>
);
};

View File

@@ -84,7 +84,7 @@ describe('ResponseLayoutToggle', () => {
describe('Initial Render', () => {
it('should render with horizontal orientation by default', () => {
renderWithProviders(<ResponseLayoutToggle />);
const button = screen.getByTestId('response-layout-toggle-button');
const button = screen.getByRole('button');
expect(button).toBeInTheDocument();
expect(button).toHaveAttribute('title', 'Switch to vertical layout');
});
@@ -100,7 +100,7 @@ describe('ResponseLayoutToggle', () => {
}
};
renderWithProviders(<ResponseLayoutToggle />, customState);
const button = screen.getByTestId('response-layout-toggle-button');
const button = screen.getByRole('button');
expect(button).toBeInTheDocument();
expect(button).toHaveAttribute('title', 'Switch to horizontal layout');
});
@@ -109,7 +109,7 @@ describe('ResponseLayoutToggle', () => {
describe('Interaction', () => {
it('should switch to vertical layout when clicked in horizontal mode', () => {
const { store } = renderWithProviders(<ResponseLayoutToggle />);
const button = screen.getByTestId('response-layout-toggle-button');
const button = screen.getByRole('button');
// Initial state check
expect(button).toHaveAttribute('title', 'Switch to vertical layout');
@@ -145,7 +145,7 @@ describe('ResponseLayoutToggle', () => {
}
};
const { store } = renderWithProviders(<ResponseLayoutToggle />, customState);
const button = screen.getByTestId('response-layout-toggle-button');
const button = screen.getByRole('button');
// Initial state check
expect(button).toHaveAttribute('title', 'Switch to horizontal layout');

View File

@@ -1,7 +0,0 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
`;
export default StyledWrapper;

View File

@@ -1,188 +0,0 @@
import React, { useState, useEffect, useRef, forwardRef, useCallback, useMemo } from 'react';
import { debounce } from 'lodash';
import styled from 'styled-components';
import { IconDots, IconDownload, IconEraser, IconBookmark, IconCopy } from '@tabler/icons';
import Dropdown from 'components/Dropdown';
import ResponseDownload from '../ResponseDownload';
import ResponseBookmark from '../ResponseBookmark';
import ResponseClear from '../ResponseClear';
import ResponseLayoutToggle, { useResponseLayoutToggle, IconDockToBottom, IconDockToRight } from '../ResponseLayoutToggle';
import ResponseCopy from '../ResponseCopy/index';
import StyledWrapper from '../StyledWrapper';
const PADDING = 48;
const StyledMenuIcon = styled.button`
display: flex;
align-items: center;
justify-content: center;
height: 1.25rem;
width: 1.5rem;
border: 1px solid ${(props) => props.theme.workspace.border};
color: ${(props) => props.theme.codemirror.variable.info.iconColor};
border-radius: 4px;
&:hover {
background-color: ${(props) => props.theme.workspace.button.bg};
color: ${(props) => props.theme.text};
}
`;
const MenuIcon = forwardRef((props, ref) => (
<StyledMenuIcon
ref={ref}
title="More actions"
{...props}
>
<IconDots size={16} strokeWidth={1.5} />
</StyledMenuIcon>
));
MenuIcon.displayName = 'MenuIcon';
const ResponsePaneActions = ({ item, collection, responseSize }) => {
const { orientation } = useResponseLayoutToggle();
const [showMenu, setShowMenu] = useState(false);
const actionsRef = useRef(null);
const dropdownTippyRef = useRef();
const individualButtonsWidthRef = useRef(null);
const showMenuRef = useRef(showMenu);
const checkSpace = useCallback(() => {
const actionsContainer = actionsRef.current?.parentElement;
const rightSideContainer = actionsContainer?.closest('.right-side-container');
if (!actionsContainer || !rightSideContainer) return;
const currentActionsWidth = actionsContainer.offsetWidth || 0;
// Store individual buttons width when they're visible
if (!showMenuRef.current && currentActionsWidth > 0) {
individualButtonsWidthRef.current = currentActionsWidth;
}
// Calculate siblings total width
let siblingsTotalWidth = 0;
let sibling = actionsContainer.previousElementSibling;
while (sibling) {
siblingsTotalWidth += sibling.offsetWidth || 0;
sibling = sibling.previousElementSibling;
}
const actionsWidth = individualButtonsWidthRef.current || currentActionsWidth;
const requiredWidth = actionsWidth + siblingsTotalWidth + PADDING;
const shouldShowMenu = rightSideContainer.offsetWidth < requiredWidth;
if (showMenuRef.current !== shouldShowMenu) {
showMenuRef.current = shouldShowMenu;
setShowMenu(shouldShowMenu);
}
}, []);
const debouncedCheckSpace = useMemo(
() => debounce(checkSpace, 50),
[checkSpace]
);
useEffect(() => {
showMenuRef.current = showMenu;
}, [showMenu]);
useEffect(() => {
checkSpace();
const rightSideContainer = actionsRef.current?.closest('.right-side-container');
if (!rightSideContainer) return;
const resizeObserver = new ResizeObserver(debouncedCheckSpace);
resizeObserver.observe(rightSideContainer);
return () => {
resizeObserver.disconnect();
debouncedCheckSpace.cancel();
};
}, [item, debouncedCheckSpace]);
const onDropdownCreate = (ref) => {
dropdownTippyRef.current = ref;
};
const closeDropdown = () => {
if (dropdownTippyRef.current) {
dropdownTippyRef.current.hide();
}
};
if (item.type !== 'http-request') {
return null;
}
return (
<StyledWrapper ref={actionsRef} className="flex items-center gap-2">
{showMenu ? (
<Dropdown onCreate={onDropdownCreate} icon={<MenuIcon data-testid="response-actions-menu" />} placement="bottom-end">
{/* Response Copy */}
<ResponseCopy item={item}>
<div className="dropdown-item" onClick={closeDropdown}>
<span className="dropdown-icon">
<IconCopy size={16} strokeWidth={1.5} />
</span>
<span>Copy response</span>
</div>
</ResponseCopy>
{/* Response Save as Example */}
<ResponseBookmark item={item} collection={collection} responseSize={responseSize}>
<div className="dropdown-item" onClick={closeDropdown}>
<span className="dropdown-icon">
<IconBookmark size={16} strokeWidth={1.5} />
</span>
<span>Save response</span>
</div>
</ResponseBookmark>
{/* Response Download */}
<ResponseDownload item={item}>
<div className="dropdown-item" onClick={closeDropdown}>
<span className="dropdown-icon">
<IconDownload size={16} strokeWidth={1.5} />
</span>
Download response
</div>
</ResponseDownload>
{/* Response Clear */}
<ResponseClear item={item} collection={collection}>
<div className="dropdown-item" onClick={closeDropdown}>
<span className="dropdown-icon">
<IconEraser size={16} strokeWidth={1.5} />
</span>
Clear response
</div>
</ResponseClear>
{/* Response Layout Toggle */}
<ResponseLayoutToggle>
<div className="dropdown-item" onClick={closeDropdown}>
<span className="dropdown-icon">
{orientation === 'horizontal' ? <IconDockToBottom /> : <IconDockToRight />}
</span>
<span>Change layout</span>
</div>
</ResponseLayoutToggle>
</Dropdown>
) : (
<div className="flex items-center gap-[2px]">
<ResponseCopy item={item} />
<ResponseBookmark item={item} collection={collection} responseSize={responseSize} />
<ResponseDownload item={item} />
<ResponseClear item={item} collection={collection} />
<ResponseLayoutToggle />
</div>
)}
</StyledWrapper>
);
};
export default ResponsePaneActions;

View File

@@ -0,0 +1,8 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
font-size: ${(props) => props.theme.font.size.base};
color: ${(props) => props.theme.requestTabPanel.responseStatus};
`;
export default StyledWrapper;

View File

@@ -0,0 +1,47 @@
import React from 'react';
import StyledWrapper from './StyledWrapper';
import toast from 'react-hot-toast';
import get from 'lodash/get';
import { IconDownload } from '@tabler/icons';
const ResponseSave = ({ item, asDropdownItem, onClose }) => {
const { ipcRenderer } = window;
const response = item.response || {};
const saveResponseToFile = () => {
if (!response.dataBuffer) return;
if (onClose) onClose();
return new Promise((resolve, reject) => {
ipcRenderer
.invoke('renderer:save-response-to-file', response, item?.requestSent?.url, item.pathname)
.then(resolve)
.catch((err) => {
toast.error(get(err, 'error.message') || 'Something went wrong!');
reject(err);
});
});
};
if (asDropdownItem) {
return (
<div
className="dropdown-item"
onClick={saveResponseToFile}
disabled={!response.dataBuffer}
style={!response.dataBuffer ? { opacity: 0.5, cursor: 'not-allowed' } : {}}
>
<IconDownload size={16} strokeWidth={1.5} className="icon mr-2" />
Download
</div>
);
}
return (
<StyledWrapper className="ml-2 flex items-center">
<button onClick={saveResponseToFile} disabled={!response.dataBuffer} title="Save response to file">
<IconDownload size={16} strokeWidth={1.5} />
</button>
</StyledWrapper>
);
};
export default ResponseSave;

View File

@@ -19,7 +19,7 @@ const ResponseSize = ({ size }) => {
}
return (
<StyledWrapper title={(size?.toLocaleString() || '0') + 'B'} className="ml-2">
<StyledWrapper title={(size?.toLocaleString() || '0') + 'B'} className="ml-4">
{sizeToDisplay}
</StyledWrapper>
);

View File

@@ -2,7 +2,7 @@ import styled from 'styled-components';
const Wrapper = styled.div`
font-size: ${(props) => props.theme.font.size.sm};
font-weight: 600;
font-weight: 500;
color: ${(props) => props.theme.requestTabPanel.responseStatus};
text-align: center;
`;

View File

@@ -21,7 +21,7 @@ const ResponseStopWatch = ({ startMillis }) => {
let seconds = milliseconds / 1000;
let secondsFormatted = `${seconds.toFixed(1)}s`;
let width = secondsFormatted.length * 0.4; // Calculate width manually to stop parent layout from "flickering" by changing width too fast
return <StyledWrapper className="ml-2" style={{ width: `${width}rem` }}>{secondsFormatted}</StyledWrapper>;
return <StyledWrapper className="ml-4" style={{ width: `${width}rem` }}>{secondsFormatted}</StyledWrapper>;
};
export default React.memo(ResponseStopWatch);

View File

@@ -2,7 +2,7 @@ import styled from 'styled-components';
const Wrapper = styled.div`
font-size: ${(props) => props.theme.font.size.sm};
font-weight: 600;
font-weight: 500;
color: ${(props) => props.theme.requestTabPanel.responseStatus};
`;

View File

@@ -17,6 +17,6 @@ const ResponseTime = ({ duration }) => {
return null;
}
return <StyledWrapper className="ml-2">{durationToDisplay}</StyledWrapper>;
return <StyledWrapper className="ml-4">{durationToDisplay}</StyledWrapper>;
};
export default ResponseTime;

View File

@@ -2,8 +2,7 @@ import styled from 'styled-components';
const Wrapper = styled.div`
font-size: ${(props) => props.theme.font.size.sm};
font-weight: 600;
white-space: nowrap;
font-weight: 500;
&.text-ok {
color: ${(props) => props.theme.requestTabPanel.responseOk};

View File

@@ -6,7 +6,7 @@ import StyledWrapper from './StyledWrapper';
// Todo: text-error class is not getting pulled in for 500 errors
const StatusCode = ({ status, statusText, isStreaming }) => {
const getTabClassname = (status) => {
return classnames({
return classnames('ml-2', {
'text-ok': status >= 100 && status < 200,
'text-ok': status >= 200 && status < 300,
'text-error': status >= 300 && status < 400,

View File

@@ -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;
}
@@ -34,12 +33,6 @@ const StyledWrapper = styled.div`
.all-tests-passed {
color: ${(props) => props.theme.colors.text.green} !important;
}
.separator {
height: 16px;
border-left: 1px solid ${(props) => props.theme.preferences.sidebar.border};
margin: 0 8px;
}
`;
export default StyledWrapper;

View File

@@ -1,4 +1,4 @@
import QueryResponse from 'components/ResponsePane/QueryResponse/index';
import QueryResult from 'components/ResponsePane/QueryResult/index';
import { useState } from 'react';
const BodyBlock = ({ collection, data, dataBuffer, headers, error, item }) => {
@@ -14,7 +14,7 @@ const BodyBlock = ({ collection, data, dataBuffer, headers, error, item }) => {
<div className="mt-2">
{data || dataBuffer ? (
<div className="h-96 overflow-auto">
<QueryResponse
<QueryResult
item={item}
collection={collection}
data={data}

View File

@@ -25,7 +25,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

@@ -16,11 +16,12 @@ import TestResultsLabel from './TestResultsLabel';
import ScriptError from './ScriptError';
import ScriptErrorIcon from './ScriptErrorIcon';
import StyledWrapper from './StyledWrapper';
import ResponsePaneActions from './ResponsePaneActions';
import QueryResultTypeSelector from './QueryResult/QueryResultTypeSelector/index';
import { useInitialResponseFormat, useResponsePreviewFormatOptions } from './QueryResult/index';
import ResponseActions from 'src/components/ResponsePane/ResponseActions';
import ResponseBookmark from 'src/components/ResponsePane/ResponseBookmark';
import ResponseCopy from 'src/components/ResponsePane/ResponseCopy';
import SkippedRequest from './SkippedRequest';
import ClearTimeline from './ClearTimeline/index';
import ResponseLayoutToggle from './ResponseLayoutToggle';
import HeightBoundContainer from 'ui/HeightBoundContainer';
import ResponseStopWatch from 'components/ResponsePane/ResponseStopWatch';
import WSMessagesList from './WsResponsePane/WSMessagesList';
@@ -31,19 +32,6 @@ const ResponsePane = ({ item, collection }) => {
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const isLoading = ['queued', 'sending'].includes(item.requestState);
const [showScriptErrorCard, setShowScriptErrorCard] = useState(false);
const [selectedFormat, setSelectedFormat] = useState('raw');
const [selectedTab, setSelectedTab] = useState('editor');
// Initialize format and tab only once when data loads
const { initialFormat, initialTab } = useInitialResponseFormat(item.response?.dataBuffer, item.response?.headers);
const previewFormatOptions = useResponsePreviewFormatOptions(item.response?.dataBuffer, item.response?.headers);
useEffect(() => {
if (initialFormat !== null && initialTab !== null) {
setSelectedFormat(initialFormat);
setSelectedTab(initialTab);
}
}, [initialFormat, initialTab]);
const requestTimeline = ([...(collection.timeline || [])]).filter((obj) => {
if (obj.itemUid === item.uid) return true;
@@ -98,8 +86,6 @@ const ResponsePane = ({ item, collection }) => {
headers={response.headers}
error={response.error}
key={item.filename}
selectedFormat={selectedFormat}
selectedTab={selectedTab}
/>
);
}
@@ -171,7 +157,7 @@ const ResponsePane = ({ item, collection }) => {
return (
<StyledWrapper className="flex flex-col h-full relative">
<div className="flex items-center px-4 tabs" role="tablist">
<div className="flex flex-wrap items-center px-4 tabs" role="tablist">
<div className={getTabClassname('response')} role="tab" onClick={() => selectTab('response')}>
Response
</div>
@@ -191,50 +177,33 @@ const ResponsePane = ({ item, collection }) => {
/>
</div>
{!isLoading ? (
<div className="flex flex-grow justify-end items-center right-side-container">
<div className="flex flex-grow justify-end items-center">
{hasScriptError && !showScriptErrorCard && (
<ScriptErrorIcon
itemUid={item.uid}
onClick={() => setShowScriptErrorCard(true)}
/>
)}
{focusedTab?.responsePaneTab === 'response' ? (
<ResponseLayoutToggle />
{focusedTab?.responsePaneTab === 'timeline' ? (
<ClearTimeline item={item} collection={collection} />
) : (item?.response && !item?.response?.error) ? (
<>
<QueryResultTypeSelector
formatOptions={previewFormatOptions}
formatValue={selectedFormat}
onFormatChange={(newFormat) => {
setSelectedFormat(newFormat);
}}
onPreviewTabSelect={() => {
setSelectedTab((prev) => prev === 'editor' ? 'preview' : 'editor');
}}
selectedTab={selectedTab}
/>
<div className="separator" />
<ResponseBookmark item={item} collection={collection} responseSize={responseSize} />
<ResponseCopy item={item} />
<ResponseActions item={item} collection={collection} />
<StatusCode status={response.status} isStreaming={item.response?.stream?.running} />
{item.response?.stream?.running
? <ResponseStopWatch startMillis={response.duration} />
: <ResponseTime duration={response.duration} />}
<ResponseSize size={responseSize} />
</>
) : null}
<div className="flex items-center response-pane-status">
<StatusCode status={response.status} isStreaming={item.response?.stream?.running} />
{item.response?.stream?.running
? <ResponseStopWatch startMillis={response.duration} />
: <ResponseTime duration={response.duration} />}
<ResponseSize size={responseSize} />
</div>
<div className="separator" />
<div className="flex items-center response-pane-actions">
{focusedTab?.responsePaneTab === 'timeline' ? (
<ClearTimeline item={item} collection={collection} />
) : (item?.response && !item?.response?.error) ? (
<ResponsePaneActions item={item} collection={collection} responseSize={responseSize} />
) : null}
</div>
</div>
) : null}
</div>
<section
className="flex flex-col min-h-0 relative px-4 pt-3 auto overflow-auto"
className="flex flex-col min-h-0 relative px-4 auto overflow-auto"
style={{
flex: '1 1 0',
height: hasScriptError && showScriptErrorCard ? 'auto' : '100%'

View File

@@ -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,7 +1,7 @@
import React, { useState, useEffect } from 'react';
import get from 'lodash/get';
import classnames from 'classnames';
import QueryResponse from 'components/ResponsePane/QueryResponse/index';
import QueryResult from 'components/ResponsePane/QueryResult';
import ResponseHeaders from 'components/ResponsePane/ResponseHeaders';
import StatusCode from 'components/ResponsePane/StatusCode';
import ResponseTime from 'components/ResponsePane/ResponseTime';
@@ -39,7 +39,7 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
switch (tab) {
case 'response': {
return (
<QueryResponse
<QueryResult
item={item}
collection={collection}
width={rightPaneWidth}

View File

@@ -1,29 +1,15 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.sandbox-icon {
display: flex;
align-items: center;
justify-content: center;
width: 1.375rem;
height: 1.375rem;
border-radius: ${(props) => props.theme.border.radius.base};
cursor: pointer;
transition: all 0.15s ease;
&:hover {
opacity: 0.8;
}
}
.safe-mode {
background-color: ${(props) => props.theme.app.collection.toolbar.sandboxMode.safeMode.bg};
color: ${(props) => props.theme.app.collection.toolbar.sandboxMode.safeMode.color};
padding: 0.15rem 0.3rem;
color: ${(props) => props.theme.colors.text.green};
border: solid 1px ${(props) => props.theme.colors.text.green} !important;
}
.developer-mode {
background-color: ${(props) => props.theme.app.collection.toolbar.sandboxMode.developerMode.bg};
color: ${(props) => props.theme.app.collection.toolbar.sandboxMode.developerMode.color};
padding: 0.15rem 0.3rem;
color: ${(props) => props.theme.colors.text.yellow};
border: solid 1px ${(props) => props.theme.colors.text.yellow} !important;
}
`;

View File

@@ -1,5 +1,5 @@
import { useDispatch } from 'react-redux';
import { IconShieldCheck, IconCode } from '@tabler/icons';
import { IconShieldLock } from '@tabler/icons';
import { addTab } from 'providers/ReduxStore/slices/tabs';
import { uuid } from 'utils/common/index';
import JsSandboxModeModal from '../JsSandboxModeModal';
@@ -23,22 +23,18 @@ const JsSandboxMode = ({ collection }) => {
<StyledWrapper className="flex">
{jsSandboxMode === 'safe' && (
<div
className="sandbox-icon safe-mode"
data-testid="sandbox-mode-selector"
className="flex items-center border rounded-md text-xs cursor-pointer safe-mode"
onClick={viewSecuritySettings}
title="Safe Mode"
>
<IconShieldCheck size={14} strokeWidth={2} />
Safe Mode
</div>
)}
{jsSandboxMode === 'developer' && (
<div
className="sandbox-icon developer-mode"
data-testid="sandbox-mode-selector"
className="flex items-center border rounded-md text-xs cursor-pointer developer-mode"
onClick={viewSecuritySettings}
title="Developer Mode"
>
<IconCode size={14} strokeWidth={2} />
Developer Mode
</div>
)}
{!jsSandboxMode ? <JsSandboxModeModal collection={collection} /> : null}

View File

@@ -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,7 +1,7 @@
import { setActiveApiSpecUid } from 'providers/ReduxStore/slices/apiSpec';
import { showApiSpecPage as _showApiSpecPage } from 'providers/ReduxStore/slices/app';
import Dropdown from 'components/Dropdown';
import { IconDots, IconX } from '@tabler/icons';
import { IconDots } from '@tabler/icons';
import { useState, useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import CloseApiSpec from '../CloseApiSpec/index';
@@ -53,10 +53,7 @@ const ApiSpecItem = ({ apiSpec }) => {
setCloseApiSpecModal(true);
}}
>
<span className="dropdown-icon">
<IconX size={16} strokeWidth={2} />
</span>
Remove
Close
</div>
</Dropdown>
</div>

View File

@@ -1,27 +1,7 @@
import styled from 'styled-components';
const Wrapper = styled.div`
display: flex;
flex-direction: column;
flex: 1 1 0%;
min-height: 0;
height: 100%;
overflow: hidden;
padding-top: 4px;
padding-bottom: 4px;
.api-specs-list {
flex: 1 1 0%;
min-height: 0;
padding-top: 4px;
padding-bottom: 4px;
overflow-y: auto;
overflow-x: hidden;
}
.api-spec-item {
height: 1.6rem;
cursor: pointer;
&.active {
background: ${(props) => props.theme.sidebar.collection.item.bg};
}
@@ -56,6 +36,14 @@ const Wrapper = styled.div`
top: -0.625rem;
}
div.dropdown-item.close-item {
color: ${(props) => props.theme.colors.danger};
&:hover {
background-color: ${(props) => props.theme.colors.bg.danger};
color: white;
}
}
.placeholder {
color: ${(props) => props.theme.colors.text.muted};
}

View File

@@ -4,6 +4,7 @@ import { useDispatch, useSelector } from 'react-redux';
import { useTheme } from 'providers/Theme';
import { openApiSpec } from 'providers/ReduxStore/slices/apiSpec';
import ApiSpecItem from './ApiSpecItem';
import ApiSpecsBadge from './ApiSpecBadge';
import StyledWrapper from './StyledWrapper';
import toast from 'react-hot-toast';
@@ -46,7 +47,8 @@ const ApiSpecs = () => {
if (!apiSpecs || !apiSpecs.length) {
return (
<StyledWrapper>
<div className="text-xs text-center placeholder py-4">
<ApiSpecsBadge />
<div className="text-xs text-center placeholder mt-4">
<div>No API Specs found.</div>
<div className="mt-2">
<OpenLink /> API Spec.
@@ -58,12 +60,15 @@ const ApiSpecs = () => {
return (
<StyledWrapper>
<div className="api-specs-list">
{apiSpecs && apiSpecs.length
? apiSpecs.map((apiSpec) => {
return <ApiSpecItem apiSpec={apiSpec} key={apiSpec.uid} />;
})
: null}
<div className="relative">
<ApiSpecsBadge />
<div className="flex flex-col top-32 bottom-10 left-0 right-0 py-4">
{apiSpecs && apiSpecs.length
? apiSpecs.map((apiSpec) => {
return <ApiSpecItem apiSpec={apiSpec} key={apiSpec.uid} />;
})
: null}
</div>
</div>
</StyledWrapper>
);

View File

@@ -26,7 +26,6 @@ const ExampleItem = ({ example, item, collection }) => {
const [showRenameModal, setShowRenameModal] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const dropdownTippyRef = useRef(null);
const exampleRef = useRef(null);
// Calculate indentation: item depth + 1 for examples
const indents = range((item.depth || 0) + 1);
@@ -58,16 +57,6 @@ const ExampleItem = ({ example, item, collection }) => {
setEditName(example.name);
}, [example.name]);
useEffect(() => {
if (isExampleActive && exampleRef.current) {
try {
exampleRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
} catch (err) {
// ignore scroll errors
}
}
}, [isExampleActive]);
const handleClone = async () => {
// Calculate the index where the cloned example will be saved
// It will be at the end of the examples array
@@ -148,7 +137,6 @@ const ExampleItem = ({ example, item, collection }) => {
return (
<StyledWrapper
ref={exampleRef}
className={itemRowClassName}
onClick={handleExampleClick}
onDoubleClick={handleDoubleClick}

View File

@@ -39,7 +39,7 @@ import GenerateCodeItem from './GenerateCodeItem';
import { isItemARequest, isItemAFolder } from 'utils/tabs';
import { doesRequestMatchSearchText, doesFolderHaveItemsMatchSearchText } from 'utils/collections/search';
import { getDefaultRequestPaneTab } from 'utils/collections';
import { hideHomePage, hideApiSpecPage } from 'providers/ReduxStore/slices/app';
import { hideHomePage } from 'providers/ReduxStore/slices/app';
import toast from 'react-hot-toast';
import StyledWrapper from './StyledWrapper';
import NetworkError from 'components/ResponsePane/NetworkError/index';
@@ -222,7 +222,6 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
const isRequest = isItemARequest(item);
if (isRequest) {
dispatch(hideHomePage());
dispatch(hideApiSpecPage());
if (isTabForItemPresent) {
dispatch(
focusTab({
@@ -240,8 +239,6 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
})
);
} else {
dispatch(hideHomePage());
dispatch(hideApiSpecPage());
dispatch(
addTab({
uid: item.uid,
@@ -504,7 +501,6 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
onKeyDown={handleKeyDown}
onFocus={handleFocus}
onBlur={handleBlur}
data-testid="sidebar-collection-item-row"
>
<div className="flex items-center h-full w-full">
{indents && indents.length

View File

@@ -24,7 +24,7 @@ import Dropdown from 'components/Dropdown';
import { toggleCollection, collapseFullCollection } from 'providers/ReduxStore/slices/collections';
import { mountCollection, moveCollectionAndPersist, handleCollectionItemDrop, pasteItem } from 'providers/ReduxStore/slices/collections/actions';
import { useDispatch, useSelector } from 'react-redux';
import { hideApiSpecPage, hideHomePage } from 'providers/ReduxStore/slices/app';
import { hideHomePage } from 'providers/ReduxStore/slices/app';
import { addTab, makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
import toast from 'react-hot-toast';
import NewRequest from 'components/Sidebar/NewRequest';
@@ -112,7 +112,6 @@ const Collection = ({ collection, searchText }) => {
if (!isChevronClick) {
dispatch(hideHomePage()); // @TODO Playwright tests are often stuck on home page, rather than collection settings tab. Revisit for a proper fix.
dispatch(hideApiSpecPage());
dispatch(
addTab({
uid: collection.uid,
@@ -245,16 +244,6 @@ const Collection = ({ collection, searchText }) => {
dragPreview(getEmptyImage(), { captureDraggingState: true });
}, []);
useEffect(() => {
if (isCollectionFocused && collectionRef.current) {
try {
collectionRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
} catch (err) {
// ignore scroll errors
}
}
}, [isCollectionFocused]);
if (searchText && searchText.length) {
if (!doesCollectionHaveItemsMatchingSearchText(collection, searchText)) {
return null;
@@ -303,7 +292,6 @@ const Collection = ({ collection, searchText }) => {
onKeyDown={handleKeyDown}
onFocus={handleFocus}
onBlur={handleBlur}
data-testid="sidebar-collection-row"
>
<div
className="flex flex-grow items-center overflow-hidden"

View File

@@ -3,20 +3,32 @@ import styled from 'styled-components';
const Wrapper = styled.div`
display: flex;
flex-direction: column;
flex: 1 1 0%;
flex: 1;
min-height: 0;
overflow: hidden;
padding-top: 4px;
padding-bottom: 4px;
.collections-list {
flex: 1 1 0%;
min-height: 0;
padding-top: 4px;
padding-bottom: 4px;
overflow-y: auto;
overflow-x: hidden;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: ${(props) => props.theme.scrollbar.color};
border-radius: 3px;
}
&::-webkit-scrollbar-thumb:hover {
background: ${(props) => props.theme.scrollbar.color};
}
}
`;

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