mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-11 09:51:30 +00:00
feat: multi-file upload in multipart form body (#7971)
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
|
||||
.file-chips-row {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.file-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 6px;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
color: ${(props) => props.theme.text};
|
||||
max-width: 140px;
|
||||
min-width: 75px;
|
||||
flex: 0 1 auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.file-chip-icon {
|
||||
flex: 0 0 auto;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
.file-chip-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.file-chip-remove {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
flex: 0 0 auto;
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => props.theme.colors.text.danger};
|
||||
}
|
||||
}
|
||||
|
||||
.file-more-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 4px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
color: ${(props) => props.theme.primary.text};
|
||||
cursor: pointer;
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => props.theme.primary.text};
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.file-summary-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 6px;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
color: ${(props) => props.theme.text};
|
||||
cursor: pointer;
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
|
||||
> span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
> svg {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:hover > span {
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: ${(props) => props.theme.colors.text.muted};
|
||||
background: ${(props) => props.theme.requestTabs.icon.hoverBg};
|
||||
}
|
||||
}
|
||||
|
||||
.upload-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: color 0.15s ease;
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const OverflowList = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 4px;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
min-width: 220px;
|
||||
max-width: 360px;
|
||||
|
||||
.overflow-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
color: ${(props) => props.theme.text};
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.requestTabs.icon.hoverBg};
|
||||
}
|
||||
}
|
||||
|
||||
.overflow-row-icon {
|
||||
flex: 0 0 auto;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
.overflow-row-name {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.overflow-row-remove {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
flex: 0 0 auto;
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => props.theme.colors.text.danger};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default Wrapper;
|
||||
@@ -0,0 +1,195 @@
|
||||
import React, { useLayoutEffect, useRef, useState } from 'react';
|
||||
import { IconUpload, IconX, IconFile, IconChevronDown } from '@tabler/icons';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import ToolHint from 'components/ToolHint';
|
||||
import path, { normalizePath } from 'utils/common/path';
|
||||
import Wrapper, { OverflowList } from './StyledWrapper';
|
||||
|
||||
const basename = (filePath) => (filePath ? path.basename(normalizePath(String(filePath))) : '');
|
||||
|
||||
// Keep in sync with the corresponding CSS values in StyledWrapper.js:
|
||||
// MIN_CHIP_W ↔ .file-chip { min-width: 75px }
|
||||
// CHIP_GAP ↔ .file-chips-row { gap: 4px }
|
||||
const MIN_CHIP_W = 75;
|
||||
const CHIP_GAP = 4;
|
||||
const UPLOAD_RESERVE = 28;
|
||||
const MORE_CHIP_RESERVE = 56;
|
||||
|
||||
const MultipartFileChipsCell = ({ files, onRemove, onAdd, editMode = true }) => {
|
||||
const containerRef = useRef(null);
|
||||
const tooltipPrefix = useRef(`mp-tip-${Math.random().toString(36).slice(2, 10)}`).current;
|
||||
const [visibleCount, setVisibleCount] = useState(files.length);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
// Measure the td (column-width, stable) rather than the content-sized cell,
|
||||
// which would feed back on visibleCount.
|
||||
const td = container.closest('td') || container.parentElement;
|
||||
if (!td) return;
|
||||
|
||||
const compute = () => {
|
||||
const tdStyle = window.getComputedStyle(td);
|
||||
const padX = parseFloat(tdStyle.paddingLeft) + parseFloat(tdStyle.paddingRight);
|
||||
const total = td.clientWidth - padX;
|
||||
if (files.length === 0) {
|
||||
setVisibleCount(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const allAtMin = files.length * MIN_CHIP_W + Math.max(0, files.length - 1) * CHIP_GAP;
|
||||
if (allAtMin + UPLOAD_RESERVE <= total) {
|
||||
setVisibleCount(files.length);
|
||||
return;
|
||||
}
|
||||
|
||||
const available = total - UPLOAD_RESERVE - MORE_CHIP_RESERVE;
|
||||
const n = Math.max(0, Math.floor((available + CHIP_GAP) / (MIN_CHIP_W + CHIP_GAP)));
|
||||
setVisibleCount(n);
|
||||
};
|
||||
|
||||
compute();
|
||||
const ro = new ResizeObserver(compute);
|
||||
ro.observe(td);
|
||||
return () => ro.disconnect();
|
||||
}, [files]);
|
||||
|
||||
const visible = files.slice(0, visibleCount);
|
||||
const overflow = files.slice(visibleCount);
|
||||
const collapsed = visibleCount === 0 && files.length > 0;
|
||||
|
||||
const renderChip = (filePath, idx) => (
|
||||
<ToolHint
|
||||
key={`${filePath}-${idx}`}
|
||||
text={filePath}
|
||||
toolhintId={`${tooltipPrefix}-chip-${idx}`}
|
||||
place="bottom-start"
|
||||
positionStrategy="fixed"
|
||||
delayShow={1000}
|
||||
className="file-chip"
|
||||
dataTestId="multipart-file-chip"
|
||||
>
|
||||
<IconFile size={14} stroke={1.5} className="file-chip-icon" />
|
||||
<span className="file-chip-name">
|
||||
{basename(filePath)}
|
||||
</span>
|
||||
{editMode && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="multipart-file-chip-remove"
|
||||
className="file-chip-remove"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove(filePath);
|
||||
}}
|
||||
title="Remove file"
|
||||
>
|
||||
<IconX size={13} stroke={1.5} />
|
||||
</button>
|
||||
)}
|
||||
</ToolHint>
|
||||
);
|
||||
|
||||
const renderOverflowList = (list) => (
|
||||
<OverflowList>
|
||||
{list.map((p, i) => (
|
||||
<ToolHint
|
||||
key={`o-${p}-${i}`}
|
||||
text={p}
|
||||
toolhintId={`${tooltipPrefix}-overflow-${i}`}
|
||||
place="bottom-start"
|
||||
positionStrategy="fixed"
|
||||
delayShow={1000}
|
||||
className="overflow-row"
|
||||
dataTestId="multipart-file-overflow-row"
|
||||
>
|
||||
<IconFile size={14} stroke={1.5} className="overflow-row-icon" />
|
||||
<span className="overflow-row-name">
|
||||
{basename(p)}
|
||||
</span>
|
||||
{editMode && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="multipart-file-overflow-remove"
|
||||
className="overflow-row-remove"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove(p);
|
||||
}}
|
||||
title="Remove file"
|
||||
>
|
||||
<IconX size={13} stroke={1.5} />
|
||||
</button>
|
||||
)}
|
||||
</ToolHint>
|
||||
))}
|
||||
</OverflowList>
|
||||
);
|
||||
|
||||
return (
|
||||
<Wrapper className="file-value-cell" ref={containerRef}>
|
||||
{collapsed ? (
|
||||
<>
|
||||
<Dropdown
|
||||
placement="bottom-start"
|
||||
appendTo={() => document.body}
|
||||
icon={(
|
||||
<button
|
||||
type="button"
|
||||
data-testid="multipart-file-summary"
|
||||
className="file-summary-chip"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
title={`${files.length} file${files.length > 1 ? 's' : ''}`}
|
||||
>
|
||||
<IconFile size={14} stroke={1.5} className="file-chip-icon" />
|
||||
<span>{files.length} file{files.length > 1 ? 's' : ''}</span>
|
||||
<IconChevronDown size={14} stroke={1.5} />
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
{renderOverflowList(files)}
|
||||
</Dropdown>
|
||||
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="file-chips-row">
|
||||
{visible.map((p, i) => renderChip(p, i))}
|
||||
</div>
|
||||
{overflow.length > 0 && (
|
||||
<Dropdown
|
||||
placement="bottom-end"
|
||||
appendTo={() => document.body}
|
||||
icon={(
|
||||
<button
|
||||
type="button"
|
||||
data-testid="multipart-file-more"
|
||||
className="file-more-chip"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
title={`${overflow.length} more file${overflow.length > 1 ? 's' : ''}`}
|
||||
>
|
||||
+{overflow.length} more
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
{renderOverflowList(overflow)}
|
||||
</Dropdown>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{editMode && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="multipart-file-upload"
|
||||
className="upload-btn ml-1"
|
||||
onClick={onAdd}
|
||||
title="Add files"
|
||||
>
|
||||
<IconUpload size={16} />
|
||||
</button>
|
||||
)}
|
||||
</Wrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default MultipartFileChipsCell;
|
||||
@@ -13,6 +13,7 @@ const Wrapper = styled.div`
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: color 0.15s ease;
|
||||
flex: 0 0 auto;
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => props.theme.text};
|
||||
@@ -23,15 +24,6 @@ const Wrapper = styled.div`
|
||||
color: ${(props) => props.theme.colors.text.danger};
|
||||
}
|
||||
|
||||
.file-value-cell {
|
||||
width: 100%;
|
||||
|
||||
.file-name {
|
||||
font-size: 12px;
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
}
|
||||
|
||||
.value-cell {
|
||||
width: 100%;
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { useCallback, useRef } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { IconUpload, IconX, IconFile } from '@tabler/icons';
|
||||
import { IconUpload } from '@tabler/icons';
|
||||
import {
|
||||
moveMultipartFormParam,
|
||||
setMultipartFormParams
|
||||
@@ -10,14 +11,18 @@ import {
|
||||
import { browseFiles } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import MultiLineEditor from 'components/MultiLineEditor';
|
||||
import SingleLineEditor from 'components/SingleLineEditor';
|
||||
import MultipartFileChipsCell from 'components/MultipartFileChipsCell';
|
||||
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
|
||||
import EditableTable from 'components/EditableTable';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { getRelativePathWithinBasePath } from 'utils/common/path';
|
||||
import path, { getRelativePathWithinBasePath, normalizePath } from 'utils/common/path';
|
||||
import { getMultipartAutoContentType } from 'utils/common/multipartContentType';
|
||||
import { usePersistedState } from 'hooks/usePersistedState';
|
||||
import { useTrackScroll } from 'hooks/useTrackScroll';
|
||||
import { isWindowsOS } from 'utils/common/platform';
|
||||
|
||||
const fileBasename = (filePath) =>
|
||||
filePath ? path.basename(normalizePath(String(filePath))) : '';
|
||||
|
||||
const MultipartFormParams = ({ item, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -57,8 +62,10 @@ const MultipartFormParams = ({ item, collection }) => {
|
||||
}, [dispatch, collection.uid, item.uid]);
|
||||
|
||||
const handleBrowseFiles = useCallback((row, onChange) => {
|
||||
dispatch(browseFiles())
|
||||
dispatch(browseFiles([], ['multiSelections']))
|
||||
.then((filePaths) => {
|
||||
if (!Array.isArray(filePaths) || filePaths.length === 0) return;
|
||||
|
||||
const processedPaths = filePaths.map((filePath) => {
|
||||
return getRelativePathWithinBasePath(collection.pathname, filePath);
|
||||
});
|
||||
@@ -66,19 +73,42 @@ const MultipartFormParams = ({ item, collection }) => {
|
||||
const currentParams = item.draft
|
||||
? get(item, 'draft.request.body.multipartForm')
|
||||
: get(item, 'request.body.multipartForm');
|
||||
const existsInParams = (currentParams || []).some((p) => p.uid === row.uid);
|
||||
const existingParam = (currentParams || []).find((p) => p.uid === row.uid);
|
||||
const existingValue = existingParam && existingParam.type === 'file' && Array.isArray(existingParam.value)
|
||||
? existingParam.value
|
||||
: [];
|
||||
const seen = new Set(existingValue);
|
||||
const merged = [...existingValue];
|
||||
const skipped = [];
|
||||
for (const p of processedPaths) {
|
||||
if (!seen.has(p)) {
|
||||
seen.add(p);
|
||||
merged.push(p);
|
||||
} else {
|
||||
skipped.push(p);
|
||||
}
|
||||
}
|
||||
|
||||
if (skipped.length === 1) {
|
||||
toast(`"${fileBasename(skipped[0])}" is already added`);
|
||||
} else if (skipped.length > 1) {
|
||||
toast(`${skipped.length} files are already added — skipped`);
|
||||
}
|
||||
|
||||
const autoContentType = getMultipartAutoContentType(merged);
|
||||
|
||||
let updatedParams;
|
||||
if (existsInParams) {
|
||||
if (existingParam) {
|
||||
updatedParams = currentParams.map((p) => {
|
||||
if (p.uid === row.uid) {
|
||||
return { ...p, type: 'file', value: processedPaths };
|
||||
return { ...p, type: 'file', value: merged, contentType: autoContentType };
|
||||
}
|
||||
return p;
|
||||
});
|
||||
} else {
|
||||
updatedParams = [
|
||||
...(currentParams || []),
|
||||
{ uid: row.uid, name: row.name || '', enabled: true, type: 'file', value: processedPaths, contentType: '' }
|
||||
{ uid: row.uid, name: row.name || '', enabled: true, type: 'file', value: merged, contentType: autoContentType }
|
||||
];
|
||||
}
|
||||
handleParamsChange(updatedParams);
|
||||
@@ -88,13 +118,21 @@ const MultipartFormParams = ({ item, collection }) => {
|
||||
});
|
||||
}, [dispatch, collection.pathname, item, handleParamsChange]);
|
||||
|
||||
const handleClearFile = useCallback((row) => {
|
||||
const handleRemoveFile = useCallback((row, filePathToRemove) => {
|
||||
const currentParams = params || [];
|
||||
const target = currentParams.find((p) => p.uid === row.uid);
|
||||
if (!target || target.type !== 'file') return;
|
||||
const currentValue = Array.isArray(target.value)
|
||||
? target.value
|
||||
: (target.value ? [target.value] : []);
|
||||
const nextValue = currentValue.filter((p) => p !== filePathToRemove);
|
||||
|
||||
const updatedParams = currentParams.map((p) => {
|
||||
if (p.uid === row.uid) {
|
||||
return { ...p, type: 'text', value: '' };
|
||||
if (p.uid !== row.uid) return p;
|
||||
if (nextValue.length === 0) {
|
||||
return { ...p, type: 'text', value: '', contentType: '' };
|
||||
}
|
||||
return p;
|
||||
return { ...p, type: 'file', value: nextValue, contentType: getMultipartAutoContentType(nextValue) };
|
||||
});
|
||||
handleParamsChange(updatedParams);
|
||||
}, [params, handleParamsChange]);
|
||||
@@ -115,19 +153,12 @@ const MultipartFormParams = ({ item, collection }) => {
|
||||
}
|
||||
}, [params, handleParamsChange]);
|
||||
|
||||
const getFileName = (filePaths) => {
|
||||
const getFileList = (filePaths) => {
|
||||
if (!filePaths || (Array.isArray(filePaths) && filePaths.length === 0)) {
|
||||
return null;
|
||||
return [];
|
||||
}
|
||||
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)`;
|
||||
return paths.filter((v) => v != null && v !== '');
|
||||
};
|
||||
|
||||
const columns = [
|
||||
@@ -144,29 +175,14 @@ const MultipartFormParams = ({ item, collection }) => {
|
||||
placeholder: 'Value',
|
||||
width: '35%',
|
||||
render: ({ row, value, onChange }) => {
|
||||
const isFile = row.type === 'file';
|
||||
const fileName = isFile ? getFileName(value) : null;
|
||||
if (fileName) {
|
||||
const files = row.type === 'file' ? getFileList(value) : [];
|
||||
if (files.length > 0) {
|
||||
return (
|
||||
<div className="flex items-center file-value-cell">
|
||||
<IconFile size={16} className="text-muted mr-1" />
|
||||
<div className="file-name flex-1 truncate" title={Array.isArray(value) ? value.join(', ') : value}>
|
||||
<SingleLineEditor
|
||||
theme={storedTheme}
|
||||
value={fileName}
|
||||
readOnly={true}
|
||||
collection={collection}
|
||||
item={item}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="clear-file-btn ml-1"
|
||||
onClick={() => handleClearFile(row)}
|
||||
title="Remove file"
|
||||
>
|
||||
<IconX size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<MultipartFileChipsCell
|
||||
files={files}
|
||||
onRemove={(filePath) => handleRemoveFile(row, filePath)}
|
||||
onAdd={() => handleBrowseFiles(row, onChange)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -186,6 +202,7 @@ const MultipartFormParams = ({ item, collection }) => {
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
data-testid="multipart-file-upload"
|
||||
className="upload-btn ml-1"
|
||||
onClick={() => handleBrowseFiles(row, onChange)}
|
||||
title="Select File"
|
||||
|
||||
@@ -62,7 +62,7 @@ const Wrapper = styled.div`
|
||||
|
||||
tr {
|
||||
position: relative;
|
||||
|
||||
|
||||
&:hover .delete-button.edit-mode {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
@@ -92,10 +92,6 @@ const Wrapper = styled.div`
|
||||
color: ${(props) => props.theme.colors.text.danger};
|
||||
}
|
||||
|
||||
.file-value-cell {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.value-cell {
|
||||
width: 100%;
|
||||
|
||||
@@ -114,7 +110,7 @@ const Wrapper = styled.div`
|
||||
border-radius: 4px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
margin-left: 8px;
|
||||
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => props.theme.colors.text.red};
|
||||
}
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
import React, { useMemo, useCallback } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { IconUpload, IconX, IconFile } from '@tabler/icons';
|
||||
import { IconUpload } from '@tabler/icons';
|
||||
import { updateResponseExampleMultipartFormParams } from 'providers/ReduxStore/slices/collections';
|
||||
import { browseFiles } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
|
||||
import mime from 'mime-types';
|
||||
import path, { getRelativePathWithinBasePath } from 'utils/common/path';
|
||||
import path, { getRelativePathWithinBasePath, normalizePath } from 'utils/common/path';
|
||||
import { getMultipartAutoContentType } from 'utils/common/multipartContentType';
|
||||
import EditableTable from 'components/EditableTable';
|
||||
import MultiLineEditor from 'components/MultiLineEditor';
|
||||
import SingleLineEditor from 'components/SingleLineEditor';
|
||||
import MultipartFileChipsCell from 'components/MultipartFileChipsCell';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { isWindowsOS } from 'utils/common/platform';
|
||||
|
||||
const fileBasename = (filePath) =>
|
||||
filePath ? path.basename(normalizePath(String(filePath))) : '';
|
||||
|
||||
const ResponseExampleMultipartFormParams = ({ item, collection, exampleUid, editMode = false }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -48,46 +52,59 @@ const ResponseExampleMultipartFormParams = ({ item, collection, exampleUid, edit
|
||||
const handleBrowseFiles = useCallback((row, onChange) => {
|
||||
if (!editMode) return;
|
||||
|
||||
dispatch(browseFiles())
|
||||
dispatch(browseFiles([], ['multiSelections']))
|
||||
.then((filePaths) => {
|
||||
if (!Array.isArray(filePaths) || filePaths.length === 0) return;
|
||||
|
||||
const processedPaths = filePaths.map((filePath) => {
|
||||
return getRelativePathWithinBasePath(collection.pathname, filePath);
|
||||
});
|
||||
|
||||
const currentParams = params || [];
|
||||
const existingParam = currentParams.find((p) => p.uid === row.uid);
|
||||
const existingValue = existingParam && existingParam.type === 'file' && Array.isArray(existingParam.value)
|
||||
? existingParam.value
|
||||
: [];
|
||||
const seen = new Set(existingValue);
|
||||
const merged = [...existingValue];
|
||||
const skipped = [];
|
||||
for (const p of processedPaths) {
|
||||
if (!seen.has(p)) {
|
||||
seen.add(p);
|
||||
merged.push(p);
|
||||
} else {
|
||||
skipped.push(p);
|
||||
}
|
||||
}
|
||||
|
||||
if (skipped.length === 1) {
|
||||
toast(`"${fileBasename(skipped[0])}" is already added`);
|
||||
} else if (skipped.length > 1) {
|
||||
toast(`${skipped.length} files are already added — skipped`);
|
||||
}
|
||||
|
||||
const autoContentType = getMultipartAutoContentType(merged);
|
||||
|
||||
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, type: 'file', value: merged, contentType: autoContentType };
|
||||
}
|
||||
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];
|
||||
updatedParams = [
|
||||
...currentParams,
|
||||
{
|
||||
uid: row.uid,
|
||||
name: row.name || '',
|
||||
type: 'file',
|
||||
value: merged,
|
||||
contentType: autoContentType,
|
||||
enabled: true
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
handleParamsChange(updatedParams);
|
||||
@@ -97,21 +114,24 @@ const ResponseExampleMultipartFormParams = ({ item, collection, exampleUid, edit
|
||||
});
|
||||
}, [editMode, dispatch, collection.pathname, params, handleParamsChange]);
|
||||
|
||||
const handleClearFile = useCallback((row) => {
|
||||
const handleRemoveFile = useCallback((row, filePathToRemove) => {
|
||||
if (!editMode) return;
|
||||
|
||||
const currentParams = params || [];
|
||||
const existingParam = currentParams.find((p) => p.uid === row.uid);
|
||||
const target = currentParams.find((p) => p.uid === row.uid);
|
||||
if (!target || target.type !== 'file') return;
|
||||
const currentValue = Array.isArray(target.value)
|
||||
? target.value
|
||||
: (target.value ? [target.value] : []);
|
||||
const nextValue = currentValue.filter((p) => p !== filePathToRemove);
|
||||
|
||||
if (existingParam) {
|
||||
const updatedParams = currentParams.map((p) => {
|
||||
if (p.uid === row.uid) {
|
||||
return { ...p, type: 'text', value: '' };
|
||||
}
|
||||
return p;
|
||||
});
|
||||
handleParamsChange(updatedParams);
|
||||
}
|
||||
const updatedParams = currentParams.map((p) => {
|
||||
if (p.uid !== row.uid) return p;
|
||||
if (nextValue.length === 0) {
|
||||
return { ...p, type: 'text', value: '', contentType: '' };
|
||||
}
|
||||
return { ...p, type: 'file', value: nextValue, contentType: getMultipartAutoContentType(nextValue) };
|
||||
});
|
||||
handleParamsChange(updatedParams);
|
||||
}, [editMode, params, handleParamsChange]);
|
||||
|
||||
const handleValueChange = useCallback((row, newValue, onChange) => {
|
||||
@@ -147,19 +167,12 @@ const ResponseExampleMultipartFormParams = ({ item, collection, exampleUid, edit
|
||||
}));
|
||||
}, [editMode, dispatch, item.uid, collection.uid, exampleUid, params]);
|
||||
|
||||
const getFileName = (filePaths) => {
|
||||
const getFileList = (filePaths) => {
|
||||
if (!filePaths || (Array.isArray(filePaths) && filePaths.length === 0)) {
|
||||
return null;
|
||||
return [];
|
||||
}
|
||||
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)`;
|
||||
return paths.filter((v) => v != null && v !== '');
|
||||
};
|
||||
|
||||
const columns = [
|
||||
@@ -178,29 +191,15 @@ const ResponseExampleMultipartFormParams = ({ item, collection, exampleUid, edit
|
||||
width: '40%',
|
||||
readOnly: !editMode,
|
||||
render: ({ row, value, onChange }) => {
|
||||
const isFile = row.type === 'file';
|
||||
const fileName = isFile ? getFileName(value) : null;
|
||||
if (fileName) {
|
||||
const fileList = row.type === 'file' ? getFileList(value) : [];
|
||||
if (fileList.length > 0) {
|
||||
return (
|
||||
<div className="flex items-center file-value-cell">
|
||||
<IconFile size={16} className="text-muted mr-1" />
|
||||
<div className="file-name flex-1 truncate" title={Array.isArray(value) ? value.join(', ') : value}>
|
||||
<SingleLineEditor
|
||||
theme={storedTheme}
|
||||
value={fileName}
|
||||
readOnly={true}
|
||||
collection={collection}
|
||||
item={item}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="clear-file-btn ml-1"
|
||||
onClick={() => handleClearFile(row)}
|
||||
title="Remove file"
|
||||
>
|
||||
<IconX size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<MultipartFileChipsCell
|
||||
files={fileList}
|
||||
onRemove={(filePath) => handleRemoveFile(row, filePath)}
|
||||
onAdd={() => handleBrowseFiles(row, onChange)}
|
||||
editMode={editMode}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,8 @@ const ToolHint = ({
|
||||
positionStrategy,
|
||||
theme = null,
|
||||
className = '',
|
||||
delayShow = 200
|
||||
delayShow = 200,
|
||||
dataTestId
|
||||
}) => {
|
||||
const { theme: contextTheme } = useTheme();
|
||||
const appliedTheme = theme || contextTheme;
|
||||
@@ -37,7 +38,7 @@ const ToolHint = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
{!anchorSelect && <span id={toolhintId} className={className}>{children}</span>}
|
||||
{!anchorSelect && <span id={toolhintId} className={className} data-testid={dataTestId}>{children}</span>}
|
||||
{anchorSelect && children}
|
||||
<ReactToolHint
|
||||
{...toolhintProps_final}
|
||||
|
||||
10
packages/bruno-app/src/utils/common/multipartContentType.js
Normal file
10
packages/bruno-app/src/utils/common/multipartContentType.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import mime from 'mime-types';
|
||||
import path from 'utils/common/path';
|
||||
|
||||
export const getMultipartAutoContentType = (files) => {
|
||||
if (!Array.isArray(files) || files.length === 0) return '';
|
||||
if (files.length === 1) {
|
||||
return mime.contentType(path.extname(files[0])) || '';
|
||||
}
|
||||
return 'multipart/mixed';
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
const { describe, it, expect } = require('@jest/globals');
|
||||
import mime from 'mime-types';
|
||||
|
||||
import { getMultipartAutoContentType } from './multipartContentType';
|
||||
|
||||
describe('getMultipartAutoContentType', () => {
|
||||
describe('empty input', () => {
|
||||
it('returns empty string for an empty array', () => {
|
||||
expect(getMultipartAutoContentType([])).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string for undefined', () => {
|
||||
expect(getMultipartAutoContentType(undefined)).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string for null', () => {
|
||||
expect(getMultipartAutoContentType(null)).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string for non-array input', () => {
|
||||
expect(getMultipartAutoContentType('foo.png')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('single file', () => {
|
||||
it('detects content type for a png from extension', () => {
|
||||
expect(getMultipartAutoContentType(['photo.png'])).toBe(mime.contentType('.png'));
|
||||
});
|
||||
|
||||
it('detects content type for a pdf', () => {
|
||||
expect(getMultipartAutoContentType(['document.pdf'])).toBe(mime.contentType('.pdf'));
|
||||
});
|
||||
|
||||
it('detects content type for json', () => {
|
||||
expect(getMultipartAutoContentType(['payload.json'])).toBe(mime.contentType('.json'));
|
||||
});
|
||||
|
||||
it('detects content type when file has a relative path', () => {
|
||||
expect(getMultipartAutoContentType(['assets/icons/logo.svg'])).toBe(mime.contentType('.svg'));
|
||||
});
|
||||
|
||||
it('detects content type when file has an absolute path', () => {
|
||||
expect(getMultipartAutoContentType(['/tmp/uploads/data.csv'])).toBe(mime.contentType('.csv'));
|
||||
});
|
||||
|
||||
it('returns empty string for a file with an unknown extension', () => {
|
||||
expect(getMultipartAutoContentType(['weirdfile.qqqzzz'])).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiple files', () => {
|
||||
it('returns multipart/mixed for two files of the same type', () => {
|
||||
expect(getMultipartAutoContentType(['a.png', 'b.png'])).toBe('multipart/mixed');
|
||||
});
|
||||
|
||||
it('returns multipart/mixed for two files of different types', () => {
|
||||
expect(getMultipartAutoContentType(['a.png', 'b.pdf'])).toBe('multipart/mixed');
|
||||
});
|
||||
|
||||
it('returns multipart/mixed for three or more files', () => {
|
||||
expect(getMultipartAutoContentType(['a.png', 'b.pdf', 'c.json'])).toBe('multipart/mixed');
|
||||
});
|
||||
|
||||
it('returns multipart/mixed even when one file has an unknown extension', () => {
|
||||
expect(getMultipartAutoContentType(['a.png', 'unknownfile'])).toBe('multipart/mixed');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user