Skip to content

Commit 8cd7c26

Browse files
authored
feat: multi-file upload in multipart form body (usebruno#7971)
1 parent 611724a commit 8cd7c26

14 files changed

Lines changed: 957 additions & 138 deletions

File tree

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
import styled from 'styled-components';
2+
3+
const Wrapper = styled.div`
4+
width: 100%;
5+
display: flex;
6+
align-items: center;
7+
min-width: 0;
8+
position: relative;
9+
10+
.file-chips-row {
11+
display: flex;
12+
flex-wrap: nowrap;
13+
align-items: center;
14+
gap: 4px;
15+
flex: 1;
16+
min-width: 0;
17+
overflow: hidden;
18+
}
19+
20+
.file-chip {
21+
display: inline-flex;
22+
align-items: center;
23+
gap: 6px;
24+
padding: 3px 6px;
25+
border-radius: 6px;
26+
background: transparent;
27+
border: 1px solid ${(props) => props.theme.input.border};
28+
font-size: 12px;
29+
line-height: 1;
30+
color: ${(props) => props.theme.text};
31+
max-width: 140px;
32+
min-width: 75px;
33+
flex: 0 1 auto;
34+
white-space: nowrap;
35+
}
36+
37+
.file-chip-icon {
38+
flex: 0 0 auto;
39+
color: ${(props) => props.theme.colors.text.muted};
40+
}
41+
42+
.file-chip-name {
43+
overflow: hidden;
44+
text-overflow: ellipsis;
45+
white-space: nowrap;
46+
flex: 1 1 auto;
47+
min-width: 0;
48+
}
49+
50+
.file-chip-remove {
51+
display: inline-flex;
52+
align-items: center;
53+
justify-content: center;
54+
padding: 1px;
55+
color: ${(props) => props.theme.colors.text.muted};
56+
background: transparent;
57+
border: none;
58+
cursor: pointer;
59+
border-radius: 3px;
60+
flex: 0 0 auto;
61+
62+
&:hover {
63+
color: ${(props) => props.theme.colors.text.danger};
64+
}
65+
}
66+
67+
.file-more-chip {
68+
display: inline-flex;
69+
align-items: center;
70+
padding: 2px 4px;
71+
background: transparent;
72+
border: none;
73+
font-size: 12px;
74+
line-height: 1;
75+
color: ${(props) => props.theme.primary.text};
76+
cursor: pointer;
77+
flex: 0 0 auto;
78+
white-space: nowrap;
79+
80+
&:hover {
81+
color: ${(props) => props.theme.primary.text};
82+
opacity: 0.8;
83+
}
84+
}
85+
86+
.file-summary-chip {
87+
display: inline-flex;
88+
align-items: center;
89+
gap: 6px;
90+
padding: 3px 6px;
91+
border-radius: 6px;
92+
background: transparent;
93+
border: 1px solid ${(props) => props.theme.input.border};
94+
font-size: 12px;
95+
line-height: 1;
96+
color: ${(props) => props.theme.text};
97+
cursor: pointer;
98+
flex: 0 1 auto;
99+
min-width: 0;
100+
white-space: nowrap;
101+
102+
> span {
103+
overflow: hidden;
104+
text-overflow: ellipsis;
105+
color: ${(props) => props.theme.text};
106+
}
107+
108+
> svg {
109+
color: ${(props) => props.theme.colors.text.muted};
110+
}
111+
112+
&:hover,
113+
&:hover > span {
114+
color: ${(props) => props.theme.text};
115+
}
116+
117+
&:hover {
118+
border-color: ${(props) => props.theme.colors.text.muted};
119+
background: ${(props) => props.theme.requestTabs.icon.hoverBg};
120+
}
121+
}
122+
123+
.upload-btn {
124+
display: flex;
125+
align-items: center;
126+
justify-content: center;
127+
padding: 4px;
128+
color: ${(props) => props.theme.colors.text.muted};
129+
background: transparent;
130+
border: none;
131+
cursor: pointer;
132+
border-radius: 4px;
133+
transition: color 0.15s ease;
134+
flex: 0 0 auto;
135+
margin-left: auto;
136+
137+
&:hover {
138+
color: ${(props) => props.theme.text};
139+
}
140+
}
141+
`;
142+
143+
export const OverflowList = styled.div`
144+
display: flex;
145+
flex-direction: column;
146+
gap: 2px;
147+
padding: 4px;
148+
max-height: 260px;
149+
overflow-y: auto;
150+
min-width: 220px;
151+
max-width: 360px;
152+
153+
.overflow-row {
154+
display: flex;
155+
align-items: center;
156+
gap: 8px;
157+
width: 100%;
158+
padding: 6px 8px;
159+
border-radius: 4px;
160+
background: transparent;
161+
font-size: 12px;
162+
line-height: 1.2;
163+
color: ${(props) => props.theme.text};
164+
165+
&:hover {
166+
background: ${(props) => props.theme.requestTabs.icon.hoverBg};
167+
}
168+
}
169+
170+
.overflow-row-icon {
171+
flex: 0 0 auto;
172+
color: ${(props) => props.theme.colors.text.muted};
173+
}
174+
175+
.overflow-row-name {
176+
flex: 1 1 auto;
177+
min-width: 0;
178+
overflow: hidden;
179+
text-overflow: ellipsis;
180+
white-space: nowrap;
181+
}
182+
183+
.overflow-row-remove {
184+
margin-left: auto;
185+
display: inline-flex;
186+
align-items: center;
187+
justify-content: center;
188+
padding: 2px;
189+
color: ${(props) => props.theme.colors.text.muted};
190+
background: transparent;
191+
border: none;
192+
cursor: pointer;
193+
border-radius: 3px;
194+
flex: 0 0 auto;
195+
196+
&:hover {
197+
color: ${(props) => props.theme.colors.text.danger};
198+
}
199+
}
200+
`;
201+
202+
export default Wrapper;
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
import React, { useLayoutEffect, useRef, useState } from 'react';
2+
import { IconUpload, IconX, IconFile, IconChevronDown } from '@tabler/icons';
3+
import Dropdown from 'components/Dropdown';
4+
import ToolHint from 'components/ToolHint';
5+
import path, { normalizePath } from 'utils/common/path';
6+
import Wrapper, { OverflowList } from './StyledWrapper';
7+
8+
const basename = (filePath) => (filePath ? path.basename(normalizePath(String(filePath))) : '');
9+
10+
// Keep in sync with the corresponding CSS values in StyledWrapper.js:
11+
// MIN_CHIP_W ↔ .file-chip { min-width: 75px }
12+
// CHIP_GAP ↔ .file-chips-row { gap: 4px }
13+
const MIN_CHIP_W = 75;
14+
const CHIP_GAP = 4;
15+
const UPLOAD_RESERVE = 28;
16+
const MORE_CHIP_RESERVE = 56;
17+
18+
const MultipartFileChipsCell = ({ files, onRemove, onAdd, editMode = true }) => {
19+
const containerRef = useRef(null);
20+
const tooltipPrefix = useRef(`mp-tip-${Math.random().toString(36).slice(2, 10)}`).current;
21+
const [visibleCount, setVisibleCount] = useState(files.length);
22+
23+
useLayoutEffect(() => {
24+
const container = containerRef.current;
25+
if (!container) return;
26+
// Measure the td (column-width, stable) rather than the content-sized cell,
27+
// which would feed back on visibleCount.
28+
const td = container.closest('td') || container.parentElement;
29+
if (!td) return;
30+
31+
const compute = () => {
32+
const tdStyle = window.getComputedStyle(td);
33+
const padX = parseFloat(tdStyle.paddingLeft) + parseFloat(tdStyle.paddingRight);
34+
const total = td.clientWidth - padX;
35+
if (files.length === 0) {
36+
setVisibleCount(0);
37+
return;
38+
}
39+
40+
const allAtMin = files.length * MIN_CHIP_W + Math.max(0, files.length - 1) * CHIP_GAP;
41+
if (allAtMin + UPLOAD_RESERVE <= total) {
42+
setVisibleCount(files.length);
43+
return;
44+
}
45+
46+
const available = total - UPLOAD_RESERVE - MORE_CHIP_RESERVE;
47+
const n = Math.max(0, Math.floor((available + CHIP_GAP) / (MIN_CHIP_W + CHIP_GAP)));
48+
setVisibleCount(n);
49+
};
50+
51+
compute();
52+
const ro = new ResizeObserver(compute);
53+
ro.observe(td);
54+
return () => ro.disconnect();
55+
}, [files]);
56+
57+
const visible = files.slice(0, visibleCount);
58+
const overflow = files.slice(visibleCount);
59+
const collapsed = visibleCount === 0 && files.length > 0;
60+
61+
const renderChip = (filePath, idx) => (
62+
<ToolHint
63+
key={`${filePath}-${idx}`}
64+
text={filePath}
65+
toolhintId={`${tooltipPrefix}-chip-${idx}`}
66+
place="bottom-start"
67+
positionStrategy="fixed"
68+
delayShow={1000}
69+
className="file-chip"
70+
dataTestId="multipart-file-chip"
71+
>
72+
<IconFile size={14} stroke={1.5} className="file-chip-icon" />
73+
<span className="file-chip-name">
74+
{basename(filePath)}
75+
</span>
76+
{editMode && (
77+
<button
78+
type="button"
79+
data-testid="multipart-file-chip-remove"
80+
className="file-chip-remove"
81+
onClick={(e) => {
82+
e.stopPropagation();
83+
onRemove(filePath);
84+
}}
85+
title="Remove file"
86+
>
87+
<IconX size={13} stroke={1.5} />
88+
</button>
89+
)}
90+
</ToolHint>
91+
);
92+
93+
const renderOverflowList = (list) => (
94+
<OverflowList>
95+
{list.map((p, i) => (
96+
<ToolHint
97+
key={`o-${p}-${i}`}
98+
text={p}
99+
toolhintId={`${tooltipPrefix}-overflow-${i}`}
100+
place="bottom-start"
101+
positionStrategy="fixed"
102+
delayShow={1000}
103+
className="overflow-row"
104+
dataTestId="multipart-file-overflow-row"
105+
>
106+
<IconFile size={14} stroke={1.5} className="overflow-row-icon" />
107+
<span className="overflow-row-name">
108+
{basename(p)}
109+
</span>
110+
{editMode && (
111+
<button
112+
type="button"
113+
data-testid="multipart-file-overflow-remove"
114+
className="overflow-row-remove"
115+
onClick={(e) => {
116+
e.stopPropagation();
117+
onRemove(p);
118+
}}
119+
title="Remove file"
120+
>
121+
<IconX size={13} stroke={1.5} />
122+
</button>
123+
)}
124+
</ToolHint>
125+
))}
126+
</OverflowList>
127+
);
128+
129+
return (
130+
<Wrapper className="file-value-cell" ref={containerRef}>
131+
{collapsed ? (
132+
<>
133+
<Dropdown
134+
placement="bottom-start"
135+
appendTo={() => document.body}
136+
icon={(
137+
<button
138+
type="button"
139+
data-testid="multipart-file-summary"
140+
className="file-summary-chip"
141+
onClick={(e) => e.stopPropagation()}
142+
title={`${files.length} file${files.length > 1 ? 's' : ''}`}
143+
>
144+
<IconFile size={14} stroke={1.5} className="file-chip-icon" />
145+
<span>{files.length} file{files.length > 1 ? 's' : ''}</span>
146+
<IconChevronDown size={14} stroke={1.5} />
147+
</button>
148+
)}
149+
>
150+
{renderOverflowList(files)}
151+
</Dropdown>
152+
153+
</>
154+
) : (
155+
<>
156+
<div className="file-chips-row">
157+
{visible.map((p, i) => renderChip(p, i))}
158+
</div>
159+
{overflow.length > 0 && (
160+
<Dropdown
161+
placement="bottom-end"
162+
appendTo={() => document.body}
163+
icon={(
164+
<button
165+
type="button"
166+
data-testid="multipart-file-more"
167+
className="file-more-chip"
168+
onClick={(e) => e.stopPropagation()}
169+
title={`${overflow.length} more file${overflow.length > 1 ? 's' : ''}`}
170+
>
171+
+{overflow.length} more
172+
</button>
173+
)}
174+
>
175+
{renderOverflowList(overflow)}
176+
</Dropdown>
177+
)}
178+
</>
179+
)}
180+
{editMode && (
181+
<button
182+
type="button"
183+
data-testid="multipart-file-upload"
184+
className="upload-btn ml-1"
185+
onClick={onAdd}
186+
title="Add files"
187+
>
188+
<IconUpload size={16} />
189+
</button>
190+
)}
191+
</Wrapper>
192+
);
193+
};
194+
195+
export default MultipartFileChipsCell;

0 commit comments

Comments
 (0)