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