mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-11 09:51:30 +00:00
fix(app): preserve multipart file values when creating example from r… (#8129)
This commit is contained in:
@@ -7,6 +7,44 @@ import Wrapper, { OverflowList } from './StyledWrapper';
|
|||||||
|
|
||||||
const basename = (filePath) => (filePath ? path.basename(normalizePath(String(filePath))) : '');
|
const basename = (filePath) => (filePath ? path.basename(normalizePath(String(filePath))) : '');
|
||||||
|
|
||||||
|
const FileEntry = ({ filePath, toolhintId, editMode, onRemove, variant }) => {
|
||||||
|
const [overRemove, setOverRemove] = useState(false);
|
||||||
|
const isChip = variant === 'chip';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToolHint
|
||||||
|
text={overRemove ? 'Remove file' : filePath}
|
||||||
|
toolhintId={toolhintId}
|
||||||
|
place={overRemove ? 'bottom-end' : 'bottom-start'}
|
||||||
|
positionStrategy="fixed"
|
||||||
|
tooltipStyle={{ maxWidth: '320px', whiteSpace: 'normal', wordBreak: 'break-all' }}
|
||||||
|
delayShow={overRemove ? 200 : 1000}
|
||||||
|
className={isChip ? 'file-chip' : 'overflow-row'}
|
||||||
|
dataTestId={isChip ? 'multipart-file-chip' : 'multipart-file-overflow-row'}
|
||||||
|
>
|
||||||
|
<IconFile size={14} stroke={1.5} className={isChip ? 'file-chip-icon' : 'overflow-row-icon'} />
|
||||||
|
<span className={isChip ? 'file-chip-name' : 'overflow-row-name'}>
|
||||||
|
{basename(filePath)}
|
||||||
|
</span>
|
||||||
|
{editMode && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid={isChip ? 'multipart-file-chip-remove' : 'multipart-file-overflow-remove'}
|
||||||
|
className={isChip ? 'file-chip-remove' : 'overflow-row-remove'}
|
||||||
|
onMouseEnter={() => setOverRemove(true)}
|
||||||
|
onMouseLeave={() => setOverRemove(false)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onRemove(filePath);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconX size={13} stroke={1.5} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</ToolHint>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Keep in sync with the corresponding CSS values in StyledWrapper.js:
|
// Keep in sync with the corresponding CSS values in StyledWrapper.js:
|
||||||
// MIN_CHIP_W ↔ .file-chip { min-width: 75px }
|
// MIN_CHIP_W ↔ .file-chip { min-width: 75px }
|
||||||
// CHIP_GAP ↔ .file-chips-row { gap: 4px }
|
// CHIP_GAP ↔ .file-chips-row { gap: 4px }
|
||||||
@@ -19,6 +57,8 @@ const MultipartFileChipsCell = ({ files, onRemove, onAdd, editMode = true }) =>
|
|||||||
const containerRef = useRef(null);
|
const containerRef = useRef(null);
|
||||||
const tooltipPrefix = useRef(`mp-tip-${Math.random().toString(36).slice(2, 10)}`).current;
|
const tooltipPrefix = useRef(`mp-tip-${Math.random().toString(36).slice(2, 10)}`).current;
|
||||||
const [visibleCount, setVisibleCount] = useState(files.length);
|
const [visibleCount, setVisibleCount] = useState(files.length);
|
||||||
|
const [summaryOpen, setSummaryOpen] = useState(false);
|
||||||
|
const [moreOpen, setMoreOpen] = useState(false);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
@@ -59,69 +99,27 @@ const MultipartFileChipsCell = ({ files, onRemove, onAdd, editMode = true }) =>
|
|||||||
const collapsed = visibleCount === 0 && files.length > 0;
|
const collapsed = visibleCount === 0 && files.length > 0;
|
||||||
|
|
||||||
const renderChip = (filePath, idx) => (
|
const renderChip = (filePath, idx) => (
|
||||||
<ToolHint
|
<FileEntry
|
||||||
key={`${filePath}-${idx}`}
|
key={`${filePath}-${idx}`}
|
||||||
text={filePath}
|
variant="chip"
|
||||||
|
filePath={filePath}
|
||||||
toolhintId={`${tooltipPrefix}-chip-${idx}`}
|
toolhintId={`${tooltipPrefix}-chip-${idx}`}
|
||||||
place="bottom-start"
|
editMode={editMode}
|
||||||
positionStrategy="fixed"
|
onRemove={onRemove}
|
||||||
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) => (
|
const renderOverflowList = (list) => (
|
||||||
<OverflowList>
|
<OverflowList>
|
||||||
{list.map((p, i) => (
|
{list.map((p, i) => (
|
||||||
<ToolHint
|
<FileEntry
|
||||||
key={`o-${p}-${i}`}
|
key={`o-${p}-${i}`}
|
||||||
text={p}
|
variant="overflow"
|
||||||
|
filePath={p}
|
||||||
toolhintId={`${tooltipPrefix}-overflow-${i}`}
|
toolhintId={`${tooltipPrefix}-overflow-${i}`}
|
||||||
place="bottom-start"
|
editMode={editMode}
|
||||||
positionStrategy="fixed"
|
onRemove={onRemove}
|
||||||
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>
|
</OverflowList>
|
||||||
);
|
);
|
||||||
@@ -133,6 +131,8 @@ const MultipartFileChipsCell = ({ files, onRemove, onAdd, editMode = true }) =>
|
|||||||
<Dropdown
|
<Dropdown
|
||||||
placement="bottom-start"
|
placement="bottom-start"
|
||||||
appendTo={() => document.body}
|
appendTo={() => document.body}
|
||||||
|
onMount={() => setSummaryOpen(true)}
|
||||||
|
onHidden={() => setSummaryOpen(false)}
|
||||||
icon={(
|
icon={(
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -147,7 +147,7 @@ const MultipartFileChipsCell = ({ files, onRemove, onAdd, editMode = true }) =>
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{renderOverflowList(files)}
|
{summaryOpen ? renderOverflowList(files) : null}
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
|
|
||||||
</>
|
</>
|
||||||
@@ -160,6 +160,8 @@ const MultipartFileChipsCell = ({ files, onRemove, onAdd, editMode = true }) =>
|
|||||||
<Dropdown
|
<Dropdown
|
||||||
placement="bottom-end"
|
placement="bottom-end"
|
||||||
appendTo={() => document.body}
|
appendTo={() => document.body}
|
||||||
|
onMount={() => setMoreOpen(true)}
|
||||||
|
onHidden={() => setMoreOpen(false)}
|
||||||
icon={(
|
icon={(
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -172,7 +174,7 @@ const MultipartFileChipsCell = ({ files, onRemove, onAdd, editMode = true }) =>
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{renderOverflowList(overflow)}
|
{moreOpen ? renderOverflowList(overflow) : null}
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export const addResponseExample = (state, action) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Ensure body always has a mode field (default to 'none' if not present)
|
// Ensure body always has a mode field (default to 'none' if not present)
|
||||||
const requestBody = item.draft.request.body || {};
|
const requestBody = cloneDeep(item.draft.request.body || {});
|
||||||
if (!requestBody.mode) {
|
if (!requestBody.mode) {
|
||||||
requestBody.mode = 'none';
|
requestBody.mode = 'none';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"version": "1",
|
||||||
|
"name": "collection",
|
||||||
|
"type": "collection"
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
meta {
|
||||||
|
name: chip-tooltip
|
||||||
|
type: http
|
||||||
|
seq: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
url: https://api.example.com/upload
|
||||||
|
body: multipartForm
|
||||||
|
auth: none
|
||||||
|
}
|
||||||
|
|
||||||
|
body:multipart-form {
|
||||||
|
files: @file(alpha.txt)
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"collections": [
|
||||||
|
{
|
||||||
|
"path": "{{projectRoot}}/tests/request/multipart-form/fixtures/collection",
|
||||||
|
"securityConfig": {
|
||||||
|
"jsSandboxMode": "safe"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
12
tests/request/multipart-form/init-user-data/preferences.json
Normal file
12
tests/request/multipart-form/init-user-data/preferences.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"maximized": false,
|
||||||
|
"lastOpenedCollections": [
|
||||||
|
"{{projectRoot}}/tests/request/multipart-form/fixtures/collection"
|
||||||
|
],
|
||||||
|
"preferences": {
|
||||||
|
"onboarding": {
|
||||||
|
"hasLaunchedBefore": true,
|
||||||
|
"hasSeenWelcomeModal": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
54
tests/request/multipart-form/multipart-chip-tooltips.spec.ts
Normal file
54
tests/request/multipart-form/multipart-chip-tooltips.spec.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { test, expect } from '../../../playwright';
|
||||||
|
import type { Locator } from '@playwright/test';
|
||||||
|
import { closeAllCollections } from '../../utils/page';
|
||||||
|
|
||||||
|
test.describe('Multipart Form - Chip Tooltip Swap', () => {
|
||||||
|
test.afterAll(async ({ pageWithUserData: page }) => {
|
||||||
|
await closeAllCollections(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tooltip swaps between file path and "Remove file"', async ({ pageWithUserData: page }) => {
|
||||||
|
await page.locator('#sidebar-collection-name').getByText('collection').click();
|
||||||
|
await page.locator('.collection-item-name').filter({ hasText: 'chip-tooltip' }).click();
|
||||||
|
|
||||||
|
const tooltip = page.locator('[role="tooltip"], .react-tooltip').filter({ visible: true });
|
||||||
|
|
||||||
|
const inlineChip = page.getByTestId('multipart-file-chip').first();
|
||||||
|
const summary = page.getByTestId('multipart-file-summary');
|
||||||
|
await expect(inlineChip.or(summary).first()).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
|
let nameTarget: Locator, removeBtn: Locator;
|
||||||
|
if (await summary.count()) {
|
||||||
|
await summary.click();
|
||||||
|
const row = page.getByTestId('multipart-file-overflow-row').first();
|
||||||
|
await expect(row).toBeVisible();
|
||||||
|
nameTarget = row.locator('.overflow-row-name');
|
||||||
|
removeBtn = row.getByTestId('multipart-file-overflow-remove');
|
||||||
|
} else {
|
||||||
|
nameTarget = inlineChip.locator('.file-chip-name');
|
||||||
|
removeBtn = inlineChip.getByTestId('multipart-file-chip-remove');
|
||||||
|
}
|
||||||
|
|
||||||
|
await test.step('Hover chip body → file path', async () => {
|
||||||
|
await nameTarget.hover();
|
||||||
|
await expect(tooltip.first()).toBeVisible({ timeout: 15000 });
|
||||||
|
await expect(tooltip.first()).toContainText('alpha.txt');
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Hover X → "Remove file"', async () => {
|
||||||
|
await removeBtn.hover();
|
||||||
|
await expect(tooltip.first()).toHaveText('Remove file');
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Hover back to chip body → path again', async () => {
|
||||||
|
await nameTarget.hover();
|
||||||
|
await expect(tooltip.first()).not.toHaveText('Remove file');
|
||||||
|
await expect(tooltip.first()).toContainText('alpha.txt');
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Only one tooltip visible at a time', async () => {
|
||||||
|
await removeBtn.hover();
|
||||||
|
await expect(tooltip).toHaveCount(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,7 +6,7 @@ meta {
|
|||||||
|
|
||||||
post {
|
post {
|
||||||
url: https://api.example.com/upload
|
url: https://api.example.com/upload
|
||||||
body: multipart-form
|
body: multipartForm
|
||||||
auth: none
|
auth: none
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,11 +17,6 @@ test.describe('Response Example - Multipart Form File Chips', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// `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) => {
|
const openMultipartExample = async (page: Page) => {
|
||||||
await page.locator('#sidebar-collection-name').getByText('collection').click();
|
await page.locator('#sidebar-collection-name').getByText('collection').click();
|
||||||
|
|
||||||
@@ -45,11 +40,6 @@ test.describe('Response Example - Multipart Form File Chips', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await test.step('All three files are present', async () => {
|
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 summary = page.getByTestId('multipart-file-summary');
|
||||||
const more = page.getByTestId('multipart-file-more');
|
const more = page.getByTestId('multipart-file-more');
|
||||||
const inlineNames = await page.getByTestId('multipart-file-chip').allTextContents();
|
const inlineNames = await page.getByTestId('multipart-file-chip').allTextContents();
|
||||||
|
|||||||
59
tests/response-examples/save-as-example-multipart.spec.ts
Normal file
59
tests/response-examples/save-as-example-multipart.spec.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { test, expect } from '../../playwright';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
const fixturePath = path.join(__dirname, 'fixtures', 'collection', 'multipart-example.bru');
|
||||||
|
|
||||||
|
test.describe('Response Example - multipart files preserved when creating example from request', () => {
|
||||||
|
// Snapshot the fixture so we restore the exact working-tree state (including
|
||||||
|
// any uncommitted changes), not whatever HEAD has.
|
||||||
|
let originalFixture: string;
|
||||||
|
|
||||||
|
test.beforeAll(() => {
|
||||||
|
originalFixture = fs.readFileSync(fixturePath, 'utf8');
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(() => {
|
||||||
|
fs.writeFileSync(fixturePath, originalFixture);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('file chips render real names, not "[Circular]"', async ({ pageWithUserData: page }) => {
|
||||||
|
await test.step('Open the multipart request', async () => {
|
||||||
|
await page.locator('#sidebar-collection-name').getByText('collection').click();
|
||||||
|
await page.locator('.collection-item-name').filter({ hasText: 'multipart-example' }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Open the 3-dot menu and pick "Create Example"', async () => {
|
||||||
|
const requestRow = page.locator('.collection-item-name').filter({ hasText: 'multipart-example' });
|
||||||
|
await requestRow.hover();
|
||||||
|
await requestRow.locator('.menu-icon').click({ force: true });
|
||||||
|
await page.locator('[role="menuitem"][data-item-id="create-example"]').click();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Fill the modal and submit', async () => {
|
||||||
|
await page.getByTestId('create-example-name-input').clear();
|
||||||
|
await page.getByTestId('create-example-name-input').fill('Created From Request');
|
||||||
|
await page.getByRole('button', { name: 'Create Example' }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Example tab opens with the right title', async () => {
|
||||||
|
const title = page.getByTestId('response-example-title');
|
||||||
|
await expect(title).toBeVisible();
|
||||||
|
await expect(title).toContainText('Created From Request');
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('File chips show real names', async () => {
|
||||||
|
// Read whichever layout shows up: inline chips or the collapsed summary dropdown.
|
||||||
|
const chips = page.getByTestId('multipart-file-chip');
|
||||||
|
let names = await chips.allTextContents();
|
||||||
|
|
||||||
|
if (names.length === 0) {
|
||||||
|
await page.getByTestId('multipart-file-summary').click();
|
||||||
|
names = await page.getByTestId('multipart-file-overflow-row').allTextContents();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(names).toEqual(['alpha.txt', 'beta.txt', 'gamma.txt']);
|
||||||
|
expect(names).not.toContain('[Circular]');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user