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;
|
cursor: pointer;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
transition: color 0.15s ease;
|
transition: color 0.15s ease;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: ${(props) => props.theme.text};
|
color: ${(props) => props.theme.text};
|
||||||
@@ -23,15 +24,6 @@ const Wrapper = styled.div`
|
|||||||
color: ${(props) => props.theme.colors.text.danger};
|
color: ${(props) => props.theme.colors.text.danger};
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-value-cell {
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
.file-name {
|
|
||||||
font-size: 12px;
|
|
||||||
color: ${(props) => props.theme.text};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.value-cell {
|
.value-cell {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import React, { useCallback, useRef } from 'react';
|
import React, { useCallback, useRef } from 'react';
|
||||||
import get from 'lodash/get';
|
import get from 'lodash/get';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
import { useTheme } from 'providers/Theme';
|
import { useTheme } from 'providers/Theme';
|
||||||
import { IconUpload, IconX, IconFile } from '@tabler/icons';
|
import { IconUpload } from '@tabler/icons';
|
||||||
import {
|
import {
|
||||||
moveMultipartFormParam,
|
moveMultipartFormParam,
|
||||||
setMultipartFormParams
|
setMultipartFormParams
|
||||||
@@ -10,14 +11,18 @@ import {
|
|||||||
import { browseFiles } from 'providers/ReduxStore/slices/collections/actions';
|
import { browseFiles } from 'providers/ReduxStore/slices/collections/actions';
|
||||||
import MultiLineEditor from 'components/MultiLineEditor';
|
import MultiLineEditor from 'components/MultiLineEditor';
|
||||||
import SingleLineEditor from 'components/SingleLineEditor';
|
import SingleLineEditor from 'components/SingleLineEditor';
|
||||||
|
import MultipartFileChipsCell from 'components/MultipartFileChipsCell';
|
||||||
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||||
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
|
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
|
||||||
import EditableTable from 'components/EditableTable';
|
import EditableTable from 'components/EditableTable';
|
||||||
import StyledWrapper from './StyledWrapper';
|
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 { usePersistedState } from 'hooks/usePersistedState';
|
||||||
import { useTrackScroll } from 'hooks/useTrackScroll';
|
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 MultipartFormParams = ({ item, collection }) => {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
@@ -57,8 +62,10 @@ const MultipartFormParams = ({ item, collection }) => {
|
|||||||
}, [dispatch, collection.uid, item.uid]);
|
}, [dispatch, collection.uid, item.uid]);
|
||||||
|
|
||||||
const handleBrowseFiles = useCallback((row, onChange) => {
|
const handleBrowseFiles = useCallback((row, onChange) => {
|
||||||
dispatch(browseFiles())
|
dispatch(browseFiles([], ['multiSelections']))
|
||||||
.then((filePaths) => {
|
.then((filePaths) => {
|
||||||
|
if (!Array.isArray(filePaths) || filePaths.length === 0) return;
|
||||||
|
|
||||||
const processedPaths = filePaths.map((filePath) => {
|
const processedPaths = filePaths.map((filePath) => {
|
||||||
return getRelativePathWithinBasePath(collection.pathname, filePath);
|
return getRelativePathWithinBasePath(collection.pathname, filePath);
|
||||||
});
|
});
|
||||||
@@ -66,19 +73,42 @@ const MultipartFormParams = ({ item, collection }) => {
|
|||||||
const currentParams = item.draft
|
const currentParams = item.draft
|
||||||
? get(item, 'draft.request.body.multipartForm')
|
? get(item, 'draft.request.body.multipartForm')
|
||||||
: get(item, '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;
|
let updatedParams;
|
||||||
if (existsInParams) {
|
if (existingParam) {
|
||||||
updatedParams = currentParams.map((p) => {
|
updatedParams = currentParams.map((p) => {
|
||||||
if (p.uid === row.uid) {
|
if (p.uid === row.uid) {
|
||||||
return { ...p, type: 'file', value: processedPaths };
|
return { ...p, type: 'file', value: merged, contentType: autoContentType };
|
||||||
}
|
}
|
||||||
return p;
|
return p;
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
updatedParams = [
|
updatedParams = [
|
||||||
...(currentParams || []),
|
...(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);
|
handleParamsChange(updatedParams);
|
||||||
@@ -88,13 +118,21 @@ const MultipartFormParams = ({ item, collection }) => {
|
|||||||
});
|
});
|
||||||
}, [dispatch, collection.pathname, item, handleParamsChange]);
|
}, [dispatch, collection.pathname, item, handleParamsChange]);
|
||||||
|
|
||||||
const handleClearFile = useCallback((row) => {
|
const handleRemoveFile = useCallback((row, filePathToRemove) => {
|
||||||
const currentParams = params || [];
|
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) => {
|
const updatedParams = currentParams.map((p) => {
|
||||||
if (p.uid === row.uid) {
|
if (p.uid !== row.uid) return p;
|
||||||
return { ...p, type: 'text', value: '' };
|
if (nextValue.length === 0) {
|
||||||
|
return { ...p, type: 'text', value: '', contentType: '' };
|
||||||
}
|
}
|
||||||
return p;
|
return { ...p, type: 'file', value: nextValue, contentType: getMultipartAutoContentType(nextValue) };
|
||||||
});
|
});
|
||||||
handleParamsChange(updatedParams);
|
handleParamsChange(updatedParams);
|
||||||
}, [params, handleParamsChange]);
|
}, [params, handleParamsChange]);
|
||||||
@@ -115,19 +153,12 @@ const MultipartFormParams = ({ item, collection }) => {
|
|||||||
}
|
}
|
||||||
}, [params, handleParamsChange]);
|
}, [params, handleParamsChange]);
|
||||||
|
|
||||||
const getFileName = (filePaths) => {
|
const getFileList = (filePaths) => {
|
||||||
if (!filePaths || (Array.isArray(filePaths) && filePaths.length === 0)) {
|
if (!filePaths || (Array.isArray(filePaths) && filePaths.length === 0)) {
|
||||||
return null;
|
return [];
|
||||||
}
|
}
|
||||||
const paths = Array.isArray(filePaths) ? filePaths : [filePaths];
|
const paths = Array.isArray(filePaths) ? filePaths : [filePaths];
|
||||||
const validPaths = paths.filter((v) => v != null && v !== '');
|
return paths.filter((v) => v != null && v !== '');
|
||||||
if (validPaths.length === 0) return null;
|
|
||||||
|
|
||||||
const separator = isWindowsOS() ? '\\' : '/';
|
|
||||||
if (validPaths.length === 1) {
|
|
||||||
return validPaths[0].split(separator).pop();
|
|
||||||
}
|
|
||||||
return `${validPaths.length} file(s)`;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
@@ -144,29 +175,14 @@ const MultipartFormParams = ({ item, collection }) => {
|
|||||||
placeholder: 'Value',
|
placeholder: 'Value',
|
||||||
width: '35%',
|
width: '35%',
|
||||||
render: ({ row, value, onChange }) => {
|
render: ({ row, value, onChange }) => {
|
||||||
const isFile = row.type === 'file';
|
const files = row.type === 'file' ? getFileList(value) : [];
|
||||||
const fileName = isFile ? getFileName(value) : null;
|
if (files.length > 0) {
|
||||||
if (fileName) {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center file-value-cell">
|
<MultipartFileChipsCell
|
||||||
<IconFile size={16} className="text-muted mr-1" />
|
files={files}
|
||||||
<div className="file-name flex-1 truncate" title={Array.isArray(value) ? value.join(', ') : value}>
|
onRemove={(filePath) => handleRemoveFile(row, filePath)}
|
||||||
<SingleLineEditor
|
onAdd={() => handleBrowseFiles(row, onChange)}
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,6 +202,7 @@ const MultipartFormParams = ({ item, collection }) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
data-testid="multipart-file-upload"
|
||||||
className="upload-btn ml-1"
|
className="upload-btn ml-1"
|
||||||
onClick={() => handleBrowseFiles(row, onChange)}
|
onClick={() => handleBrowseFiles(row, onChange)}
|
||||||
title="Select File"
|
title="Select File"
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ const Wrapper = styled.div`
|
|||||||
|
|
||||||
tr {
|
tr {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
&:hover .delete-button.edit-mode {
|
&:hover .delete-button.edit-mode {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
visibility: visible;
|
visibility: visible;
|
||||||
@@ -92,10 +92,6 @@ const Wrapper = styled.div`
|
|||||||
color: ${(props) => props.theme.colors.text.danger};
|
color: ${(props) => props.theme.colors.text.danger};
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-value-cell {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.value-cell {
|
.value-cell {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
@@ -114,7 +110,7 @@ const Wrapper = styled.div`
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: ${(props) => props.theme.colors.text.muted};
|
color: ${(props) => props.theme.colors.text.muted};
|
||||||
margin-left: 8px;
|
margin-left: 8px;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: ${(props) => props.theme.colors.text.red};
|
color: ${(props) => props.theme.colors.text.red};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,22 @@
|
|||||||
import React, { useMemo, useCallback } from 'react';
|
import React, { useMemo, useCallback } from 'react';
|
||||||
import get from 'lodash/get';
|
import get from 'lodash/get';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
import { useTheme } from 'providers/Theme';
|
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 { updateResponseExampleMultipartFormParams } from 'providers/ReduxStore/slices/collections';
|
||||||
import { browseFiles } from 'providers/ReduxStore/slices/collections/actions';
|
import { browseFiles } from 'providers/ReduxStore/slices/collections/actions';
|
||||||
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
|
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
|
||||||
import mime from 'mime-types';
|
import path, { getRelativePathWithinBasePath, normalizePath } from 'utils/common/path';
|
||||||
import path, { getRelativePathWithinBasePath } from 'utils/common/path';
|
import { getMultipartAutoContentType } from 'utils/common/multipartContentType';
|
||||||
import EditableTable from 'components/EditableTable';
|
import EditableTable from 'components/EditableTable';
|
||||||
import MultiLineEditor from 'components/MultiLineEditor';
|
import MultiLineEditor from 'components/MultiLineEditor';
|
||||||
import SingleLineEditor from 'components/SingleLineEditor';
|
import SingleLineEditor from 'components/SingleLineEditor';
|
||||||
|
import MultipartFileChipsCell from 'components/MultipartFileChipsCell';
|
||||||
import StyledWrapper from './StyledWrapper';
|
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 ResponseExampleMultipartFormParams = ({ item, collection, exampleUid, editMode = false }) => {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
@@ -48,46 +52,59 @@ const ResponseExampleMultipartFormParams = ({ item, collection, exampleUid, edit
|
|||||||
const handleBrowseFiles = useCallback((row, onChange) => {
|
const handleBrowseFiles = useCallback((row, onChange) => {
|
||||||
if (!editMode) return;
|
if (!editMode) return;
|
||||||
|
|
||||||
dispatch(browseFiles())
|
dispatch(browseFiles([], ['multiSelections']))
|
||||||
.then((filePaths) => {
|
.then((filePaths) => {
|
||||||
|
if (!Array.isArray(filePaths) || filePaths.length === 0) return;
|
||||||
|
|
||||||
const processedPaths = filePaths.map((filePath) => {
|
const processedPaths = filePaths.map((filePath) => {
|
||||||
return getRelativePathWithinBasePath(collection.pathname, filePath);
|
return getRelativePathWithinBasePath(collection.pathname, filePath);
|
||||||
});
|
});
|
||||||
|
|
||||||
const currentParams = params || [];
|
const currentParams = params || [];
|
||||||
const existingParam = currentParams.find((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;
|
let updatedParams;
|
||||||
if (existingParam) {
|
if (existingParam) {
|
||||||
// Update existing param
|
|
||||||
updatedParams = currentParams.map((p) => {
|
updatedParams = currentParams.map((p) => {
|
||||||
if (p.uid === row.uid) {
|
if (p.uid === row.uid) {
|
||||||
const updated = { ...p, type: 'file', value: processedPaths };
|
return { ...p, type: 'file', value: merged, contentType: autoContentType };
|
||||||
// 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;
|
return p;
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Add new param (from EditableTable's empty row)
|
updatedParams = [
|
||||||
const newParam = {
|
...currentParams,
|
||||||
uid: row.uid,
|
{
|
||||||
name: row.name || '',
|
uid: row.uid,
|
||||||
type: 'file',
|
name: row.name || '',
|
||||||
value: processedPaths,
|
type: 'file',
|
||||||
contentType: '',
|
value: merged,
|
||||||
enabled: true
|
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);
|
handleParamsChange(updatedParams);
|
||||||
@@ -97,21 +114,24 @@ const ResponseExampleMultipartFormParams = ({ item, collection, exampleUid, edit
|
|||||||
});
|
});
|
||||||
}, [editMode, dispatch, collection.pathname, params, handleParamsChange]);
|
}, [editMode, dispatch, collection.pathname, params, handleParamsChange]);
|
||||||
|
|
||||||
const handleClearFile = useCallback((row) => {
|
const handleRemoveFile = useCallback((row, filePathToRemove) => {
|
||||||
if (!editMode) return;
|
if (!editMode) return;
|
||||||
|
|
||||||
const currentParams = params || [];
|
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) => {
|
||||||
const updatedParams = currentParams.map((p) => {
|
if (p.uid !== row.uid) return p;
|
||||||
if (p.uid === row.uid) {
|
if (nextValue.length === 0) {
|
||||||
return { ...p, type: 'text', value: '' };
|
return { ...p, type: 'text', value: '', contentType: '' };
|
||||||
}
|
}
|
||||||
return p;
|
return { ...p, type: 'file', value: nextValue, contentType: getMultipartAutoContentType(nextValue) };
|
||||||
});
|
});
|
||||||
handleParamsChange(updatedParams);
|
handleParamsChange(updatedParams);
|
||||||
}
|
|
||||||
}, [editMode, params, handleParamsChange]);
|
}, [editMode, params, handleParamsChange]);
|
||||||
|
|
||||||
const handleValueChange = useCallback((row, newValue, onChange) => {
|
const handleValueChange = useCallback((row, newValue, onChange) => {
|
||||||
@@ -147,19 +167,12 @@ const ResponseExampleMultipartFormParams = ({ item, collection, exampleUid, edit
|
|||||||
}));
|
}));
|
||||||
}, [editMode, dispatch, item.uid, collection.uid, exampleUid, params]);
|
}, [editMode, dispatch, item.uid, collection.uid, exampleUid, params]);
|
||||||
|
|
||||||
const getFileName = (filePaths) => {
|
const getFileList = (filePaths) => {
|
||||||
if (!filePaths || (Array.isArray(filePaths) && filePaths.length === 0)) {
|
if (!filePaths || (Array.isArray(filePaths) && filePaths.length === 0)) {
|
||||||
return null;
|
return [];
|
||||||
}
|
}
|
||||||
const paths = Array.isArray(filePaths) ? filePaths : [filePaths];
|
const paths = Array.isArray(filePaths) ? filePaths : [filePaths];
|
||||||
const validPaths = paths.filter((v) => v != null && v !== '');
|
return paths.filter((v) => v != null && v !== '');
|
||||||
if (validPaths.length === 0) return null;
|
|
||||||
|
|
||||||
const separator = isWindowsOS() ? '\\' : '/';
|
|
||||||
if (validPaths.length === 1) {
|
|
||||||
return validPaths[0].split(separator).pop();
|
|
||||||
}
|
|
||||||
return `${validPaths.length} file(s)`;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
@@ -178,29 +191,15 @@ const ResponseExampleMultipartFormParams = ({ item, collection, exampleUid, edit
|
|||||||
width: '40%',
|
width: '40%',
|
||||||
readOnly: !editMode,
|
readOnly: !editMode,
|
||||||
render: ({ row, value, onChange }) => {
|
render: ({ row, value, onChange }) => {
|
||||||
const isFile = row.type === 'file';
|
const fileList = row.type === 'file' ? getFileList(value) : [];
|
||||||
const fileName = isFile ? getFileName(value) : null;
|
if (fileList.length > 0) {
|
||||||
if (fileName) {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center file-value-cell">
|
<MultipartFileChipsCell
|
||||||
<IconFile size={16} className="text-muted mr-1" />
|
files={fileList}
|
||||||
<div className="file-name flex-1 truncate" title={Array.isArray(value) ? value.join(', ') : value}>
|
onRemove={(filePath) => handleRemoveFile(row, filePath)}
|
||||||
<SingleLineEditor
|
onAdd={() => handleBrowseFiles(row, onChange)}
|
||||||
theme={storedTheme}
|
editMode={editMode}
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ const ToolHint = ({
|
|||||||
positionStrategy,
|
positionStrategy,
|
||||||
theme = null,
|
theme = null,
|
||||||
className = '',
|
className = '',
|
||||||
delayShow = 200
|
delayShow = 200,
|
||||||
|
dataTestId
|
||||||
}) => {
|
}) => {
|
||||||
const { theme: contextTheme } = useTheme();
|
const { theme: contextTheme } = useTheme();
|
||||||
const appliedTheme = theme || contextTheme;
|
const appliedTheme = theme || contextTheme;
|
||||||
@@ -37,7 +38,7 @@ const ToolHint = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{!anchorSelect && <span id={toolhintId} className={className}>{children}</span>}
|
{!anchorSelect && <span id={toolhintId} className={className} data-testid={dataTestId}>{children}</span>}
|
||||||
{anchorSelect && children}
|
{anchorSelect && children}
|
||||||
<ReactToolHint
|
<ReactToolHint
|
||||||
{...toolhintProps_final}
|
{...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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
163
tests/request/multipart-form/multipart-form-file-chips.spec.ts
Normal file
163
tests/request/multipart-form/multipart-form-file-chips.spec.ts
Normal 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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 () => {
|
await test.step('Verify the file name appears in the row', async () => {
|
||||||
const fileCell = table.allRows().locator('.file-value-cell').first();
|
const fileCell = table.allRows().locator('.file-value-cell').first();
|
||||||
await expect(fileCell).toBeVisible();
|
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
|
// Save the request to clear draft state
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
117
tests/response-examples/multipart-form-chips.spec.ts
Normal file
117
tests/response-examples/multipart-form-chips.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1041,16 +1041,33 @@ const addMultipartFileToLastRow = async (page: Page, electronApp: ElectronApplic
|
|||||||
|
|
||||||
await expect(lastRow.locator('.upload-btn')).toBeVisible();
|
await expect(lastRow.locator('.upload-btn')).toBeVisible();
|
||||||
await lastRow.locator('.upload-btn').click();
|
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) => {
|
const removeFirstMultipartFile = async (page: Page) => {
|
||||||
await test.step('Remove first multipart file', async () => {
|
await test.step('Remove first multipart file', async () => {
|
||||||
const table = buildCommonLocators(page).table('editable-table');
|
const table = buildCommonLocators(page).table('editable-table');
|
||||||
await expect(table.allRows().locator('.file-value-cell').first()).toBeVisible();
|
const firstRow = table.allRows().first();
|
||||||
await table.allRows().first().locator('.clear-file-btn').click();
|
await expect(firstRow.locator('.file-value-cell')).toBeVisible();
|
||||||
await expect(table.allRows().first().locator('.upload-btn')).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();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user