feat: multi-file upload in multipart form body (#7971)

This commit is contained in:
Pooja
2026-05-21 20:20:25 +05:30
committed by GitHub
parent 611724a744
commit 8cd7c26648
14 changed files with 957 additions and 138 deletions

View File

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

View File

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

View File

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

View File

@@ -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}
<MultipartFileChipsCell
files={files}
onRemove={(filePath) => handleRemoveFile(row, filePath)}
onAdd={() => handleBrowseFiles(row, onChange)}
/>
</div>
<button
className="clear-file-btn ml-1"
onClick={() => handleClearFile(row)}
title="Remove file"
>
<IconX size={16} />
</button>
</div>
);
}
@@ -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"

View File

@@ -92,10 +92,6 @@ const Wrapper = styled.div`
color: ${(props) => props.theme.colors.text.danger};
}
.file-value-cell {
width: 100%;
}
.value-cell {
width: 100%;

View File

@@ -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 = {
updatedParams = [
...currentParams,
{
uid: row.uid,
name: row.name || '',
type: 'file',
value: processedPaths,
contentType: '',
value: merged,
contentType: autoContentType,
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];
];
}
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: '' };
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);
}
}, [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}
<MultipartFileChipsCell
files={fileList}
onRemove={(filePath) => handleRemoveFile(row, filePath)}
onAdd={() => handleBrowseFiles(row, onChange)}
editMode={editMode}
/>
</div>
<button
className="clear-file-btn ml-1"
onClick={() => handleClearFile(row)}
title="Remove file"
>
<IconX size={16} />
</button>
</div>
);
}

View File

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

View 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';
};

View File

@@ -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');
});
});
});

View File

@@ -0,0 +1,163 @@
import { test, expect } from '../../../playwright';
import {
closeAllCollections,
createCollection,
createRequest,
openCollection,
openRequest,
saveRequest,
selectRequestPaneTab
} from '../../utils/page';
import { buildCommonLocators } from '../../utils/page/locators';
import type { ElectronApplication, Page } from '@playwright/test';
import * as fs from 'fs';
import * as path from 'path';
test.describe('Multipart Form - Multiple File Upload', () => {
let tmpDir: string;
let fileA: string;
let fileB: string;
let fileC: string;
test.beforeAll(async ({ page, electronApp, createTmpDir }) => {
tmpDir = await createTmpDir('multipart-multi-upload');
fileA = path.join(tmpDir, 'alpha.txt');
fileB = path.join(tmpDir, 'beta.txt');
fileC = path.join(tmpDir, 'gamma.txt');
await fs.promises.writeFile(fileA, 'a');
await fs.promises.writeFile(fileB, 'b');
await fs.promises.writeFile(fileC, 'c');
// Maximize the window so the value column is wide enough to render chips
await electronApp.evaluate(({ BrowserWindow }) => {
BrowserWindow.getAllWindows()[0]?.maximize();
});
await electronApp.evaluate(({ dialog }) => {
(dialog as any).__originalShowOpenDialog = dialog.showOpenDialog;
(global as any).__mockFilePaths = [];
dialog.showOpenDialog = async () => ({
canceled: false,
filePaths: (global as any).__mockFilePaths || []
});
});
await createCollection(page, 'multipart-multi-upload', tmpDir);
await createRequest(page, 'test-multi-upload', '', {
url: 'https://testbench-sanity.usebruno.com/api/echo/json',
method: 'POST',
inFolder: false
});
await openCollection(page, 'multipart-multi-upload');
await openRequest(page, 'multipart-multi-upload', 'test-multi-upload', { persist: true });
await selectRequestPaneTab(page, 'Body');
await buildCommonLocators(page).request.bodyModeSelector().click();
await page.locator('.dropdown-item').filter({ hasText: 'Multipart Form' }).click();
});
test.afterAll(async ({ page, electronApp }) => {
await electronApp.evaluate(({ dialog }) => {
if ((dialog as any).__originalShowOpenDialog) {
dialog.showOpenDialog = (dialog as any).__originalShowOpenDialog;
delete (dialog as any).__originalShowOpenDialog;
}
});
await closeAllCollections(page);
});
// Reset the form to a single empty row before each test.
test.beforeEach(async ({ page }) => {
const table = buildCommonLocators(page).table('editable-table');
await expect(table.container()).toBeVisible();
let rowCount = await table.allRows().count();
while (rowCount > 1) {
await table.rowDeleteButton(rowCount - 2).click();
await expect(table.allRows()).toHaveCount(rowCount - 1);
rowCount = await table.allRows().count();
}
await saveRequest(page);
});
// Tell the mocked dialog what to return, then click the upload button on
// the empty last row.
const uploadFiles = async (page: Page, electronApp: ElectronApplication, files: string[]) => {
await electronApp.evaluate((_, paths) => {
(global as any).__mockFilePaths = paths;
}, files);
const table = buildCommonLocators(page).table('editable-table');
await table.allRows().last().getByTestId('multipart-file-upload').click();
};
// Reads all file names currently associated with the row, regardless of
// whether they render as inline chips, in a `+N more` overflow dropdown, or
// as a collapsed `N files` summary. The CI Linux runner has a small display,
// so the value column often collapses into one of the overflow modes.
const readFileNames = async (page: Page): Promise<string[]> => {
const inlineChips = page.getByTestId('multipart-file-chip');
const summary = page.getByTestId('multipart-file-summary');
const more = page.getByTestId('multipart-file-more');
const inlineNames = await inlineChips.allTextContents();
const overflowTrigger = (await summary.count()) > 0 ? summary : (await more.count()) > 0 ? more : null;
if (!overflowTrigger) {
return inlineNames;
}
await overflowTrigger.click();
const overflowRows = page.getByTestId('multipart-file-overflow-row');
await expect(overflowRows.first()).toBeVisible();
const overflowNames = await overflowRows.allTextContents();
// Close the popover by clicking the trigger again (Tippy click-toggle).
await overflowTrigger.click();
await expect(overflowRows.first()).toBeHidden();
// In summary mode all files are in the dropdown; in `+N more` mode the
// inline chips plus the dropdown rows together cover the full list.
return (await summary.count()) > 0 ? overflowNames : [...inlineNames, ...overflowNames];
};
// Removes a single file by name, handling inline-chip and overflow-row paths.
const removeFileByName = async (page: Page, fileName: string) => {
const inlineChip = page.getByTestId('multipart-file-chip').filter({ hasText: fileName });
if ((await inlineChip.count()) > 0) {
await inlineChip.getByTestId('multipart-file-chip-remove').click();
await expect(inlineChip).toHaveCount(0);
return;
}
const summary = page.getByTestId('multipart-file-summary');
const more = page.getByTestId('multipart-file-more');
const trigger = (await summary.count()) > 0 ? summary : more;
await trigger.click();
const row = page.getByTestId('multipart-file-overflow-row').filter({ hasText: fileName });
await expect(row).toBeVisible();
await row.getByTestId('multipart-file-overflow-remove').click();
await expect(row).toHaveCount(0);
// Close the popover if it's still open (it may have auto-closed when its
// last row disappeared).
if ((await trigger.count()) > 0 && (await page.getByTestId('multipart-file-overflow-row').first().isVisible().catch(() => false))) {
await trigger.click();
}
};
test('uploading multiple files registers one entry per file', async ({ page, electronApp }) => {
await uploadFiles(page, electronApp, [fileA, fileB, fileC]);
const names = await readFileNames(page);
expect(names).toEqual(['alpha.txt', 'beta.txt', 'gamma.txt']);
});
test('each file can be removed individually', async ({ page, electronApp }) => {
await uploadFiles(page, electronApp, [fileA, fileB, fileC]);
await removeFileByName(page, 'beta.txt');
const names = await readFileNames(page);
expect(names).toEqual(['alpha.txt', 'gamma.txt']);
});
});

View File

@@ -69,7 +69,19 @@ test.describe.serial('Multipart Form - File Select Without Key', () => {
await test.step('Verify the file name appears in the row', async () => {
const fileCell = table.allRows().locator('.file-value-cell').first();
await expect(fileCell).toBeVisible();
await expect(fileCell).toContainText('test-file.txt');
const inlineChip = fileCell.getByTestId('multipart-file-chip');
const summary = fileCell.getByTestId('multipart-file-summary');
if (await inlineChip.count() > 0) {
await expect(inlineChip.first()).toContainText('test-file.txt');
} else {
await expect(summary).toBeVisible();
await summary.click();
const overflowRow = page.getByTestId('multipart-file-overflow-row').first();
await expect(overflowRow).toBeVisible();
await expect(overflowRow).toContainText('test-file.txt');
await summary.click();
}
});
// Save the request to clear draft state

View File

@@ -0,0 +1,30 @@
meta {
name: multipart-example
type: http
seq: 4
}
post {
url: https://api.example.com/upload
body: multipart-form
auth: none
}
body:multipart-form {
files: @file(alpha.txt|beta.txt|gamma.txt)
}
example {
name: Three Files Example
description: Example with three multipart file uploads
request: {
url: https://api.example.com/upload
method: post
mode: multipartForm
body:multipart-form: {
files: @file(alpha.txt|beta.txt|gamma.txt)
}
}
}

View File

@@ -0,0 +1,117 @@
import { test, expect } from '../../playwright';
import { execSync } from 'child_process';
import path from 'path';
import type { Page } from '@playwright/test';
const fixturePath = path.join(__dirname, 'fixtures', 'collection', 'multipart-example.bru');
test.describe('Response Example - Multipart Form File Chips', () => {
test.afterAll(async () => {
// Restore the fixture .bru file in case any test mutated it. Skip silently
// if the file isn't tracked in git yet (first commit of this fixture).
try {
execSync(`git ls-files --error-unmatch "${fixturePath}"`, { stdio: 'ignore' });
execSync(`git checkout -- "${fixturePath}"`);
} catch {
// File isn't tracked; nothing to restore.
}
});
// `pageWithUserData` reuses the Electron app across tests in the same worker
// (it doesn't pass `closePrevious: true`), so we can't assume a clean DOM
// between tests. This helper is idempotent: it only toggles the chevron when
// the examples list isn't already expanded, so re-running it after a
// previous test leaves things in either state still works.
const openMultipartExample = async (page: Page) => {
await page.locator('#sidebar-collection-name').getByText('collection').click();
const requestItem = page.locator('.collection-item-name', { hasText: 'multipart-example' });
await expect(requestItem).toBeVisible();
await requestItem.click();
const exampleItem = page.locator('.collection-item-name').filter({ hasText: 'Three Files Example' });
if (!(await exampleItem.isVisible().catch(() => false))) {
await requestItem.getByTestId('request-item-chevron').click();
await expect(exampleItem).toBeVisible();
}
await exampleItem.click();
await expect(page.getByTestId('response-example-title')).toBeVisible();
};
test('renders multipart files as chips in read-only mode', async ({ pageWithUserData: page }) => {
await test.step('Open the multipart example', async () => {
await openMultipartExample(page);
});
await test.step('All three files are present', async () => {
// The cell can be in one of three layout modes (inline chips, `+N more`
// overflow, or a fully collapsed `N files` summary) depending on the
// value-column width. CI Linux runners often have a small display that
// pushes the cell into the collapsed mode, so we read both inline chips
// and any overflow-dropdown rows to cover every case.
const summary = page.getByTestId('multipart-file-summary');
const more = page.getByTestId('multipart-file-more');
const inlineNames = await page.getByTestId('multipart-file-chip').allTextContents();
const hasSummary = (await summary.count()) > 0;
const overflowTrigger = hasSummary ? summary : (await more.count()) > 0 ? more : null;
let names = inlineNames;
if (overflowTrigger) {
await overflowTrigger.click();
const overflowRows = page.getByTestId('multipart-file-overflow-row');
await expect(overflowRows.first()).toBeVisible();
const overflowNames = await overflowRows.allTextContents();
await overflowTrigger.click();
await expect(overflowRows.first()).toBeHidden();
names = hasSummary ? overflowNames : [...inlineNames, ...overflowNames];
}
expect(names).toEqual(['alpha.txt', 'beta.txt', 'gamma.txt']);
});
await test.step('Destructive controls are hidden in read-only mode', async () => {
await expect(page.getByTestId('multipart-file-upload')).toHaveCount(0);
await expect(page.getByTestId('multipart-file-chip-remove')).toHaveCount(0);
});
});
test('edit mode reveals the upload button', async ({ pageWithUserData: page }) => {
await test.step('Open the multipart example', async () => {
await openMultipartExample(page);
});
await test.step('All three files are present', async () => {
const summary = page.getByTestId('multipart-file-summary');
const more = page.getByTestId('multipart-file-more');
const inlineNames = await page.getByTestId('multipart-file-chip').allTextContents();
const hasSummary = (await summary.count()) > 0;
const overflowTrigger = hasSummary ? summary : (await more.count()) > 0 ? more : null;
let names = inlineNames;
if (overflowTrigger) {
await overflowTrigger.click();
const overflowRows = page.getByTestId('multipart-file-overflow-row');
await expect(overflowRows.first()).toBeVisible();
const overflowNames = await overflowRows.allTextContents();
await overflowTrigger.click();
await expect(overflowRows.first()).toBeHidden();
names = hasSummary ? overflowNames : [...inlineNames, ...overflowNames];
}
expect(names).toEqual(['alpha.txt', 'beta.txt', 'gamma.txt']);
});
await test.step('Click edit on the example', async () => {
await page.getByTestId('response-example-edit-btn').click();
});
await test.step('Upload button is now visible', async () => {
await expect(page.getByTestId('multipart-file-upload').first()).toBeVisible();
});
await test.step('Cancel edit to leave the example untouched', async () => {
await page.getByTestId('response-example-cancel-btn').click();
});
});
});

View File

@@ -1041,16 +1041,33 @@ const addMultipartFileToLastRow = async (page: Page, electronApp: ElectronApplic
await expect(lastRow.locator('.upload-btn')).toBeVisible();
await lastRow.locator('.upload-btn').click();
await expect(lastRow.locator('.file-value-cell')).toContainText(path.basename(filePath));
await expect(lastRow.locator('.file-value-cell')).toBeVisible();
const inlineChip = lastRow.getByTestId('multipart-file-chip').filter({ hasText: path.basename(filePath) });
const summary = lastRow.getByTestId('multipart-file-summary');
await expect(inlineChip.or(summary)).toBeVisible();
});
};
const removeFirstMultipartFile = async (page: Page) => {
await test.step('Remove first multipart file', async () => {
const table = buildCommonLocators(page).table('editable-table');
await expect(table.allRows().locator('.file-value-cell').first()).toBeVisible();
await table.allRows().first().locator('.clear-file-btn').click();
await expect(table.allRows().first().locator('.upload-btn')).toBeVisible();
const firstRow = table.allRows().first();
await expect(firstRow.locator('.file-value-cell')).toBeVisible();
const inlineRemove = firstRow.getByTestId('multipart-file-chip-remove').first();
const summary = firstRow.getByTestId('multipart-file-summary');
if (await inlineRemove.count() > 0) {
await inlineRemove.click();
} else {
await expect(summary).toBeVisible();
await summary.click();
const overflowRemove = page.getByTestId('multipart-file-overflow-remove').first();
await expect(overflowRemove).toBeVisible();
await overflowRemove.click();
}
await expect(firstRow.locator('.file-value-cell')).toHaveCount(0);
await expect(firstRow.locator('.value-cell')).toBeVisible();
});
};