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

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

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 = {
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}
/>
);
}

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