From 8cd7c26648673b7e4c6a8994a11d8cf5f08a31bf Mon Sep 17 00:00:00 2001 From: Pooja Date: Thu, 21 May 2026 20:20:25 +0530 Subject: [PATCH] feat: multi-file upload in multipart form body (#7971) --- .../MultipartFileChipsCell/StyledWrapper.js | 202 ++++++++++++++++++ .../MultipartFileChipsCell/index.js | 195 +++++++++++++++++ .../MultipartFormParams/StyledWrapper.js | 10 +- .../RequestPane/MultipartFormParams/index.js | 105 +++++---- .../StyledWrapper.js | 8 +- .../index.js | 143 ++++++------- .../src/components/ToolHint/index.js | 5 +- .../src/utils/common/multipartContentType.js | 10 + .../utils/common/multipartContentType.spec.js | 68 ++++++ .../multipart-form-file-chips.spec.ts | 163 ++++++++++++++ .../multipart-form-file-select.spec.ts | 14 +- .../fixtures/collection/multipart-example.bru | 30 +++ .../multipart-form-chips.spec.ts | 117 ++++++++++ tests/utils/page/actions.ts | 25 ++- 14 files changed, 957 insertions(+), 138 deletions(-) create mode 100644 packages/bruno-app/src/components/MultipartFileChipsCell/StyledWrapper.js create mode 100644 packages/bruno-app/src/components/MultipartFileChipsCell/index.js create mode 100644 packages/bruno-app/src/utils/common/multipartContentType.js create mode 100644 packages/bruno-app/src/utils/common/multipartContentType.spec.js create mode 100644 tests/request/multipart-form/multipart-form-file-chips.spec.ts create mode 100644 tests/response-examples/fixtures/collection/multipart-example.bru create mode 100644 tests/response-examples/multipart-form-chips.spec.ts diff --git a/packages/bruno-app/src/components/MultipartFileChipsCell/StyledWrapper.js b/packages/bruno-app/src/components/MultipartFileChipsCell/StyledWrapper.js new file mode 100644 index 000000000..2bea4cb1a --- /dev/null +++ b/packages/bruno-app/src/components/MultipartFileChipsCell/StyledWrapper.js @@ -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; diff --git a/packages/bruno-app/src/components/MultipartFileChipsCell/index.js b/packages/bruno-app/src/components/MultipartFileChipsCell/index.js new file mode 100644 index 000000000..62340e766 --- /dev/null +++ b/packages/bruno-app/src/components/MultipartFileChipsCell/index.js @@ -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) => ( + + + + {basename(filePath)} + + {editMode && ( + + )} + + ); + + const renderOverflowList = (list) => ( + + {list.map((p, i) => ( + + + + {basename(p)} + + {editMode && ( + + )} + + ))} + + ); + + return ( + + {collapsed ? ( + <> + document.body} + icon={( + + )} + > + {renderOverflowList(files)} + + + + ) : ( + <> +
+ {visible.map((p, i) => renderChip(p, i))} +
+ {overflow.length > 0 && ( + document.body} + icon={( + + )} + > + {renderOverflowList(overflow)} + + )} + + )} + {editMode && ( + + )} +
+ ); +}; + +export default MultipartFileChipsCell; diff --git a/packages/bruno-app/src/components/RequestPane/MultipartFormParams/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/MultipartFormParams/StyledWrapper.js index b191ace70..36f85c31d 100644 --- a/packages/bruno-app/src/components/RequestPane/MultipartFormParams/StyledWrapper.js +++ b/packages/bruno-app/src/components/RequestPane/MultipartFormParams/StyledWrapper.js @@ -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%; diff --git a/packages/bruno-app/src/components/RequestPane/MultipartFormParams/index.js b/packages/bruno-app/src/components/RequestPane/MultipartFormParams/index.js index 05fb33b88..a5e6f507c 100644 --- a/packages/bruno-app/src/components/RequestPane/MultipartFormParams/index.js +++ b/packages/bruno-app/src/components/RequestPane/MultipartFormParams/index.js @@ -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 ( -
- -
- -
- -
+ handleRemoveFile(row, filePath)} + onAdd={() => handleBrowseFiles(row, onChange)} + /> ); } @@ -186,6 +202,7 @@ const MultipartFormParams = ({ item, collection }) => { /> - + handleRemoveFile(row, filePath)} + onAdd={() => handleBrowseFiles(row, onChange)} + editMode={editMode} + /> ); } diff --git a/packages/bruno-app/src/components/ToolHint/index.js b/packages/bruno-app/src/components/ToolHint/index.js index f8e9ff06b..70d1baba7 100644 --- a/packages/bruno-app/src/components/ToolHint/index.js +++ b/packages/bruno-app/src/components/ToolHint/index.js @@ -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 && {children}} + {!anchorSelect && {children}} {anchorSelect && children} { + if (!Array.isArray(files) || files.length === 0) return ''; + if (files.length === 1) { + return mime.contentType(path.extname(files[0])) || ''; + } + return 'multipart/mixed'; +}; diff --git a/packages/bruno-app/src/utils/common/multipartContentType.spec.js b/packages/bruno-app/src/utils/common/multipartContentType.spec.js new file mode 100644 index 000000000..3567fbaeb --- /dev/null +++ b/packages/bruno-app/src/utils/common/multipartContentType.spec.js @@ -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'); + }); + }); +}); diff --git a/tests/request/multipart-form/multipart-form-file-chips.spec.ts b/tests/request/multipart-form/multipart-form-file-chips.spec.ts new file mode 100644 index 000000000..638b5cd08 --- /dev/null +++ b/tests/request/multipart-form/multipart-form-file-chips.spec.ts @@ -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 => { + 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']); + }); +}); diff --git a/tests/request/multipart-form/multipart-form-file-select.spec.ts b/tests/request/multipart-form/multipart-form-file-select.spec.ts index 799df32b2..9f369014c 100644 --- a/tests/request/multipart-form/multipart-form-file-select.spec.ts +++ b/tests/request/multipart-form/multipart-form-file-select.spec.ts @@ -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 diff --git a/tests/response-examples/fixtures/collection/multipart-example.bru b/tests/response-examples/fixtures/collection/multipart-example.bru new file mode 100644 index 000000000..1879d04d6 --- /dev/null +++ b/tests/response-examples/fixtures/collection/multipart-example.bru @@ -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) + } + } +} diff --git a/tests/response-examples/multipart-form-chips.spec.ts b/tests/response-examples/multipart-form-chips.spec.ts new file mode 100644 index 000000000..c38735e16 --- /dev/null +++ b/tests/response-examples/multipart-form-chips.spec.ts @@ -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(); + }); + }); +}); diff --git a/tests/utils/page/actions.ts b/tests/utils/page/actions.ts index 60785dc85..05828f801 100644 --- a/tests/utils/page/actions.ts +++ b/tests/utils/page/actions.ts @@ -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(); }); };