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

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}
/>
</>
)}