fix: refactor response examples to use MenuDropdown and Editable components (#6382)

* feat: use common dropdown component

* fix: update example ui to match v3

* fix: test cases, bugs

* fix: review comments

* fix: review comments

* fix: review

* fix: file body/binary table within response examples

* fix: file name, close btn not visible issue

* fix: unnessary transition for three  dots

* fix: install missing deps in bruno-app

* update example url when param is updated

* empty commit

* chore: update package-lock.json

---------

Co-authored-by: Bijin A B <bijin@usebruno.com>
This commit is contained in:
sanish chirayath
2025-12-14 16:21:06 +05:30
committed by GitHub
parent 2f5537c8db
commit 8cbda5f5cc
30 changed files with 1211 additions and 1349 deletions

26
package-lock.json generated
View File

@@ -28450,6 +28450,7 @@
"lodash": "^4.17.21",
"markdown-it": "^13.0.2",
"markdown-it-replace-link": "^1.2.0",
"mime-types": "^3.0.2",
"moment": "^2.30.1",
"moment-timezone": "^0.5.47",
"mousetrap": "^1.6.5",
@@ -30007,6 +30008,31 @@
"uc.micro": "^2.0.0"
}
},
"packages/bruno-app/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"
}
},
"packages/bruno-app/node_modules/mime-types": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
"license": "MIT",
"dependencies": {
"mime-db": "^1.54.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"packages/bruno-app/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",

View File

@@ -44,9 +44,8 @@
"i18next": "24.1.2",
"idb": "^7.0.0",
"immer": "^9.0.15",
"jsesc": "^3.0.2",
"js-yaml": "^4.1.0",
"xml2js": "^0.6.2",
"jsesc": "^3.0.2",
"jshint": "^2.13.6",
"json5": "^2.2.3",
"jsonc-parser": "^3.2.1",
@@ -56,6 +55,7 @@
"lodash": "^4.17.21",
"markdown-it": "^13.0.2",
"markdown-it-replace-link": "^1.2.0",
"mime-types": "^3.0.2",
"moment": "^2.30.1",
"moment-timezone": "^0.5.47",
"mousetrap": "^1.6.5",
@@ -89,6 +89,7 @@
"system": "^2.0.1",
"url": "^0.11.3",
"xml-formatter": "^3.5.0",
"xml2js": "^0.6.2",
"yup": "^0.32.11"
},
"devDependencies": {

View File

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

View File

@@ -1,17 +1,27 @@
import React, { useRef, forwardRef } from 'react';
import { IconCaretDown } from '@tabler/icons';
import {
IconCaretDown,
IconForms,
IconBraces,
IconCode,
IconFileText,
IconDatabase,
IconFile,
IconX
} from '@tabler/icons';
import Dropdown from 'components/Dropdown';
import { humanizeRequestBodyMode } from 'utils/collections';
import StyledWrapper from './StyledWrapper';
const DEFAULT_MODES = [
{ key: 'multipartForm', label: 'Multipart Form', category: 'Form' },
{ key: 'formUrlEncoded', label: 'Form URL Encoded', category: 'Form' },
{ key: 'json', label: 'JSON', category: 'Raw' },
{ key: 'xml', label: 'XML', category: 'Raw' },
{ key: 'text', label: 'TEXT', category: 'Raw' },
{ key: 'sparql', label: 'SPARQL', category: 'Raw' },
{ key: 'file', label: 'File / Binary', category: 'Other' },
{ key: 'none', label: 'None', category: 'Other' }
{ key: 'multipartForm', label: 'Multipart Form', category: 'Form', icon: IconForms },
{ key: 'formUrlEncoded', label: 'Form URL Encoded', category: 'Form', icon: IconForms },
{ key: 'json', label: 'JSON', category: 'Raw', icon: IconBraces },
{ key: 'xml', label: 'XML', category: 'Raw', icon: IconCode },
{ key: 'text', label: 'TEXT', category: 'Raw', icon: IconFileText },
{ key: 'sparql', label: 'SPARQL', category: 'Raw', icon: IconDatabase },
{ key: 'file', label: 'File / Binary', category: 'Other', icon: IconFile },
{ key: 'none', label: 'No Body', category: 'Other', icon: IconX }
];
const BodyModeSelector = ({
@@ -53,30 +63,40 @@ const BodyModeSelector = ({
}, {});
return (
<div className={`inline-flex items-center body-mode-selector ${disabled ? 'cursor-default' : 'cursor-pointer'} ${wrapperClassName}`}>
<Dropdown
onCreate={onDropdownCreate}
icon={<Icon />}
placement={placement}
disabled={disabled}
className={className}
>
{Object.entries(groupedModes).map(([category, categoryModes]) => (
<React.Fragment key={category}>
{showCategories && <div className="label-item font-medium">{category}</div>}
{categoryModes.map((mode) => (
<div
key={mode.key}
className="dropdown-item"
onClick={() => onModeSelect(mode.key)}
>
{mode.label}
</div>
))}
</React.Fragment>
))}
</Dropdown>
</div>
<StyledWrapper className={wrapperClassName}>
<div className={`inline-flex items-center body-mode-selector ${disabled ? 'cursor-default' : 'cursor-pointer'}`}>
<Dropdown
onCreate={onDropdownCreate}
icon={<Icon />}
placement={placement}
disabled={disabled}
className={className}
>
{Object.entries(groupedModes).map(([category, categoryModes]) => (
<React.Fragment key={category}>
{showCategories && <div className="label-item">{category}</div>}
{categoryModes.map((mode) => {
const ModeIcon = mode.icon;
return (
<div
key={mode.key}
className="dropdown-item"
onClick={() => onModeSelect(mode.key)}
>
{ModeIcon && (
<span className="dropdown-icon">
<ModeIcon size={16} strokeWidth={2} />
</span>
)}
{mode.label}
</div>
);
})}
</React.Fragment>
))}
</Dropdown>
</div>
</StyledWrapper>
);
};

View File

@@ -35,17 +35,17 @@ const StyledWrapper = styled.div`
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;
}
}
}
&.has-checkbox thead td:nth-child(1) {
width: 25px !important;
border-right: none;
}
tbody {
tr {
transition: background 0.1s ease;
@@ -62,19 +62,6 @@ const StyledWrapper = styled.div`
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;
}
@@ -82,6 +69,19 @@ const StyledWrapper = styled.div`
}
}
&.has-checkbox tbody td:nth-child(1) {
width: 25px;
border-right: none;
text-align: center;
vertical-align: middle;
line-height: 1;
input[type='checkbox'] {
vertical-align: baseline;
display: inline-block;
}
}
.tooltip-mod {
font-size: 11px !important;
max-width: 200px !important;

View File

@@ -223,7 +223,7 @@ const EditableTable = ({
const reorderableRowCount = showAddRow ? rowsWithEmpty.length - 1 : rowsWithEmpty.length;
return (
<StyledWrapper>
<StyledWrapper className={showCheckbox ? 'has-checkbox' : 'no-checkbox'}>
<div className="table-container" ref={tableRef}>
<table>
<thead>

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,36 +14,34 @@ const ResponseExampleBodyMode = ({ item, collection, exampleUid, body, bodyMode,
// Initialize the new body structure based on the selected mode
let newBody = { mode: value };
// Preserve existing data for the new mode if it exists
if (body) {
switch (value) {
case 'json':
newBody.json = body.json || '';
break;
case 'text':
newBody.text = body.text || '';
break;
case 'xml':
newBody.xml = body.xml || '';
break;
case 'sparql':
newBody.sparql = body.sparql || '';
break;
case 'formUrlEncoded':
newBody.formUrlEncoded = body.formUrlEncoded || [];
break;
case 'multipartForm':
newBody.multipartForm = body.multipartForm || [];
break;
case 'file':
newBody.file = body.file || { name: '', data: '' };
break;
case 'none':
// No additional data needed for 'none' mode
break;
default:
break;
}
// Initialize body content based on selected mode
switch (value) {
case 'json':
newBody.json = body?.json || '';
break;
case 'text':
newBody.text = body?.text || '';
break;
case 'xml':
newBody.xml = body?.xml || '';
break;
case 'sparql':
newBody.sparql = body?.sparql || '';
break;
case 'formUrlEncoded':
newBody.formUrlEncoded = body?.formUrlEncoded || [];
break;
case 'multipartForm':
newBody.multipartForm = body?.multipartForm || [];
break;
case 'file':
newBody.file = Array.isArray(body?.file) ? body.file : [];
break;
case 'none':
// No additional data needed for 'none' mode
break;
default:
break;
}
dispatch(updateResponseExampleRequest({

View File

@@ -1,16 +1,14 @@
import React, { useState, useMemo } from 'react';
import { get, cloneDeep } from 'lodash';
import { IconTrash } from '@tabler/icons';
import React, { useState, useMemo, useCallback } from 'react';
import { get } from 'lodash';
import { useDispatch } from 'react-redux';
import { useTheme } from 'providers/Theme';
import { updateResponseExampleFileBodyParams } from 'providers/ReduxStore/slices/collections';
import mime from 'mime-types';
import path from 'utils/common/path';
import EditableTable from 'components/EditableTable';
import StyledWrapper from './StyledWrapper';
import FilePickerEditor from 'components/FilePickerEditor/index';
import SingleLineEditor from 'components/SingleLineEditor/index';
import Table from 'components/Table-v2';
import ReorderTable from 'components/ReorderTable';
import RadioButton from 'components/RadioButton';
const ResponseExampleFileBody = ({ item, collection, exampleUid, editMode = false }) => {
@@ -25,16 +23,8 @@ const ResponseExampleFileBody = ({ item, collection, exampleUid, editMode = fals
return Array.isArray(_params) ? _params : [];
}, [item.draft, item.examples, item, exampleUid]);
const [enabledFileUid, setEnableFileUid] = useState(params.length > 0 ? params[0].uid : '');
const addFile = () => {
const newParam = {
filePath: '',
contentType: '',
selected: true
};
const updatedParams = [...params, newParam];
const handleParamsChange = useCallback((updatedParams) => {
if (!editMode) return;
dispatch(updateResponseExampleFileBodyParams({
itemUid: item.uid,
@@ -42,75 +32,70 @@ const ResponseExampleFileBody = ({ item, collection, exampleUid, editMode = fals
exampleUid: exampleUid,
params: updatedParams
}));
};
}, [editMode, dispatch, item.uid, collection.uid, exampleUid]);
const handleParamChange = (e, _param, type) => {
const handleFilePathChange = useCallback((row, newFilePath, onChange) => {
if (!editMode) return;
const param = cloneDeep(_param);
switch (type) {
case 'filePath': {
param.filePath = e.target.filePath;
// Auto-detect content type from file extension using mime library (same as updateFile)
const contentType = mime.contentType(path.extname(e.target.filePath));
param.contentType = contentType || '';
break;
}
case 'contentType': {
param.contentType = e.target.contentType;
break;
}
case 'selected': {
// When a file is selected, deselect all others and select this one
const updatedParams = params.map((p) => ({
...p,
selected: p.uid === param.uid ? e.target.checked : false
}));
const currentParams = params || [];
const existingParam = currentParams.find((p) => p.uid === row.uid);
dispatch(updateResponseExampleFileBodyParams({
itemUid: item.uid,
collectionUid: collection.uid,
exampleUid: exampleUid,
params: updatedParams
}));
// Update the enabled file UID state
if (e.target.checked) {
setEnableFileUid(param.uid);
let updatedParams;
if (existingParam) {
// Update existing param
updatedParams = currentParams.map((p) => {
if (p.uid === row.uid) {
const updated = { ...p, filePath: newFilePath };
// Auto-detect content type from file extension
if (newFilePath) {
const contentType = mime.contentType(path.extname(newFilePath));
updated.contentType = contentType || '';
} else {
updated.contentType = '';
}
return updated;
}
return; // Early return since we already dispatched
return p;
});
} else {
// Add new param (from EditableTable's empty row)
// Deselect all existing params and select the new one
const deselectedParams = currentParams.map((p) => ({ ...p, selected: false }));
const newParam = {
uid: row.uid,
filePath: newFilePath,
contentType: '',
selected: true
};
// Auto-detect content type from file extension
if (newFilePath) {
const contentType = mime.contentType(path.extname(newFilePath));
newParam.contentType = contentType || '';
}
updatedParams = [...deselectedParams, newParam];
}
const updatedParams = params.map((p) => p.uid === param.uid ? param : p);
handleParamsChange(updatedParams);
}, [editMode, params, handleParamsChange]);
dispatch(updateResponseExampleFileBodyParams({
itemUid: item.uid,
collectionUid: collection.uid,
exampleUid: exampleUid,
params: updatedParams
}));
};
const handleRemoveParams = (param) => {
const handleSelectedChange = useCallback((row, checked) => {
if (!editMode) return;
const updatedParams = params.filter((p) => p.uid !== param.uid);
dispatch(updateResponseExampleFileBodyParams({
itemUid: item.uid,
collectionUid: collection.uid,
exampleUid: exampleUid,
params: updatedParams
// When a file is selected, deselect all others and select this one
const updatedParams = params.map((p) => ({
...p,
selected: p.uid === row.uid ? checked : false
}));
};
const handleParamDrag = ({ updateReorderedItem }) => {
handleParamsChange(updatedParams);
}, [editMode, params, handleParamsChange]);
const handleParamDrag = useCallback(({ updateReorderedItem }) => {
if (!editMode) return;
const reorderedParams = updateReorderedItem.map((uid) => {
return params.find((p) => p.uid === uid);
});
}).filter(Boolean);
dispatch(updateResponseExampleFileBodyParams({
itemUid: item.uid,
@@ -118,6 +103,73 @@ const ResponseExampleFileBody = ({ item, collection, exampleUid, editMode = fals
exampleUid: exampleUid,
params: reorderedParams
}));
}, [editMode, dispatch, item.uid, collection.uid, exampleUid, params]);
const columns = [
{
key: 'filePath',
name: 'File',
isKeyField: true,
placeholder: 'File',
width: '50%',
readOnly: !editMode,
render: ({ row, value, onChange, isLastEmptyRow }) => (
<FilePickerEditor
isSingleFilePicker={true}
value={value || ''}
onChange={(newPath) => handleFilePathChange(row, newPath, onChange)}
collection={collection}
readOnly={!editMode}
/>
)
},
{
key: 'contentType',
name: 'Content-Type',
placeholder: 'Auto',
width: '30%',
readOnly: !editMode,
render: ({ row, value, onChange, isLastEmptyRow }) => (
<SingleLineEditor
className="flex items-center justify-center"
onSave={() => {}}
theme={storedTheme}
placeholder={isLastEmptyRow ? 'Auto' : ''}
value={value || ''}
onChange={onChange}
onRun={() => {}}
collection={collection}
readOnly={!editMode}
/>
)
},
{
key: 'selected',
name: 'Selected',
width: '20%',
readOnly: !editMode,
render: ({ row, value, onChange, isLastEmptyRow, rowIndex }) => (
<div className="flex items-center justify-center pl-4">
<RadioButton
key={row.uid}
id={`file-${row.uid}`}
name="selectedFile"
value={row.uid}
checked={row.selected}
onChange={(e) => handleSelectedChange(row, e.target.checked)}
disabled={!editMode}
className="mr-1 mousetrap"
dataTestId={`file-radio-button-${rowIndex}`}
/>
</div>
)
}
];
const defaultRow = {
filePath: '',
contentType: '',
selected: false
};
if (params.length === 0 && !editMode) {
@@ -126,95 +178,16 @@ const ResponseExampleFileBody = ({ item, collection, exampleUid, editMode = fals
return (
<StyledWrapper className="w-full mt-4">
<Table
headers={[
{ name: 'File', accessor: 'file', width: '50%' },
{ name: 'Content-Type', accessor: 'contentType', width: '30%' },
{ name: 'Selected', accessor: 'selected', width: '20%' }
]}
>
<ReorderTable updateReorderedItem={handleParamDrag}>
{params && params.length
? params.map((param, index) => {
return (
<tr key={param.uid} data-uid={param.uid}>
<td className="flex relative">
<FilePickerEditor
isSingleFilePicker={true}
value={param.filePath}
onChange={editMode ? (path) =>
handleParamChange({
target: {
filePath: path
}
},
param,
'filePath') : () => {}}
collection={collection}
readOnly={!editMode}
/>
</td>
<td>
<div className="flex items-center justify-center pl-4">
<SingleLineEditor
className="flex items-center justify-center"
onSave={() => {}}
theme={storedTheme}
placeholder="Auto"
value={param.contentType}
onChange={editMode ? (newValue) =>
handleParamChange({
target: {
contentType: newValue
}
},
param,
'contentType') : () => {}}
onRun={() => {}}
collection={collection}
/>
</div>
</td>
<td>
<div className="flex items-center justify-center pl-4">
<RadioButton
key={param.uid}
id={`file-${param.uid}`}
name="selectedFile"
value={param.uid}
checked={enabledFileUid === param.uid || param.selected}
onChange={editMode ? (e) => handleParamChange(e, param, 'selected') : () => {}}
disabled={!editMode}
className="mr-1 mousetrap"
dataTestId={`file-radio-button-${index}`}
/>
<button
tabIndex="-1"
onClick={() => handleRemoveParams(param)}
className={`delete-button ${editMode ? 'edit-mode' : ''}`}
disabled={!editMode}
>
<IconTrash strokeWidth={1.5} size={16} />
</button>
</div>
</td>
</tr>
);
})
: null}
</ReorderTable>
</Table>
{editMode && (
<div className="flex justify-between mt-2">
<button
className="btn-action pr-2 py-3 select-none"
onClick={addFile}
>
+ Add File
</button>
</div>
)}
<EditableTable
columns={columns}
rows={params || []}
onChange={handleParamsChange}
defaultRow={defaultRow}
reorderable={editMode}
onReorder={handleParamDrag}
showAddRow={editMode}
showCheckbox={false}
/>
</StyledWrapper>
);
};

View File

@@ -1,15 +1,11 @@
import React, { useMemo } from 'react';
import React, { useMemo, useCallback } from 'react';
import get from 'lodash/get';
import cloneDeep from 'lodash/cloneDeep';
import { IconTrash } from '@tabler/icons';
import { useDispatch } from 'react-redux';
import { useTheme } from 'providers/Theme';
import { updateResponseExampleFormUrlEncodedParams } from 'providers/ReduxStore/slices/collections';
import EditableTable from 'components/EditableTable';
import MultiLineEditor from 'components/MultiLineEditor';
import StyledWrapper from './StyledWrapper';
import ReorderTable from 'components/ReorderTable/index';
import Table from 'components/Table-v2';
import Checkbox from 'components/Checkbox';
const ResponseExampleFormUrlEncodedParams = ({ item, collection, exampleUid, editMode = false }) => {
const dispatch = useDispatch();
@@ -21,72 +17,67 @@ const ResponseExampleFormUrlEncodedParams = ({ item, collection, exampleUid, edi
: get(item, 'examples', []).find((e) => e.uid === exampleUid)?.request?.body?.formUrlEncoded || [];
}, [item, exampleUid]);
const addParam = () => {
const newParam = {
name: '',
value: '',
enabled: true
};
const updatedParams = [...params, newParam];
dispatch(updateResponseExampleFormUrlEncodedParams({
itemUid: item.uid,
collectionUid: collection.uid,
exampleUid: exampleUid,
params: updatedParams
}));
};
const handleParamChange = (e, _param, type) => {
const handleParamsChange = useCallback((updatedParams) => {
if (!editMode) return;
const param = cloneDeep(_param);
switch (type) {
case 'name': {
param.name = e.target.value;
break;
}
case 'value': {
param.value = e.target.value;
break;
}
case 'enabled': {
param.enabled = e.target.checked;
break;
}
dispatch(updateResponseExampleFormUrlEncodedParams({
itemUid: item.uid,
collectionUid: collection.uid,
exampleUid: exampleUid,
params: updatedParams
}));
}, [editMode, dispatch, item.uid, collection.uid, exampleUid]);
const handleParamDrag = useCallback(({ updateReorderedItem }) => {
if (!editMode) return;
const reorderedParams = updateReorderedItem.map((uid) => {
return params.find((p) => p.uid === uid);
}).filter(Boolean);
dispatch(updateResponseExampleFormUrlEncodedParams({
itemUid: item.uid,
collectionUid: collection.uid,
exampleUid: exampleUid,
params: reorderedParams
}));
}, [editMode, dispatch, item.uid, collection.uid, exampleUid, params]);
const columns = [
{
key: 'name',
name: 'Key',
isKeyField: true,
placeholder: 'Key',
width: '40%',
readOnly: !editMode
},
{
key: 'value',
name: 'Value',
placeholder: 'Value',
width: '60%',
readOnly: !editMode,
render: ({ row, value, onChange, isLastEmptyRow }) => (
<MultiLineEditor
value={value || ''}
theme={storedTheme}
onSave={() => {}}
onChange={onChange}
allowNewlines={true}
onRun={() => {}}
collection={collection}
item={item}
placeholder={isLastEmptyRow ? 'Value' : ''}
/>
)
}
];
const updatedParams = params.map((p) => p.uid === param.uid ? param : p);
dispatch(updateResponseExampleFormUrlEncodedParams({
itemUid: item.uid,
collectionUid: collection.uid,
exampleUid: exampleUid,
params: updatedParams
}));
};
const handleRemoveParams = (param) => {
const updatedParams = params.filter((p) => p.uid !== param.uid);
dispatch(updateResponseExampleFormUrlEncodedParams({
itemUid: item.uid,
collectionUid: collection.uid,
exampleUid: exampleUid,
params: updatedParams
}));
};
const handleParamDrag = ({ updateReorderedItem }) => {
const updatedParams = updateReorderedItem(params);
dispatch(updateResponseExampleFormUrlEncodedParams({
itemUid: item.uid,
collectionUid: collection.uid,
exampleUid: exampleUid,
params: updatedParams
}));
const defaultRow = {
name: '',
value: '',
enabled: true
};
if (params.length === 0 && !editMode) {
@@ -95,84 +86,15 @@ const ResponseExampleFormUrlEncodedParams = ({ item, collection, exampleUid, edi
return (
<StyledWrapper className="w-full mt-4">
<Table
headers={[
{ name: 'Key', accessor: 'key', width: '40%' },
{ name: 'Value', accessor: 'value', width: '60%' }
]}
>
<ReorderTable updateReorderedItem={handleParamDrag}>
{params && params.length
? params.map((param, index) => {
return (
<tr key={param.uid} data-uid={param.uid}>
<td className="flex relative">
<div className="flex items-center justify-center mr-3">
<Checkbox
checked={param.enabled === true}
disabled={!editMode}
onChange={(e) => handleParamChange(e, param, 'enabled')}
dataTestId={`urlencoded-param-checkbox-${index}`}
/>
</div>
<input
type="text"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
value={param.name}
className="mousetrap"
onChange={editMode ? (e) => handleParamChange(e, param, 'name') : () => {}}
disabled={!editMode}
/>
</td>
<td>
<div className="flex items-center justify-center pl-4">
<MultiLineEditor
value={param.value}
theme={storedTheme}
onSave={() => {}}
onChange={editMode ? (newValue) =>
handleParamChange({
target: {
value: newValue
}
},
param,
'value') : () => {}}
allowNewlines={true}
onRun={() => {}}
collection={collection}
item={item}
/>
<button
tabIndex="-1"
onClick={() => handleRemoveParams(param)}
className={`delete-button ${editMode ? 'edit-mode' : ''}`}
disabled={!editMode}
>
<IconTrash strokeWidth={1.5} size={16} />
</button>
</div>
</td>
</tr>
);
})
: null}
</ReorderTable>
</Table>
{editMode && (
<div className="flex justify-between mt-2">
<button
className="btn-action text-link pr-2 py-3 select-none"
onClick={addParam}
>
+ Add Param
</button>
</div>
)}
<EditableTable
columns={columns}
rows={params || []}
onChange={handleParamsChange}
defaultRow={defaultRow}
reorderable={editMode}
onReorder={handleParamDrag}
showAddRow={editMode}
/>
</StyledWrapper>
);
};

View File

@@ -1,14 +1,11 @@
import React, { useState, useMemo } from 'react';
import React, { useState, useMemo, useCallback } from 'react';
import { useDispatch } from 'react-redux';
import { useTheme } from 'providers/Theme';
import { IconTrash } from '@tabler/icons';
import get from 'lodash/get';
import { addResponseExampleRequestHeader, updateResponseExampleRequestHeader, deleteResponseExampleRequestHeader, moveResponseExampleRequestHeader, setResponseExampleRequestHeaders } from 'providers/ReduxStore/slices/collections';
import Table from 'components/Table-v2';
import ReorderTable from 'components/ReorderTable';
import { moveResponseExampleRequestHeader, setResponseExampleRequestHeaders } from 'providers/ReduxStore/slices/collections';
import EditableTable from 'components/EditableTable';
import SingleLineEditor from 'components/SingleLineEditor';
import BulkEditor from 'components/BulkEditor';
import Checkbox from 'components/Checkbox';
import { headers as StandardHTTPHeaders } from 'know-your-http-well';
import { MimeTypes } from 'utils/codemirror/autocompleteConstants';
import StyledWrapper from './StyledWrapper';
@@ -26,55 +23,18 @@ const ResponseExampleHeaders = ({ editMode, item, collection, exampleUid }) => {
: get(item, 'examples', []).find((e) => e.uid === exampleUid)?.request?.headers || [];
}, [item, exampleUid]);
const handleAddHeader = () => {
const handleHeadersChange = useCallback((updatedHeaders) => {
if (editMode) {
dispatch(addResponseExampleRequestHeader({
itemUid: item.uid,
collectionUid: collection.uid,
exampleUid: exampleUid
}));
}
};
const handleHeaderValueChange = (e, header, type) => {
if (editMode) {
const updatedHeader = { ...header };
switch (type) {
case 'name': {
updatedHeader.name = e.target.value;
break;
}
case 'value': {
updatedHeader.value = e.target.value;
break;
}
case 'enabled': {
updatedHeader.enabled = e.target.checked;
break;
}
}
dispatch(updateResponseExampleRequestHeader({
dispatch(setResponseExampleRequestHeaders({
itemUid: item.uid,
collectionUid: collection.uid,
exampleUid: exampleUid,
header: updatedHeader
headers: updatedHeaders
}));
}
};
}, [editMode, dispatch, item.uid, collection.uid, exampleUid]);
const handleRemoveHeader = (header) => {
if (editMode) {
dispatch(deleteResponseExampleRequestHeader({
itemUid: item.uid,
collectionUid: collection.uid,
exampleUid: exampleUid,
headerUid: header.uid
}));
}
};
const handleHeaderDrag = ({ updateReorderedItem }) => {
const handleHeaderDrag = useCallback(({ updateReorderedItem }) => {
if (editMode) {
dispatch(moveResponseExampleRequestHeader({
itemUid: item.uid,
@@ -83,7 +43,7 @@ const ResponseExampleHeaders = ({ editMode, item, collection, exampleUid }) => {
updateReorderedItem
}));
}
};
}, [editMode, dispatch, item.uid, collection.uid, exampleUid]);
const toggleBulkEditMode = () => {
setIsBulkEditMode(!isBulkEditMode);
@@ -100,6 +60,58 @@ const ResponseExampleHeaders = ({ editMode, item, collection, exampleUid }) => {
}
};
const columns = [
{
key: 'name',
name: 'Key',
isKeyField: true,
placeholder: 'Key',
width: '40%',
readOnly: !editMode,
render: ({ row, value, onChange, isLastEmptyRow }) => (
<SingleLineEditor
value={value || ''}
readOnly={!editMode}
theme={storedTheme}
onSave={() => {}}
onChange={(newValue) => onChange(newValue.replace(/[\r\n]/g, ''))}
autocomplete={headerAutoCompleteList}
onRun={() => {}}
collection={collection}
placeholder={isLastEmptyRow ? 'Key' : ''}
/>
)
},
{
key: 'value',
name: 'Value',
placeholder: 'Value',
width: '60%',
readOnly: !editMode,
render: ({ row, value, onChange, isLastEmptyRow }) => (
<SingleLineEditor
value={value || ''}
readOnly={!editMode}
theme={storedTheme}
onSave={() => {}}
onChange={onChange}
onRun={() => {}}
autocomplete={MimeTypes}
allowNewlines={true}
collection={collection}
item={item}
placeholder={isLastEmptyRow ? 'Value' : ''}
/>
)
}
];
const defaultRow = {
name: '',
value: '',
enabled: true
};
if (isBulkEditMode && editMode) {
return (
<StyledWrapper className="w-full mt-3">
@@ -119,85 +131,17 @@ const ResponseExampleHeaders = ({ editMode, item, collection, exampleUid }) => {
return (
<StyledWrapper className="w-full mt-4">
<div className="mb-1 title text-xs font-bold">Headers</div>
<Table
headers={[
{ name: 'Key', accessor: 'key', width: '40%' },
{ name: 'Value', accessor: 'value', width: '60%' }
]}
>
<ReorderTable updateReorderedItem={handleHeaderDrag}>
{headers && headers.length
? headers.map((header, index) => (
<tr key={header.uid} data-uid={header.uid}>
<td className="flex relative">
<div className="flex items-center justify-center mr-3">
<Checkbox
checked={header.enabled === true}
disabled={!editMode}
onChange={(e) => handleHeaderValueChange(e, header, 'enabled')}
dataTestId={`header-checkbox-${index}`}
/>
</div>
<SingleLineEditor
value={header.name || ''}
readOnly={!editMode}
theme={storedTheme}
onSave={() => {}}
onChange={(newValue) =>
handleHeaderValueChange({
target: {
value: newValue
}
},
header,
'name')}
autocomplete={headerAutoCompleteList}
onRun={() => {}}
collection={collection}
/>
</td>
<td>
<div className="flex items-center justify-center pl-4">
<SingleLineEditor
value={header.value || ''}
readOnly={!editMode}
theme={storedTheme}
onSave={() => {}}
onChange={(newValue) =>
handleHeaderValueChange({
target: {
value: newValue
}
},
header,
'value')}
onRun={() => {}}
autocomplete={MimeTypes}
allowNewlines={true}
collection={collection}
item={item}
/>
{editMode && (
<button tabIndex="-1" onClick={() => handleRemoveHeader(header)} className="delete-button">
<IconTrash strokeWidth={1.5} size={16} />
</button>
)}
</div>
</td>
</tr>
))
: null}
</ReorderTable>
</Table>
<EditableTable
columns={columns}
rows={headers || []}
onChange={handleHeadersChange}
defaultRow={defaultRow}
reorderable={editMode}
onReorder={handleHeaderDrag}
showAddRow={editMode}
/>
{editMode && (
<div className="flex justify-between mt-2">
<button
className="btn-action text-link pr-2 py-3 select-none"
onClick={handleAddHeader}
>
+ Add Header
</button>
<div className="flex justify-end mt-2">
<button
className="btn-action text-link select-none"
onClick={toggleBulkEditMode}

View File

@@ -1,19 +1,17 @@
import React, { useMemo } from 'react';
import React, { useMemo, useCallback } from 'react';
import get from 'lodash/get';
import cloneDeep from 'lodash/cloneDeep';
import { IconTrash } from '@tabler/icons';
import { useDispatch } from 'react-redux';
import { useTheme } from 'providers/Theme';
import { IconUpload, IconX, IconFile } from '@tabler/icons';
import { updateResponseExampleMultipartFormParams } from 'providers/ReduxStore/slices/collections';
import { browseFiles } from 'providers/ReduxStore/slices/collections/actions';
import mime from 'mime-types';
import path from 'utils/common/path';
import EditableTable from 'components/EditableTable';
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';
import ReorderTable from 'components/ReorderTable/index';
import Checkbox from 'components/Checkbox';
import { isWindowsOS } from 'utils/common/platform';
const ResponseExampleMultipartFormParams = ({ item, collection, exampleUid, editMode = false }) => {
const dispatch = useDispatch();
@@ -25,95 +23,109 @@ const ResponseExampleMultipartFormParams = ({ item, collection, exampleUid, edit
: get(item, 'examples', []).find((e) => e.uid === exampleUid)?.request?.body?.multipartForm || [];
}, [item, exampleUid]);
const addParam = () => {
const newParam = {
name: '',
value: '',
contentType: '',
enabled: true,
type: 'text'
};
const updatedParams = [...params, newParam];
dispatch(updateResponseExampleMultipartFormParams({
itemUid: item.uid,
collectionUid: collection.uid,
exampleUid: exampleUid,
params: updatedParams
}));
};
const addFile = () => {
const newParam = {
name: '',
value: [],
contentType: '',
enabled: true,
type: 'file'
};
const updatedParams = [...params, newParam];
dispatch(updateResponseExampleMultipartFormParams({
itemUid: item.uid,
collectionUid: collection.uid,
exampleUid: exampleUid,
params: updatedParams
}));
};
const handleParamChange = (e, _param, type) => {
const handleParamsChange = useCallback((updatedParams) => {
if (!editMode) return;
const param = cloneDeep(_param);
switch (type) {
case 'name': {
param.name = e.target.value;
break;
}
case 'value': {
param.value = e.target.value;
if (param.type === 'file' && e.target.value) {
const contentType = mime.contentType(path.extname(e.target.value));
param.contentType = contentType || '';
dispatch(updateResponseExampleMultipartFormParams({
itemUid: item.uid,
collectionUid: collection.uid,
exampleUid: exampleUid,
params: updatedParams
}));
}, [editMode, dispatch, item.uid, collection.uid, exampleUid]);
const handleBrowseFiles = useCallback((row, onChange) => {
if (!editMode) return;
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 = params || [];
const existingParam = currentParams.find((p) => p.uid === row.uid);
let updatedParams;
if (existingParam) {
// Update existing param
updatedParams = currentParams.map((p) => {
if (p.uid === row.uid) {
const updated = { ...p, type: 'file', value: processedPaths };
// Auto-detect content type from first file
if (processedPaths.length > 0) {
const contentType = mime.contentType(path.extname(processedPaths[0]));
updated.contentType = contentType || '';
}
return updated;
}
return p;
});
} else {
// Add new param (from EditableTable's empty row)
const newParam = {
uid: row.uid,
name: row.name || '',
type: 'file',
value: processedPaths,
contentType: '',
enabled: true
};
// Auto-detect content type from first file
if (processedPaths.length > 0) {
const contentType = mime.contentType(path.extname(processedPaths[0]));
newParam.contentType = contentType || '';
}
updatedParams = [...currentParams, newParam];
}
break;
}
case 'contentType': {
param.contentType = e.target.value;
break;
}
case 'enabled': {
param.enabled = e.target.checked;
break;
}
}
const updatedParams = params.map((p) => p.uid === param.uid ? param : p);
handleParamsChange(updatedParams);
})
.catch((error) => {
console.error(error);
});
}, [editMode, dispatch, collection.pathname, params, handleParamsChange]);
dispatch(updateResponseExampleMultipartFormParams({
itemUid: item.uid,
collectionUid: collection.uid,
exampleUid: exampleUid,
params: updatedParams
}));
};
const handleRemoveParams = (param) => {
const handleClearFile = useCallback((row) => {
if (!editMode) return;
const updatedParams = params.filter((p) => p.uid !== param.uid);
const currentParams = params || [];
const existingParam = currentParams.find((p) => p.uid === row.uid);
dispatch(updateResponseExampleMultipartFormParams({
itemUid: item.uid,
collectionUid: collection.uid,
exampleUid: exampleUid,
params: updatedParams
}));
};
if (existingParam) {
const updatedParams = currentParams.map((p) => {
if (p.uid === row.uid) {
return { ...p, type: 'text', value: '' };
}
return p;
});
handleParamsChange(updatedParams);
}
}, [editMode, params, handleParamsChange]);
const handleParamDrag = ({ updateReorderedItem }) => {
const handleValueChange = useCallback((row, newValue, onChange) => {
if (!editMode) return;
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);
}
}, [editMode, params, handleParamsChange]);
const handleParamDrag = useCallback(({ updateReorderedItem }) => {
if (!editMode) return;
const reorderedParams = updateReorderedItem.map((uid) => {
@@ -126,6 +138,117 @@ const ResponseExampleMultipartFormParams = ({ item, collection, exampleUid, edit
exampleUid: exampleUid,
params: reorderedParams
}));
}, [editMode, dispatch, item.uid, collection.uid, exampleUid, params]);
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%',
readOnly: !editMode
},
{
key: 'value',
name: 'Value',
placeholder: 'Value',
width: '40%',
readOnly: !editMode,
render: ({ row, value, onChange, isLastEmptyRow }) => {
const isFile = row.type === 'file';
const fileName = isFile ? getFileName(value) : null;
const hasTextValue = !isFile && value && value.length > 0;
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={() => {}}
theme={storedTheme}
value={value || ''}
onChange={(newValue) => handleValueChange(row, newValue, onChange)}
onRun={() => {}}
allowNewlines={true}
collection={collection}
item={item}
readOnly={!editMode}
placeholder={isLastEmptyRow ? 'Value' : ''}
/>
</div>
{!hasTextValue && !isLastEmptyRow && (
<button
className="upload-btn ml-1"
onClick={() => handleBrowseFiles(row, onChange)}
title="Select file"
>
<IconUpload size={16} />
</button>
)}
</div>
);
}
},
{
key: 'contentType',
name: 'Content-Type',
placeholder: 'Auto',
width: '30%',
readOnly: !editMode,
render: ({ row, value, onChange, isLastEmptyRow }) => (
<SingleLineEditor
onSave={() => {}}
theme={storedTheme}
placeholder={isLastEmptyRow ? 'Auto' : ''}
value={value || ''}
onChange={onChange}
onRun={() => {}}
collection={collection}
readOnly={!editMode}
/>
)
}
];
const defaultRow = {
name: '',
value: '',
contentType: '',
enabled: true,
type: 'text'
};
if (params.length === 0 && !editMode) {
@@ -134,129 +257,15 @@ const ResponseExampleMultipartFormParams = ({ item, collection, exampleUid, edit
return (
<StyledWrapper className="w-full mt-4">
<Table
headers={[
{ name: 'Key', accessor: 'key', width: '30%' },
{ name: 'Value', accessor: 'value', width: '40%' },
{ name: 'Content-Type', accessor: 'content-type', width: '30%' }
]}
>
<ReorderTable updateReorderedItem={handleParamDrag}>
{params && params.length
? params.map((param, index) => {
return (
<tr key={param.uid} className="w-full" data-uid={param.uid}>
<td className="flex relative">
<div className="flex items-center justify-center mr-3">
<Checkbox
checked={param.enabled === true}
disabled={!editMode}
onChange={(e) => handleParamChange(e, param, 'enabled')}
dataTestId={`multipart-form-param-checkbox-${index}`}
/>
</div>
<input
type="text"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
value={param.name}
className="mousetrap"
onChange={(e) => handleParamChange(e, param, 'name')}
disabled={!editMode}
/>
</td>
<td>
<div className="flex items-center justify-center pl-4">
{param.type === 'file' ? (
<FilePickerEditor
value={param.value}
onChange={(newValue) =>
handleParamChange({
target: {
value: newValue
}
},
param,
'value')}
collection={collection}
readOnly={!editMode}
/>
) : (
<MultiLineEditor
onSave={() => {}}
theme={storedTheme}
value={param.value}
onChange={(newValue) =>
handleParamChange({
target: {
value: newValue
}
},
param,
'value')}
onRun={() => {}}
allowNewlines={true}
collection={collection}
item={item}
readOnly={!editMode}
/>
)}
</div>
</td>
<td>
<div className="flex items-center justify-center pl-4">
<SingleLineEditor
onSave={() => {}}
theme={storedTheme}
placeholder="Auto"
value={param.contentType}
onChange={(newValue) =>
handleParamChange({
target: {
value: newValue
}
},
param,
'contentType')}
onRun={() => {}}
collection={collection}
readOnly={!editMode}
/>
<button
tabIndex="-1"
onClick={() => handleRemoveParams(param)}
className={`delete-button ${editMode ? 'edit-mode' : ''}`}
disabled={!editMode}
>
<IconTrash strokeWidth={1.5} size={16} />
</button>
</div>
</td>
</tr>
);
})
: null}
</ReorderTable>
</Table>
{editMode && (
<div className="flex justify-between mt-2">
<button
className="btn-action text-link pr-2 py-3 select-none"
onClick={addParam}
>
+ Add Param
</button>
<button
className="btn-action text-link pr-2 py-3 select-none"
onClick={addFile}
>
+ Add File
</button>
</div>
)}
<EditableTable
columns={columns}
rows={params || []}
onChange={handleParamsChange}
defaultRow={defaultRow}
reorderable={editMode}
onReorder={handleParamDrag}
showAddRow={editMode}
/>
</StyledWrapper>
);
};

View File

@@ -43,13 +43,17 @@ const StyledWrapper = styled.div`
tbody {
tr {
border-bottom: 1px solid ${(props) => props.theme.table.border};
&:hover {
background: ${(props) => props.theme.plainGrid.hoverBg};
}
}
}
}
/* Override styles for EditableTable to prevent uppercase transformation and ensure proper spacing */
/* The .table-container is from EditableTable component */
.table-container table thead td {
text-transform: none !important;
letter-spacing: normal !important;
padding: 8px 10px !important;
}
tr {

View File

@@ -1,14 +1,11 @@
import React, { useState, useMemo } from 'react';
import React, { useState, useMemo, useCallback } from 'react';
import { useDispatch } from 'react-redux';
import { IconTrash } from '@tabler/icons';
import { useTheme } from 'providers/Theme';
import get from 'lodash/get';
import { addResponseExampleParam, updateResponseExampleParam, deleteResponseExampleParam, moveResponseExampleParam, setResponseExampleParams } from 'providers/ReduxStore/slices/collections';
import Table from 'components/Table-v2';
import ReorderTable from 'components/ReorderTable';
import { moveResponseExampleParam, setResponseExampleParams } from 'providers/ReduxStore/slices/collections';
import EditableTable from 'components/EditableTable';
import SingleLineEditor from 'components/SingleLineEditor';
import BulkEditor from 'components/BulkEditor';
import Checkbox from 'components/Checkbox';
import InfoTip from 'components/InfoTip';
import StyledWrapper from './StyledWrapper';
@@ -26,61 +23,22 @@ const ResponseExampleParams = ({ editMode, item, collection, exampleUid }) => {
const queryParams = params.filter((param) => param.type === 'query');
const pathParams = params.filter((param) => param.type === 'path');
const handleAddQueryParam = () => {
const handleQueryParamsChange = useCallback((updatedQueryParams) => {
if (!editMode) {
return;
}
dispatch(addResponseExampleParam({
itemUid: item.uid,
collectionUid: collection.uid,
exampleUid: exampleUid
}));
};
const handleQueryParamChange = (e, data, key) => {
if (!editMode) {
return;
}
const updatedParam = { ...data };
switch (key) {
case 'name': {
updatedParam.name = e.target.value;
break;
}
case 'value': {
updatedParam.value = e.target.value;
break;
}
case 'enabled': {
updatedParam.enabled = e.target.checked;
break;
}
}
dispatch(updateResponseExampleParam({
// Merge updated query params with path params
const allParams = [...updatedQueryParams, ...pathParams];
dispatch(setResponseExampleParams({
itemUid: item.uid,
collectionUid: collection.uid,
exampleUid: exampleUid,
param: updatedParam
params: allParams
}));
};
}, [editMode, dispatch, item.uid, collection.uid, exampleUid, pathParams]);
const handleRemoveQueryParam = (param) => {
if (!editMode) {
return;
}
dispatch(deleteResponseExampleParam({
itemUid: item.uid,
collectionUid: collection.uid,
exampleUid: exampleUid,
paramUid: param.uid
}));
};
const handleQueryParamDrag = ({ updateReorderedItem }) => {
const handleQueryParamDrag = useCallback(({ updateReorderedItem }) => {
if (!editMode) {
return;
}
@@ -91,7 +49,22 @@ const ResponseExampleParams = ({ editMode, item, collection, exampleUid }) => {
exampleUid: exampleUid,
updateReorderedItem
}));
};
}, [editMode, dispatch, item.uid, collection.uid, exampleUid]);
const handlePathParamsChange = useCallback((updatedPathParams) => {
if (!editMode) {
return;
}
// Merge updated path params with query params
const allParams = [...queryParams, ...updatedPathParams];
dispatch(setResponseExampleParams({
itemUid: item.uid,
collectionUid: collection.uid,
exampleUid: exampleUid,
params: allParams
}));
}, [editMode, dispatch, item.uid, collection.uid, exampleUid, queryParams]);
const toggleBulkEditMode = () => {
setIsBulkEditMode(!isBulkEditMode);
@@ -102,27 +75,13 @@ const ResponseExampleParams = ({ editMode, item, collection, exampleUid }) => {
return;
}
// Merge bulk edited query params with path params
const allParams = [...newParams, ...pathParams];
dispatch(setResponseExampleParams({
itemUid: item.uid,
collectionUid: collection.uid,
exampleUid: exampleUid,
params: newParams
}));
};
const handlePathParamChange = (e, data) => {
if (!editMode) {
return;
}
const updatedParam = { ...data };
updatedParam.value = e.target.value;
dispatch(updateResponseExampleParam({
itemUid: item.uid,
collectionUid: collection.uid,
exampleUid: exampleUid,
param: updatedParam
params: allParams
}));
};
@@ -138,6 +97,86 @@ const ResponseExampleParams = ({ editMode, item, collection, exampleUid }) => {
);
}
const queryColumns = [
{
key: 'name',
name: 'Name',
isKeyField: true,
placeholder: 'Name',
width: '40%',
readOnly: !editMode,
render: ({ row, value, onChange, isLastEmptyRow }) => (
<SingleLineEditor
value={value || ''}
theme={storedTheme}
onSave={() => {}}
onChange={onChange}
onRun={() => {}}
collection={collection}
variablesAutocomplete={true}
readOnly={!editMode}
placeholder={isLastEmptyRow ? 'Name' : ''}
/>
)
},
{
key: 'value',
name: 'Value',
placeholder: 'Value',
width: '60%',
readOnly: !editMode,
render: ({ row, value, onChange, isLastEmptyRow }) => (
<SingleLineEditor
value={value || ''}
theme={storedTheme}
onSave={() => {}}
onChange={onChange}
onRun={() => {}}
collection={collection}
variablesAutocomplete={true}
readOnly={!editMode}
placeholder={isLastEmptyRow ? 'Value' : ''}
/>
)
}
];
const pathColumns = [
{
key: 'name',
name: 'Name',
readOnly: true,
width: '40%'
},
{
key: 'value',
name: 'Value',
placeholder: 'Value',
width: '60%',
readOnly: !editMode,
render: ({ row, value, onChange, isLastEmptyRow }) => (
<SingleLineEditor
value={value || ''}
theme={storedTheme}
onSave={() => {}}
onChange={onChange}
onRun={() => {}}
collection={collection}
variablesAutocomplete={true}
readOnly={!editMode}
placeholder={isLastEmptyRow ? 'Value' : ''}
/>
)
}
];
const defaultQueryRow = {
name: '',
value: '',
enabled: true,
type: 'query'
};
if (queryParams.length === 0 && pathParams.length === 0 && !editMode) {
return null;
}
@@ -145,69 +184,17 @@ const ResponseExampleParams = ({ editMode, item, collection, exampleUid }) => {
return (
<StyledWrapper className="w-full mt-4">
<div className="mb-1 title text-xs font-bold">Query parameters</div>
<Table
headers={[
{ name: 'Name', accessor: 'name', width: '40%' },
{ name: 'Value', accessor: 'value', width: '60%' }
]}
>
<ReorderTable updateReorderedItem={handleQueryParamDrag}>
{queryParams && queryParams.length
? queryParams.map((param, index) => (
<tr key={param.uid} data-uid={param.uid}>
<td className="flex relative">
<div className="flex items-center justify-center mr-3">
<Checkbox
checked={param.enabled !== false}
disabled={!editMode}
onChange={(e) => handleQueryParamChange(e, param, 'enabled')}
dataTestId={`query-param-checkbox-${index}`}
/>
</div>
<SingleLineEditor
value={param.name || ''}
theme={storedTheme}
onSave={() => {}}
onChange={(newValue) => handleQueryParamChange({ target: { value: newValue } }, param, 'name')}
onRun={() => {}}
collection={collection}
variablesAutocomplete={true}
readOnly={!editMode}
/>
</td>
<td>
<div className="flex items-center justify-center pl-4">
<SingleLineEditor
value={param.value || ''}
theme={storedTheme}
onSave={() => {}}
onChange={(newValue) => handleQueryParamChange({ target: { value: newValue } }, param, 'value')}
onRun={() => {}}
collection={collection}
variablesAutocomplete={true}
readOnly={!editMode}
/>
{editMode && (
<button tabIndex="-1" onClick={() => handleRemoveQueryParam(param)} className="delete-button">
<IconTrash strokeWidth={1.5} size={16} />
</button>
)}
</div>
</td>
</tr>
))
: null}
</ReorderTable>
</Table>
<EditableTable
columns={queryColumns}
rows={queryParams || []}
onChange={handleQueryParamsChange}
defaultRow={defaultQueryRow}
reorderable={editMode}
onReorder={handleQueryParamDrag}
showAddRow={editMode}
/>
{editMode && (
<div className="flex justify-between mt-2">
<button
className="btn-action text-link pr-2 py-3 select-none"
onClick={handleAddQueryParam}
>
+ Add Param
</button>
<div className="flex justify-end mt-2">
<button
className="btn-action text-link select-none"
onClick={toggleBulkEditMode}
@@ -231,37 +218,16 @@ const ResponseExampleParams = ({ editMode, item, collection, exampleUid }) => {
</div>
</InfoTip>
</div>
<Table
headers={[
{ name: 'Name', accessor: 'name', width: '40%' },
{ name: 'Value', accessor: 'value', width: '60%' }
]}
>
{pathParams && pathParams.length
? pathParams.map((path, index) => {
return (
<tr key={index} data-uid={path.uid}>
<td>
{path.name}
</td>
<td>
<SingleLineEditor
value={path.value}
theme={storedTheme}
onSave={() => {}}
onChange={(newValue) => handlePathParamChange({ target: { value: newValue } }, path)}
onRun={() => {}}
collection={collection}
variablesAutocomplete={true}
readOnly={!editMode}
/>
</td>
</tr>
);
})
: null}
</Table>
{pathParams.length === 0 && <div className="title pr-2 py-3 mt-2 text-xs">No path parameters defined</div>}
<EditableTable
columns={pathColumns}
rows={pathParams}
onChange={handlePathParamsChange}
defaultRow={{}}
showCheckbox={false}
showDelete={false}
showAddRow={false}
reorderable={false}
/>
</>
)}

View File

@@ -1,12 +1,10 @@
import React, { useState, useMemo } from 'react';
import React, { useState, useMemo, useCallback } from 'react';
import { useDispatch } from 'react-redux';
import { useTheme } from 'providers/Theme';
import { IconTrash } from '@tabler/icons';
import get from 'lodash/get';
import { addResponseExampleHeader, updateResponseExampleHeader, deleteResponseExampleHeader, moveResponseExampleHeader, setResponseExampleHeaders, updateResponseExampleResponse } from 'providers/ReduxStore/slices/collections';
import { moveResponseExampleHeader, setResponseExampleHeaders, updateResponseExampleResponse } from 'providers/ReduxStore/slices/collections';
import { getBodyType } from 'utils/responseBodyProcessor';
import Table from 'components/Table-v2';
import ReorderTable from 'components/ReorderTable';
import EditableTable from 'components/EditableTable';
import SingleLineEditor from 'components/SingleLineEditor';
import BulkEditor from 'components/BulkEditor';
import { headers as StandardHTTPHeaders } from 'know-your-http-well';
@@ -28,45 +26,17 @@ const ResponseExampleResponseHeaders = ({ editMode, item, collection, exampleUid
return item.draft ? get(item, 'draft.examples', []).find((e) => e.uid === exampleUid)?.response || {} : get(item, 'examples', []).find((e) => e.uid === exampleUid)?.response || {};
}, [item, exampleUid]);
const handleAddHeader = () => {
const handleHeadersChange = useCallback((updatedHeaders) => {
if (!editMode) {
return;
}
dispatch(addResponseExampleHeader({
itemUid: item.uid,
collectionUid: collection.uid,
exampleUid: exampleUid
}));
};
// Check if content-type header was updated
const contentTypeHeader = updatedHeaders.find((h) => h.name?.toLowerCase() === 'content-type');
const oldContentTypeHeader = headers.find((h) => h.name?.toLowerCase() === 'content-type');
const handleHeaderValueChange = (e, header, type) => {
if (!editMode) {
return;
}
const updatedHeader = { ...header };
switch (type) {
case 'name': {
updatedHeader.name = e.target.value;
break;
}
case 'value': {
updatedHeader.value = e.target.value;
break;
}
}
dispatch(updateResponseExampleHeader({
itemUid: item.uid,
collectionUid: collection.uid,
exampleUid: exampleUid,
header: updatedHeader
}));
// If content-type header is being updated, automatically update the body type
if (header.name?.toLowerCase() === 'content-type' && type === 'value') {
const newContentType = updatedHeader.value?.toLowerCase() || '';
if (contentTypeHeader && oldContentTypeHeader && contentTypeHeader.value !== oldContentTypeHeader.value) {
const newContentType = contentTypeHeader.value?.toLowerCase() || '';
const newBodyType = getBodyType(newContentType);
const currentBodyType = response.body?.type || 'text';
@@ -85,22 +55,16 @@ const ResponseExampleResponseHeaders = ({ editMode, item, collection, exampleUid
}));
}
}
};
const handleRemoveHeader = (header) => {
if (!editMode) {
return;
}
dispatch(deleteResponseExampleHeader({
dispatch(setResponseExampleHeaders({
itemUid: item.uid,
collectionUid: collection.uid,
exampleUid: exampleUid,
headerUid: header.uid
headers: updatedHeaders
}));
};
}, [editMode, dispatch, item.uid, collection.uid, exampleUid, headers, response]);
const handleHeaderDrag = ({ updateReorderedItem }) => {
const handleHeaderDrag = useCallback(({ updateReorderedItem }) => {
if (!editMode) {
return;
}
@@ -111,7 +75,7 @@ const ResponseExampleResponseHeaders = ({ editMode, item, collection, exampleUid
exampleUid: exampleUid,
updateReorderedItem
}));
};
}, [editMode, dispatch, item.uid, collection.uid, exampleUid]);
const toggleBulkEditMode = () => {
setIsBulkEditMode(!isBulkEditMode);
@@ -152,79 +116,71 @@ const ResponseExampleResponseHeaders = ({ editMode, item, collection, exampleUid
);
}
const columns = [
{
key: 'name',
name: 'Key',
isKeyField: true,
placeholder: 'Key',
width: '40%',
readOnly: !editMode,
render: ({ row, value, onChange, isLastEmptyRow }) => (
<SingleLineEditor
value={value || ''}
theme={storedTheme}
onSave={() => {}}
onChange={(newValue) => onChange(newValue.replace(/[\r\n]/g, ''))}
autocomplete={headerAutoCompleteList}
onRun={() => {}}
collection={collection}
readOnly={!editMode}
placeholder={isLastEmptyRow ? 'Key' : ''}
/>
)
},
{
key: 'value',
name: 'Value',
placeholder: 'Value',
width: '60%',
readOnly: !editMode,
render: ({ row, value, onChange, isLastEmptyRow }) => (
<SingleLineEditor
value={value || ''}
theme={storedTheme}
onSave={() => {}}
onChange={onChange}
onRun={() => {}}
autocomplete={MimeTypes}
allowNewlines={true}
collection={collection}
item={item}
readOnly={!editMode}
placeholder={isLastEmptyRow ? 'Value' : ''}
/>
)
}
];
const defaultRow = {
name: '',
value: ''
};
return (
<StyledWrapper className="w-full px-4">
<Table
headers={[
{ name: 'Key', accessor: 'key', width: '40%' },
{ name: 'Value', accessor: 'value', width: '60%' }
]}
>
<ReorderTable updateReorderedItem={handleHeaderDrag}>
{headers && headers.length
? headers.map((header) => (
<tr key={header.uid} data-uid={header.uid}>
<td className="flex relative">
<SingleLineEditor
value={header.name || ''}
theme={storedTheme}
onSave={() => {}}
onChange={(newValue) =>
handleHeaderValueChange({
target: {
value: newValue
}
},
header,
'name')}
autocomplete={headerAutoCompleteList}
onRun={() => {}}
collection={collection}
readOnly={!editMode}
/>
</td>
<td>
<div className="flex items-center justify-center pl-4">
<SingleLineEditor
value={header.value || ''}
theme={storedTheme}
onSave={() => {}}
onChange={(newValue) =>
handleHeaderValueChange({
target: {
value: newValue
}
},
header,
'value')}
onRun={() => {}}
autocomplete={MimeTypes}
allowNewlines={true}
collection={collection}
item={item}
readOnly={!editMode}
/>
{editMode && (
<button tabIndex="-1" onClick={() => handleRemoveHeader(header)} className="delete-button">
<IconTrash strokeWidth={1.5} size={16} />
</button>
)}
</div>
</td>
</tr>
))
: null}
</ReorderTable>
</Table>
<EditableTable
columns={columns}
rows={headers || []}
onChange={handleHeadersChange}
defaultRow={defaultRow}
reorderable={editMode}
onReorder={handleHeaderDrag}
showAddRow={editMode}
showCheckbox={false}
/>
{editMode && (
<div className="flex justify-between mt-2 flex-shrink-0">
<button
className="btn-action text-link pr-2 py-3 select-none"
onClick={handleAddHeader}
>
+ Add Header
</button>
<div className="flex justify-end mt-2 flex-shrink-0">
<button
className="btn-action text-link select-none"
onClick={toggleBulkEditMode}

View File

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

View File

@@ -5,13 +5,14 @@ const StyledWrapper = styled.div`
.menu-icon {
color: ${(props) => props.theme.sidebar.dropdownIcon.color};
visibility: hidden;
.dropdown {
div[aria-expanded='true'] {
visibility: visible;
}
div[aria-expanded='false'] {
visibility: hidden;
visibility: visible;
}
}
}
@@ -36,11 +37,7 @@ const StyledWrapper = styled.div`
&.item-hovered {
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
.menu-icon {
.dropdown {
div[aria-expanded='false'] {
visibility: visible;
}
}
visibility: visible;
}
}

View File

@@ -1,4 +1,4 @@
import React, { useState, useRef, forwardRef, useEffect } from 'react';
import React, { useState, useRef, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { addTab, makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
import {
@@ -8,13 +8,16 @@ import {
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { insertTaskIntoQueue } from 'providers/ReduxStore/slices/app';
import { uuid } from 'utils/common';
import { IconDots } from '@tabler/icons';
import { IconDots, IconEdit, IconCopy, IconTrash, IconCode } from '@tabler/icons';
import ExampleIcon from 'components/Icons/ExampleIcon';
import range from 'lodash/range';
import classnames from 'classnames';
import Dropdown from 'components/Dropdown';
import MenuDropdown from 'ui/MenuDropdown';
import ActionIcon from 'ui/ActionIcon';
import Modal from 'components/Modal';
import DeleteResponseExampleModal from './DeleteResponseExampleModal';
import GenerateCodeItem from '../GenerateCodeItem';
import toast from 'react-hot-toast';
import StyledWrapper from './StyledWrapper';
const ExampleItem = ({ example, item, collection }) => {
@@ -25,8 +28,9 @@ const ExampleItem = ({ example, item, collection }) => {
const [editName, setEditName] = useState(example.name);
const [showRenameModal, setShowRenameModal] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const dropdownTippyRef = useRef(null);
const [generateCodeItemModalOpen, setGenerateCodeItemModalOpen] = useState(false);
const exampleRef = useRef(null);
const menuDropdownRef = useRef(null);
// Calculate indentation: item depth + 1 for examples
const indents = range((item.depth || 0) + 1);
@@ -48,9 +52,6 @@ const ExampleItem = ({ example, item, collection }) => {
const handleRename = () => {
setEditName(example.name); // Set current name when opening modal
setShowRenameModal(true);
if (dropdownTippyRef.current) {
dropdownTippyRef.current.hide();
}
};
// Update editName when example changes
@@ -93,26 +94,22 @@ const ExampleItem = ({ example, item, collection }) => {
itemUid: item.uid,
exampleIndex: clonedExampleIndex
}));
if (dropdownTippyRef.current) {
dropdownTippyRef.current.hide();
}
};
const handleDelete = () => {
setShowDeleteModal(true);
if (dropdownTippyRef.current) {
dropdownTippyRef.current.hide();
}
};
const handleRightClick = (e) => {
e.preventDefault();
e.stopPropagation();
// Show the dropdown menu programmatically
if (dropdownTippyRef.current) {
dropdownTippyRef.current.show();
const handleGenerateCode = () => {
// Check if example has a request URL
if (
(example?.request?.url !== '' && example?.request?.url !== undefined)
|| (item?.request?.url !== '' && item?.request?.url !== undefined)
|| (item?.draft?.request?.url !== undefined && item?.draft?.request?.url !== '')
) {
setGenerateCodeItemModalOpen(true);
} else {
toast.error('URL is required');
}
};
@@ -130,17 +127,48 @@ const ExampleItem = ({ example, item, collection }) => {
setShowRenameModal(false);
};
const onDropdownCreate = (instance) => {
dropdownTippyRef.current = instance;
// Build menu items for MenuDropdown
const buildMenuItems = () => {
return [
{
id: 'rename',
leftSection: IconEdit,
label: 'Rename',
onClick: handleRename,
testId: 'response-example-rename-option'
},
{
id: 'clone',
leftSection: IconCopy,
label: 'Clone',
onClick: handleClone,
testId: 'response-example-clone-option'
},
{
id: 'generate-code',
leftSection: IconCode,
label: 'Generate Code',
onClick: handleGenerateCode,
testId: 'response-example-generate-code-option'
},
{ id: 'separator-1', type: 'divider' },
{
id: 'delete',
leftSection: IconTrash,
label: 'Delete',
className: 'delete-item',
onClick: handleDelete,
testId: 'response-example-delete-option'
}
];
};
const MenuIcon = forwardRef((props, ref) => {
return (
<div ref={ref} data-testid="response-example-menu-icon">
<IconDots size={22} />
</div>
);
});
// Handle right-click context menu
const handleContextMenu = (e) => {
e.preventDefault();
e.stopPropagation();
menuDropdownRef.current?.open();
};
const itemRowClassName = classnames('flex collection-item-name relative items-center', {
'item-focused-in-tab': isExampleActive
@@ -152,7 +180,7 @@ const ExampleItem = ({ example, item, collection }) => {
className={itemRowClassName}
onClick={handleExampleClick}
onDoubleClick={handleDoubleClick}
onContextMenu={handleRightClick}
onContextMenu={handleContextMenu}
>
{indents && indents.length
? indents.map((i) => (
@@ -160,7 +188,6 @@ const ExampleItem = ({ example, item, collection }) => {
className="indent-block"
key={i}
style={{ width: 16, minWidth: 16, height: '100%' }}
onContextMenu={handleRightClick}
>
&nbsp;{/* Indent */}
</div>
@@ -169,45 +196,20 @@ const ExampleItem = ({ example, item, collection }) => {
<div
className="flex flex-grow items-center h-full overflow-hidden"
style={{ paddingLeft: 8 }}
onContextMenu={handleRightClick}
>
<div style={{ width: 16, minWidth: 16 }}></div>
<ExampleIcon size={16} color="currentColor" className="mr-2 text-gray-400 flex-shrink-0" />
<span className="item-name truncate text-gray-700 dark:text-gray-300 ">{example.name}</span>
</div>
<div className="menu-icon pr-2">
<Dropdown onCreate={onDropdownCreate} icon={<MenuIcon />} placement="bottom-start">
<div
className="dropdown-item"
onClick={(e) => {
dropdownTippyRef.current.hide();
handleRename();
}}
data-testid="response-example-rename-option"
>
Rename
</div>
<div
className="dropdown-item"
onClick={(e) => {
dropdownTippyRef.current.hide();
handleClone();
}}
data-testid="response-example-clone-option"
>
Clone
</div>
<div
className="dropdown-item text-red-600"
onClick={(e) => {
dropdownTippyRef.current.hide();
handleDelete();
}}
data-testid="response-example-delete-option"
>
Delete
</div>
</Dropdown>
<MenuDropdown
ref={menuDropdownRef}
items={buildMenuItems()}
placement="bottom-start"
data-testid="response-example-menu"
>
<IconDots size={22} data-testid="response-example-menu-icon" />
</MenuDropdown>
</div>
{showRenameModal && (
@@ -250,6 +252,16 @@ const ExampleItem = ({ example, item, collection }) => {
collection={collection}
/>
)}
{generateCodeItemModalOpen && (
<GenerateCodeItem
collectionUid={collection.uid}
item={item}
onClose={() => setGenerateCodeItemModalOpen(false)}
isExample={true}
exampleUid={example.uid}
/>
)}
</StyledWrapper>
);
};

View File

@@ -4,13 +4,14 @@ const Wrapper = styled.div`
position: relative;
.menu-icon {
color: ${(props) => props.theme.sidebar.dropdownIcon.color};
visibility: hidden;
.dropdown {
div[aria-expanded='true'] {
visibility: visible;
}
div[aria-expanded='false'] {
visibility: hidden;
visibility: visible;
}
}
}
@@ -97,11 +98,7 @@ const Wrapper = styled.div`
&.item-hovered {
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
.menu-icon {
.dropdown {
div[aria-expanded='false'] {
visibility: visible;
}
}
visibility: visible;
}
}

View File

@@ -1,4 +1,4 @@
import React, { useState, useRef, forwardRef, useEffect } from 'react';
import React, { useState, useRef, useEffect } from 'react';
import { getEmptyImage } from 'react-dnd-html5-backend';
import range from 'lodash/range';
import filter from 'lodash/filter';
@@ -14,7 +14,6 @@ import {
IconCopy,
IconClipboard,
IconCode,
IconPhoto,
IconFolder,
IconTrash,
IconSettings,
@@ -28,7 +27,7 @@ import { toggleCollectionItem, addResponseExample } from 'providers/ReduxStore/s
import { insertTaskIntoQueue } from 'providers/ReduxStore/slices/app';
import { uuid } from 'utils/common';
import { copyRequest } from 'providers/ReduxStore/slices/app';
import Dropdown from 'components/Dropdown';
import MenuDropdown from 'ui/MenuDropdown';
import NewRequest from 'components/Sidebar/NewRequest';
import NewFolder from 'components/Sidebar/NewFolder';
import RenameCollectionItem from './RenameCollectionItem';
@@ -46,6 +45,7 @@ import NetworkError from 'components/ResponsePane/NetworkError/index';
import CollectionItemInfo from './CollectionItemInfo/index';
import CollectionItemIcon from './CollectionItemIcon';
import ExampleItem from './ExampleItem';
import ExampleIcon from 'components/Icons/ExampleIcon';
import { scrollToTheActiveTab } from 'utils/tabs';
import { isTabForItemActive as isTabForItemActiveSelector, isTabForItemPresent as isTabForItemPresentSelector } from 'src/selectors/tab';
import { isEqual } from 'lodash';
@@ -68,6 +68,7 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
// We use a single ref for drag and drop.
const ref = useRef(null);
const menuDropdownRef = useRef(null);
const [renameItemModalOpen, setRenameItemModalOpen] = useState(false);
const [cloneItemModalOpen, setCloneItemModalOpen] = useState(false);
@@ -182,15 +183,6 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
})
});
const dropdownTippyRef = useRef();
const MenuIcon = forwardRef((props, ref) => {
return (
<div ref={ref}>
<IconDots size={22} />
</div>
);
});
const iconClassName = classnames({
'rotate-90': !itemIsCollapsed
});
@@ -289,19 +281,138 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
e.preventDefault();
};
const handleRightClick = (event) => {
const _menuDropdown = dropdownTippyRef.current;
if (_menuDropdown) {
let menuDropdownBehavior = 'show';
if (_menuDropdown.state.isShown) {
menuDropdownBehavior = 'hide';
}
_menuDropdown[menuDropdownBehavior]();
}
// Handle right-click context menu
const handleContextMenu = (e) => {
e.preventDefault();
e.stopPropagation();
menuDropdownRef.current?.open();
};
let indents = range(item.depth);
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
// Build menu items for MenuDropdown
const buildMenuItems = () => {
const items = [
{
id: 'info',
leftSection: IconInfoCircle,
label: 'Info',
onClick: () => setItemInfoModalOpen(true)
}
];
if (isFolder) {
items.push(
{
id: 'new-request',
leftSection: IconFilePlus,
label: 'New Request',
onClick: () => setNewRequestModalOpen(true)
},
{
id: 'new-folder',
leftSection: IconFolderPlus,
label: 'New Folder',
onClick: () => setNewFolderModalOpen(true)
},
{
id: 'run',
leftSection: IconPlayerPlay,
label: 'Run',
onClick: () => setRunCollectionModalOpen(true)
}
);
}
items.push(
{
id: 'clone',
leftSection: IconCopy,
label: 'Clone',
onClick: () => setCloneItemModalOpen(true)
},
{
id: 'copy',
leftSection: IconCopy,
label: 'Copy',
onClick: handleCopyItem
}
);
if (isFolder && hasCopiedItems) {
items.push({
id: 'paste',
leftSection: IconClipboard,
label: 'Paste',
onClick: handlePasteItem
});
}
items.push(
{
id: 'rename',
leftSection: IconEdit,
label: 'Rename',
onClick: () => setRenameItemModalOpen(true)
},
{
id: 'show-in-folder',
leftSection: IconFolder,
label: 'Show in Folder',
onClick: handleShowInFolder
}
);
if (!isFolder && (item.type === 'http-request' || item.type === 'graphql-request')) {
items.push({
id: 'generate-code',
leftSection: IconCode,
label: 'Generate Code',
onClick: handleGenerateCode
});
}
if (!isFolder && isItemARequest(item) && item.type === 'http-request') {
items.push({
id: 'create-example',
leftSection: ExampleIcon,
label: 'Create Example',
onClick: () => setCreateExampleModalOpen(true)
});
}
items.push({ id: 'separator-1', type: 'divider' });
if (isFolder) {
items.push(
{
id: 'settings',
leftSection: IconSettings,
label: 'Settings',
onClick: viewFolderSettings
},
{
id: 'open-terminal',
leftSection: IconTerminal2,
label: 'Open in Terminal',
onClick: async () => {
const folderCwd = item.pathname || collectionPathname;
await openDevtoolsAndSwitchToTerminal(dispatch, folderCwd);
}
}
);
}
items.push({
id: 'delete',
leftSection: IconTrash,
label: 'Delete',
className: 'delete-item',
onClick: () => setDeleteItemModalOpen(true)
});
return items;
};
const className = classnames('flex flex-col w-full', {
'is-sidebar-dragging': isSidebarDragging
@@ -382,9 +493,7 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
const folderItems = sortByNameThenSequence(filter(item.items, (i) => isItemAFolder(i)));
const requestItems = sortItemsBySequence(filter(item.items, (i) => isItemARequest(i)));
const handleGenerateCode = (e) => {
e.stopPropagation();
dropdownTippyRef.current.hide();
const handleGenerateCode = () => {
if (
(item?.request?.url !== '')
|| (item?.draft?.request?.url !== undefined && item?.draft?.request?.url !== '')
@@ -412,15 +521,12 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
};
const handleCopyItem = () => {
dropdownTippyRef.current.hide();
dispatch(copyRequest(item));
const itemType = isFolder ? 'Folder' : 'Request';
toast.success(`${itemType} copied to clipboard`);
};
const handlePasteItem = () => {
dropdownTippyRef.current.hide();
// Only allow paste into folders
if (!isFolder) {
toast.error('Paste is only available for folders');
@@ -504,6 +610,7 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
onKeyDown={handleKeyDown}
onFocus={handleFocus}
onBlur={handleBlur}
onContextMenu={handleContextMenu}
data-testid="sidebar-collection-item-row"
>
<div className="flex items-center h-full w-full">
@@ -511,7 +618,6 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
? indents.map((i) => (
<div
onClick={handleClick}
onContextMenu={handleRightClick}
onDoubleClick={handleDoubleClick}
className="indent-block"
key={i}
@@ -525,7 +631,6 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
className="flex flex-grow items-center h-full overflow-hidden"
style={{ paddingLeft: 8 }}
onClick={handleClick}
onContextMenu={handleRightClick}
onDoubleClick={handleDoubleClick}
>
<div style={{ width: 16, minWidth: 16 }}>
@@ -559,186 +664,14 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
</div>
</div>
<div className="menu-icon pr-2">
<Dropdown onCreate={onDropdownCreate} icon={<MenuIcon />} placement="bottom-start">
<div
className="dropdown-item"
onClick={(e) => {
dropdownTippyRef.current.hide();
setItemInfoModalOpen(true);
}}
>
<span className="dropdown-icon">
<IconInfoCircle size={16} strokeWidth={2} />
</span>
Info
</div>
{isFolder && (
<>
<div
className="dropdown-item"
onClick={(e) => {
dropdownTippyRef.current.hide();
setNewRequestModalOpen(true);
}}
>
<span className="dropdown-icon">
<IconFilePlus size={16} strokeWidth={2} />
</span>
New Request
</div>
<div
className="dropdown-item"
onClick={(e) => {
dropdownTippyRef.current.hide();
setNewFolderModalOpen(true);
}}
>
<span className="dropdown-icon">
<IconFolderPlus size={16} strokeWidth={2} />
</span>
New Folder
</div>
<div
className="dropdown-item"
onClick={(e) => {
dropdownTippyRef.current.hide();
setRunCollectionModalOpen(true);
}}
>
<span className="dropdown-icon">
<IconPlayerPlay size={16} strokeWidth={2} />
</span>
Run
</div>
</>
)}
<div
className="dropdown-item"
onClick={(e) => {
dropdownTippyRef.current.hide();
setCloneItemModalOpen(true);
}}
>
<span className="dropdown-icon">
<IconCopy size={16} strokeWidth={2} />
</span>
Clone
</div>
<div
className="dropdown-item"
onClick={handleCopyItem}
>
<span className="dropdown-icon">
<IconCopy size={16} strokeWidth={2} />
</span>
Copy
</div>
{isFolder && hasCopiedItems && (
<div
className="dropdown-item"
onClick={handlePasteItem}
>
<span className="dropdown-icon">
<IconClipboard size={16} strokeWidth={2} />
</span>
Paste
</div>
)}
<div
className="dropdown-item"
onClick={(e) => {
dropdownTippyRef.current.hide();
setRenameItemModalOpen(true);
}}
>
<span className="dropdown-icon">
<IconEdit size={16} strokeWidth={2} />
</span>
Rename
</div>
<div
className="dropdown-item"
onClick={(e) => {
dropdownTippyRef.current.hide();
handleShowInFolder();
}}
>
<span className="dropdown-icon">
<IconFolder size={16} strokeWidth={2} />
</span>
Show in Folder
</div>
{!isFolder && (item.type === 'http-request' || item.type === 'graphql-request') && (
<div
className="dropdown-item"
onClick={(e) => {
handleGenerateCode(e);
}}
>
<span className="dropdown-icon">
<IconCode size={16} strokeWidth={2} />
</span>
Generate Code
</div>
)}
{!isFolder && isItemARequest(item) && item.type === 'http-request' && (
<div
className="dropdown-item"
onClick={(e) => {
dropdownTippyRef.current.hide();
setCreateExampleModalOpen(true);
}}
>
<span className="dropdown-icon">
<IconPhoto size={16} strokeWidth={2} />
</span>
Create Example
</div>
)}
<div className="dropdown-separator"></div>
{isFolder && (
<div
className="dropdown-item"
onClick={(e) => {
dropdownTippyRef.current.hide();
viewFolderSettings();
}}
>
<span className="dropdown-icon">
<IconSettings size={16} strokeWidth={2} />
</span>
Settings
</div>
)}
{isFolder && (
<div
className="dropdown-item"
onClick={async (e) => {
dropdownTippyRef.current.hide();
// Get folder pathname
const folderCwd = item.pathname || collectionPathname;
await openDevtoolsAndSwitchToTerminal(dispatch, folderCwd);
}}
>
<span className="dropdown-icon">
<IconTerminal2 size={16} strokeWidth={2} />
</span>
Open in Terminal
</div>
)}
<div
className="dropdown-item delete-item"
onClick={(e) => {
dropdownTippyRef.current.hide();
setDeleteItemModalOpen(true);
}}
>
<span className="dropdown-icon">
<IconTrash size={16} strokeWidth={2} />
</span>
Delete
</div>
</Dropdown>
<MenuDropdown
ref={menuDropdownRef}
items={buildMenuItems()}
placement="bottom-start"
data-testid="collection-item-menu"
>
<IconDots size={22} />
</MenuDropdown>
</div>
</div>
</div>

View File

@@ -329,8 +329,8 @@ export const setResponseExampleHeaders = (state, action) => {
const example = item.draft.examples.find((e) => e.uid === exampleUid);
if (!example) return;
example.response.headers = map(headers, ({ name = '', value = '', enabled = true }) => ({
uid: uuid(),
example.response.headers = map(headers, ({ uid, name = '', value = '', enabled = true }) => ({
uid: uid || uuid(),
name: name,
value: value,
description: '',
@@ -921,8 +921,8 @@ export const setResponseExampleRequestHeaders = (state, action) => {
const example = item.draft.examples.find((e) => e.uid === exampleUid);
if (!example) return;
example.request.headers = map(headers, ({ name = '', value = '', enabled = true }) => ({
uid: uuid(),
example.request.headers = map(headers, ({ uid, name = '', value = '', enabled = true }) => ({
uid: uid || uuid(),
name: name,
value: value,
description: '',
@@ -950,14 +950,36 @@ export const setResponseExampleParams = (state, action) => {
const example = item.draft.examples.find((e) => e.uid === exampleUid);
if (!example) return;
example.request.params = map(params, ({ name = '', value = '', enabled = true, type = 'query' }) => ({
uid: uuid(),
example.request.params = map(params, ({ uid, name = '', value = '', enabled = true, type = 'query' }) => ({
uid: uid || uuid(),
name: name,
value: value,
description: '',
enabled: enabled,
type: type
}));
// Update URL when query parameters change
const queryParams = filter(example.request.params, (p) => p.enabled && p.type === 'query');
const query = stringifyQueryParams(queryParams);
if (!example.request.url) {
example.request.url = '';
}
const parts = splitOnFirst(example.request.url, '?');
if (!query || !query.length) {
if (parts.length) {
example.request.url = parts[0];
}
} else {
if (!parts.length) {
example.request.url += '?' + query;
} else {
example.request.url = parts[0] + '?' + query;
}
}
};
// Response Example Body Types

View File

@@ -17,8 +17,15 @@ const StyledWrapper = styled.div`
/* flex container - enforces boundaries */
.flex-boundary {
width: 100%;
min-width: 0;
display: flex;
flex-direction: column;
overflow-y: auto;
> * {
flex: 1 1 0;
min-height: 0;
}
}
`;

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { forwardRef, useImperativeHandle } from 'react';
import { useRef, useCallback, useState } from 'react';
import Dropdown from 'components/Dropdown';
@@ -36,8 +36,9 @@ const getNextIndex = (currentIndex, total, key, noFocus) => {
* @param {string} props.className - Optional className for the dropdown
* @param {string} props.selectedItemId - Optional ID of the selected/active item to focus on open
* @param {Object} props.dropdownProps - Other props passed to underlying Dropdown component
* @param {React.Ref} ref - Optional ref to expose open/close methods
*/
const MenuDropdown = ({
const MenuDropdown = forwardRef(({
items = [],
children,
placement = 'bottom-end',
@@ -45,10 +46,17 @@ const MenuDropdown = ({
selectedItemId,
'data-testid': testId = 'menu-dropdown',
...dropdownProps
}) => {
}, ref) => {
const tippyRef = useRef();
const [isOpen, setIsOpen] = useState(false);
// Expose open/close methods via ref
useImperativeHandle(ref, () => ({
open: () => setIsOpen(true),
close: () => setIsOpen(false),
toggle: () => setIsOpen((prev) => !prev)
}), []);
// Get all focusable menu items from the menu dropdown
const getMenuItems = useCallback(() => {
const popper = tippyRef.current?.popper;
@@ -274,6 +282,6 @@ const MenuDropdown = ({
</div>
</Dropdown>
);
};
});
export default MenuDropdown;

View File

@@ -60,7 +60,7 @@ test.describe('Draft values are used in requests', () => {
// Create a request in the collection
// Create a new request via collection menu
await folder.locator('.menu-icon').hover();
await folder.hover();
await folder.locator('.menu-icon').click();
await page.locator('.dropdown-item').filter({ hasText: 'New Request' }).click();

View File

@@ -11,7 +11,7 @@ test.describe('Copy and Paste Requests', () => {
// Create a new request
const collection = page.locator('.collection-name').filter({ hasText: 'test-collection' });
await collection.locator('.collection-actions').hover();
await collection.hover();
await collection.locator('.collection-actions .icon').click();
await page.locator('.dropdown-item').filter({ hasText: 'New Request' }).click();
await page.getByPlaceholder('Request Name').fill('original-request');
@@ -23,6 +23,7 @@ test.describe('Copy and Paste Requests', () => {
// Copy the request
const requestItem = page.locator('.collection-item-name').filter({ hasText: 'original-request' });
await requestItem.hover();
await requestItem.locator('.menu-icon').click();
await page.locator('.dropdown-item').filter({ hasText: 'Copy' }).click();
@@ -37,7 +38,7 @@ test.describe('Copy and Paste Requests', () => {
test('should paste request into a folder', async ({ page, createTmpDir }) => {
const collection = page.locator('.collection-name').filter({ hasText: 'test-collection' });
await collection.locator('.collection-actions').hover();
await collection.hover();
await collection.locator('.collection-actions .icon').click();
await page.locator('.dropdown-item').filter({ hasText: 'New Folder' }).click();
await page.locator('#folder-name').fill('test-folder');

View File

@@ -24,19 +24,19 @@ test.describe.serial('Response Example Menu Operations', () => {
await page.getByRole('button', { name: 'Create Example' }).click();
// Wait for modal to close
await page.waitForSelector('text=Save Response as Example', { state: 'detached' });
await page.locator('.collection-item-name', { hasText: 'menu-operations' }).getByTestId('request-item-chevron').click();
await page.locator('.collection-item-name').filter({ hasText: 'menu-operations' }).getByTestId('request-item-chevron').click();
const exampleItem = page.locator('.collection-item-name').getByText('Example to Clone');
await expect(exampleItem).toBeVisible();
const exampleRow = page.locator('.collection-item-name').filter({ hasText: 'Example to Clone' });
await expect(exampleRow).toBeVisible();
});
await test.step('Clone example', async () => {
const exampleItem = page.locator('.collection-item-name').getByText('Example to Clone');
await exampleItem.hover();
await page.getByTestId('response-example-menu-icon').last().click();
const exampleRow = page.locator('.collection-item-name').filter({ hasText: 'Example to Clone' });
await exampleRow.hover();
await exampleRow.locator('.menu-icon').click();
await page.getByTestId('response-example-clone-option').click();
const clonedExampleItem = page.locator('.collection-item-name').getByText('Example to Clone (Copy)');
await page.getByTestId('response-example-menu-clone').click();
const clonedExampleItem = page.locator('.collection-item-name').filter({ hasText: 'Example to Clone (Copy)' });
await expect(clonedExampleItem).toBeVisible();
});
});
@@ -57,20 +57,20 @@ test.describe.serial('Response Example Menu Operations', () => {
// Wait for modal to close
await page.waitForSelector('text=Save Response as Example', { state: 'detached' });
const exampleItem = page.locator('.collection-item-name').getByText('Example to Delete', { exact: true });
await expect(exampleItem).toBeVisible();
const exampleRow = page.locator('.collection-item-name').filter({ hasText: 'Example to Delete' });
await expect(exampleRow).toBeVisible();
});
await test.step('Delete example', async () => {
const exampleItem = page.locator('.collection-item-name').getByText('Example to Delete', { exact: true });
await expect(exampleItem).toBeVisible();
await exampleItem.hover();
await page.getByTestId('response-example-menu-icon').last().click();
const exampleRow = page.locator('.collection-item-name').filter({ hasText: 'Example to Delete' });
await expect(exampleRow).toBeVisible();
await exampleRow.hover();
await exampleRow.locator('.menu-icon').click();
await page.getByTestId('response-example-delete-option').click();
await page.getByTestId('response-example-menu-delete').click();
await expect(page.getByText('Delete Example')).toBeVisible();
await page.getByRole('button', { name: 'Delete' }).click();
await expect(exampleItem).not.toBeVisible();
await expect(exampleRow).not.toBeVisible();
});
});
@@ -90,16 +90,16 @@ test.describe.serial('Response Example Menu Operations', () => {
// Wait for modal to close
await page.waitForSelector('text=Save Response as Example', { state: 'detached' });
const exampleItem = page.locator('.collection-item-name').getByText('Example to Rename', { exact: true });
await expect(exampleItem).toBeVisible();
const exampleRow = page.locator('.collection-item-name').filter({ hasText: 'Example to Rename' });
await expect(exampleRow).toBeVisible();
});
await test.step('Rename example', async () => {
const exampleItem = page.locator('.collection-item-name').getByText('Example to Rename', { exact: true });
await expect(exampleItem).toBeVisible();
await exampleItem.hover();
await page.getByTestId('response-example-menu-icon').last().click();
await page.getByTestId('response-example-rename-option').click();
const exampleRow = page.locator('.collection-item-name').filter({ hasText: 'Example to Rename' });
await expect(exampleRow).toBeVisible();
await exampleRow.hover();
await exampleRow.locator('.menu-icon').click();
await page.getByTestId('response-example-menu-rename').click();
await expect(page.getByText('Rename Example')).toBeVisible();
const renameExampleNameInput = page.getByTestId('rename-example-name-input');
await renameExampleNameInput.clear();
@@ -107,10 +107,9 @@ test.describe.serial('Response Example Menu Operations', () => {
await page.getByRole('button', { name: 'Rename' }).click();
// Wait for modal to close
await page.waitForSelector('text=Rename Example', { state: 'detached' });
const updatedExampleItem = page.locator('.collection-item-name').getByText('Renamed Example', { exact: true });
await expect(exampleItem).not.toBeVisible();
await expect(updatedExampleItem).toBeVisible();
await expect(updatedExampleItem).toHaveText('Renamed Example');
const updatedExampleRow = page.locator('.collection-item-name').filter({ hasText: 'Renamed Example' });
await expect(exampleRow).not.toBeVisible();
await expect(updatedExampleRow).toBeVisible();
});
});
});

View File

@@ -217,6 +217,7 @@ const deleteRequest = async (page, requestName: string, collectionName: string)
const collectionWrapper = collectionContainer.locator('..');
const request = collectionWrapper.locator('.collection-item-name').filter({ hasText: requestName });
await request.hover();
await request.locator('.menu-icon').click();
await locators.dropdown.item('Delete').click();
await locators.modal.button('Delete').click();