diff --git a/packages/bruno-app/src/components/MultipartFileChipsCell/index.js b/packages/bruno-app/src/components/MultipartFileChipsCell/index.js index 62340e766..73ee8ac4e 100644 --- a/packages/bruno-app/src/components/MultipartFileChipsCell/index.js +++ b/packages/bruno-app/src/components/MultipartFileChipsCell/index.js @@ -7,6 +7,44 @@ import Wrapper, { OverflowList } from './StyledWrapper'; 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 ( + + + + {basename(filePath)} + + {editMode && ( + + )} + + ); +}; + // 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 } @@ -19,6 +57,8 @@ 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); + const [summaryOpen, setSummaryOpen] = useState(false); + const [moreOpen, setMoreOpen] = useState(false); useLayoutEffect(() => { const container = containerRef.current; @@ -59,69 +99,27 @@ const MultipartFileChipsCell = ({ files, onRemove, onAdd, editMode = true }) => const collapsed = visibleCount === 0 && files.length > 0; const renderChip = (filePath, idx) => ( - - - - {basename(filePath)} - - {editMode && ( - - )} - + editMode={editMode} + onRemove={onRemove} + /> ); const renderOverflowList = (list) => ( {list.map((p, i) => ( - - - - {basename(p)} - - {editMode && ( - - )} - + editMode={editMode} + onRemove={onRemove} + /> ))} ); @@ -133,6 +131,8 @@ const MultipartFileChipsCell = ({ files, onRemove, onAdd, editMode = true }) => document.body} + onMount={() => setSummaryOpen(true)} + onHidden={() => setSummaryOpen(false)} icon={( )} > - {renderOverflowList(files)} + {summaryOpen ? renderOverflowList(files) : null} @@ -160,6 +160,8 @@ const MultipartFileChipsCell = ({ files, onRemove, onAdd, editMode = true }) => document.body} + onMount={() => setMoreOpen(true)} + onHidden={() => setMoreOpen(false)} icon={( )} > - {renderOverflowList(overflow)} + {moreOpen ? renderOverflowList(overflow) : null} )} diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/exampleReducers.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/exampleReducers.js index 5e07f7c38..7d3d216e5 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/exampleReducers.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/exampleReducers.js @@ -22,7 +22,7 @@ export const addResponseExample = (state, action) => { } // 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) { requestBody.mode = 'none'; } diff --git a/tests/request/multipart-form/fixtures/collection/bruno.json b/tests/request/multipart-form/fixtures/collection/bruno.json new file mode 100644 index 000000000..03a26e1f7 --- /dev/null +++ b/tests/request/multipart-form/fixtures/collection/bruno.json @@ -0,0 +1,5 @@ +{ + "version": "1", + "name": "collection", + "type": "collection" +} diff --git a/tests/request/multipart-form/fixtures/collection/chip-tooltip.bru b/tests/request/multipart-form/fixtures/collection/chip-tooltip.bru new file mode 100644 index 000000000..8a819a9b0 --- /dev/null +++ b/tests/request/multipart-form/fixtures/collection/chip-tooltip.bru @@ -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) +} diff --git a/tests/request/multipart-form/init-user-data/collection-security.json b/tests/request/multipart-form/init-user-data/collection-security.json new file mode 100644 index 000000000..e8ad3e9d7 --- /dev/null +++ b/tests/request/multipart-form/init-user-data/collection-security.json @@ -0,0 +1,10 @@ +{ + "collections": [ + { + "path": "{{projectRoot}}/tests/request/multipart-form/fixtures/collection", + "securityConfig": { + "jsSandboxMode": "safe" + } + } + ] +} diff --git a/tests/request/multipart-form/init-user-data/preferences.json b/tests/request/multipart-form/init-user-data/preferences.json new file mode 100644 index 000000000..02d753451 --- /dev/null +++ b/tests/request/multipart-form/init-user-data/preferences.json @@ -0,0 +1,12 @@ +{ + "maximized": false, + "lastOpenedCollections": [ + "{{projectRoot}}/tests/request/multipart-form/fixtures/collection" + ], + "preferences": { + "onboarding": { + "hasLaunchedBefore": true, + "hasSeenWelcomeModal": true + } + } +} diff --git a/tests/request/multipart-form/multipart-chip-tooltips.spec.ts b/tests/request/multipart-form/multipart-chip-tooltips.spec.ts new file mode 100644 index 000000000..15059727b --- /dev/null +++ b/tests/request/multipart-form/multipart-chip-tooltips.spec.ts @@ -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); + }); + }); +}); diff --git a/tests/response-examples/fixtures/collection/multipart-example.bru b/tests/response-examples/fixtures/collection/multipart-example.bru index 1879d04d6..d1b3907b9 100644 --- a/tests/response-examples/fixtures/collection/multipart-example.bru +++ b/tests/response-examples/fixtures/collection/multipart-example.bru @@ -6,7 +6,7 @@ meta { post { url: https://api.example.com/upload - body: multipart-form + body: multipartForm auth: none } diff --git a/tests/response-examples/multipart-form-chips.spec.ts b/tests/response-examples/multipart-form-chips.spec.ts index c38735e16..776214a4e 100644 --- a/tests/response-examples/multipart-form-chips.spec.ts +++ b/tests/response-examples/multipart-form-chips.spec.ts @@ -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) => { 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 () => { - // 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(); diff --git a/tests/response-examples/save-as-example-multipart.spec.ts b/tests/response-examples/save-as-example-multipart.spec.ts new file mode 100644 index 000000000..a26ab180a --- /dev/null +++ b/tests/response-examples/save-as-example-multipart.spec.ts @@ -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]'); + }); + }); +});