From 9d65fb1a9395653cfc582805cdb013f9a2d36daa Mon Sep 17 00:00:00 2001 From: Sundram Gupta Date: Mon, 7 Sep 2026 16:43:23 +0530 Subject: [PATCH 1/7] fix(playground): close script error cards one at a time and keep the response content scrollable below them --- .../tests/playground/script-execution.spec.ts | 37 ++++++++++++++++++ .../ResponsePane/ResponsePane.tsx | 38 +++++++++++-------- .../ResponsePane/StyledWrapper.ts | 11 ++++++ 3 files changed, 70 insertions(+), 16 deletions(-) diff --git a/packages/bruno-api-docs/e2e/tests/playground/script-execution.spec.ts b/packages/bruno-api-docs/e2e/tests/playground/script-execution.spec.ts index a05d209d..862039d9 100644 --- a/packages/bruno-api-docs/e2e/tests/playground/script-execution.spec.ts +++ b/packages/bruno-api-docs/e2e/tests/playground/script-execution.spec.ts @@ -35,6 +35,10 @@ const axios = require('axios'); await axios.get('https://unreachable.invalid/get'); `; +const REQUIRE_LODASH_POST_RESPONSE_SCRIPT = ` +const _ = require('lodash'); +`; + const REQUIRE_LODASH_PRE_REQUEST_SCRIPT = ` const _ = require('lodash'); `; @@ -97,6 +101,39 @@ test.describe('playground script execution', () => { await expect(responsePane.bodyEditor.surface).toBeVisible(); }); + test('each script error card closes on its own and the body keeps its height below the cards', async ({ page, playground, responsePane }) => { + await page.setViewportSize({ width: 1280, height: 640 }); + await responsePane.mockUsersResponse(JSON.stringify({ users: [] })); + + await page.goto('/#/?pg=1&dock=bottom'); + await playground.openSidebarItem('get users'); + await playground.selectTab('scripts'); + await page.getByTestId('scripts-tabs-tab-post-response').click(); + await setEditorScript(page, playground.postResponseScriptEditor, REQUIRE_LODASH_POST_RESPONSE_SCRIPT); + await playground.selectTab('tests'); + await setEditorScript(page, playground.testsEditor, REQUIRE_FS_TESTS_SCRIPT); + + await responsePane.send(); + + await expect(responsePane.scriptErrors.getByTestId('error-title')).toHaveText(['Post-Response Script Error', 'Test Script Error']); + await expect.poll(() => responsePane.bodyEditor.surface.evaluate((el) => el.clientHeight)).toBeGreaterThan(100); + await responsePane.bodyPanel.evaluate((panel) => { panel.scrollTop = panel.scrollHeight; }); + await expect.poll(() => responsePane.bodyPanel.evaluate((panel) => { + const editor = panel.querySelector('[data-testid="response-body-editor"]') as HTMLElement; + return panel.getBoundingClientRect().bottom - editor.getBoundingClientRect().bottom; + })).toBeGreaterThanOrEqual(15); + + await responsePane.switchToTab('tests'); + const summary = responsePane.testsPanel.getByText('Tests (3), Passed: 1, Failed: 2'); + await summary.scrollIntoViewIfNeeded(); + await expect(summary).toBeInViewport(); + + await responsePane.testsScriptErrors.getByTestId('error-banner-dismiss').first().click(); + await expect(responsePane.testsScriptErrors.getByTestId('error-title')).toHaveText(['Test Script Error']); + await responsePane.switchToTab('response'); + await expect(responsePane.scriptErrors.getByTestId('error-title')).toHaveText(['Test Script Error']); + }); + test('a pre-request script that throws shows a Pre-Request Script Error card instead of a response', async ({ page, playground, responsePane }) => { await page.goto('/#/?pg=1&dock=bottom'); await playground.openSidebarItem('get users'); diff --git a/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponsePane.tsx b/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponsePane.tsx index 4237a9ec..1b46bee1 100644 --- a/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponsePane.tsx +++ b/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponsePane.tsx @@ -27,7 +27,7 @@ interface ResponsePaneProps { const ResponsePane: React.FC = ({ response, isLoading, orientation, itemUuid }) => { const [activeTab, setActiveTab] = useState('response'); - const [dismissedScriptErrorsRequestId, setDismissedScriptErrorsRequestId] = useState(); + const [dismissedScriptErrorKeys, setDismissedScriptErrorKeys] = useState([]); const { actionsExpandedWidth, measureActions } = useResponseActions(); const { @@ -73,8 +73,10 @@ const ResponsePane: React.FC = ({ response, isLoading, orient ); - const scriptErrorsDismissed = dismissedScriptErrorsRequestId === response.requestId; - const scriptErrors = scriptErrorsDismissed ? [] : (response.scriptErrors ?? []); + const scriptErrorKey = (phase: string) => `${response.requestId}:${phase}`; + const scriptErrors = (response.scriptErrors ?? []).filter( + (scriptError) => !dismissedScriptErrorKeys.includes(scriptErrorKey(scriptError.phase)) + ); const renderScriptErrors = (testId: string) => scriptErrors.length ? (
@@ -83,7 +85,7 @@ const ResponsePane: React.FC = ({ response, isLoading, orient key={scriptError.phase} title={SCRIPT_ERROR_TITLES[scriptError.phase]} message={scriptError.message} - onDismiss={() => setDismissedScriptErrorsRequestId(response.requestId)} + onDismiss={() => setDismissedScriptErrorKeys((keys) => [...keys, scriptErrorKey(scriptError.phase)])} /> ))}
@@ -97,24 +99,28 @@ const ResponsePane: React.FC = ({ response, isLoading, orient ) : null} - {response.error ? renderErrorBanner() : ( - - )} +
+ {response.error ? renderErrorBanner() : ( + + )} +
); const renderHeaders = () => ; const renderTestResults = () => (
{renderScriptErrors('tests-script-errors')} - +
+ +
); diff --git a/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/StyledWrapper.ts b/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/StyledWrapper.ts index 95847dba..54259a40 100644 --- a/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/StyledWrapper.ts +++ b/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/StyledWrapper.ts @@ -10,6 +10,17 @@ export const StyledWrapper = styled.div` overflow-y: auto; } + .tab-panel-content { + display: flex; + flex-direction: column; + height: 100%; + flex-shrink: 0; + } + + .tab-panel-content:not(:first-child) { + padding-bottom: 1rem; + } + .tabs-right { gap: 0.75rem; } From 9b740f22c8014001a2aa4c3519863a8a185587b0 Mon Sep 17 00:00:00 2001 From: Sundram Gupta Date: Thu, 17 Sep 2026 01:40:08 +0530 Subject: [PATCH 2/7] fix(playground): key-value table parity with the desktop app Tables cap at 24rem and scroll inside the container; cell hover tooltips and name title attributes are gone. Multiline values grow to fit instead of scrolling, re-fit when a column is dragged narrower, and share one line-height with single-line inputs so rows keep their height. The header-name suggestions list gets the thin themed scrollbar. Assertions gain the Description column with round-trip persistence and the app's short column labels. The first column header starts exactly where the cell text starts, and query params say Name instead of Key. KeyValueTable styles move into an Emotion StyledWrapper like every other component; the legacy .text-input rules are dropped because HighlightedInput owns those fields. --- .../key-value-table.component.ts | 4 + .../e2e/components/playground.component.ts | 10 +- .../tests/playground/env-table-values.spec.ts | 41 +++ .../tests/playground/key-value-table.spec.ts | 155 ++++++++++ .../tests/playground/keyvalue-table.spec.ts | 91 ------ .../playground-descriptions.spec.ts | 18 +- .../HighlightedInput/HighlightedInput.tsx | 26 +- .../HighlightedInput/StyledWrapper.ts | 31 +- .../KeyValueTable/KeyValueTable.css | 289 ------------------ .../KeyValueTable/KeyValueTable.spec.tsx | 24 +- .../KeyValueTable/KeyValueTable.tsx | 46 ++- .../components/KeyValueTable/StyledWrapper.ts | 267 ++++++++++++++++ .../Common/AssertsTab/AssertsTab.spec.tsx | 33 +- .../Views/Common/AssertsTab/AssertsTab.tsx | 53 +--- .../Views/Common/ParamsTab/ParamsTab.spec.tsx | 13 +- .../Views/Common/ParamsTab/ParamsTab.tsx | 10 +- .../src/e2eFixtures/descriptionsCollection.ts | 9 +- packages/bruno-api-docs/src/test-utils/dom.ts | 4 + .../src/utils/assertionRows.spec.ts | 68 +++++ .../bruno-api-docs/src/utils/assertionRows.ts | 47 +++ 20 files changed, 747 insertions(+), 492 deletions(-) create mode 100644 packages/bruno-api-docs/e2e/tests/playground/env-table-values.spec.ts create mode 100644 packages/bruno-api-docs/e2e/tests/playground/key-value-table.spec.ts delete mode 100644 packages/bruno-api-docs/e2e/tests/playground/keyvalue-table.spec.ts delete mode 100644 packages/bruno-api-docs/src/components/KeyValueTable/KeyValueTable.css create mode 100644 packages/bruno-api-docs/src/components/KeyValueTable/StyledWrapper.ts create mode 100644 packages/bruno-api-docs/src/utils/assertionRows.spec.ts create mode 100644 packages/bruno-api-docs/src/utils/assertionRows.ts diff --git a/packages/bruno-api-docs/e2e/components/key-value-table/key-value-table.component.ts b/packages/bruno-api-docs/e2e/components/key-value-table/key-value-table.component.ts index 35a18b14..33396c2d 100644 --- a/packages/bruno-api-docs/e2e/components/key-value-table/key-value-table.component.ts +++ b/packages/bruno-api-docs/e2e/components/key-value-table/key-value-table.component.ts @@ -6,8 +6,10 @@ export class KeyValueTableComponent extends BaseComponent { readonly container: Locator; readonly table: Locator; readonly nameInputs: Locator; + readonly nameTexts: Locator; readonly valueInputs: Locator; readonly descriptionInputs: Locator; + readonly nameHeader: Locator; readonly descriptionHeader: Locator; readonly cellErrors: Locator; readonly autocomplete: Locator; @@ -18,8 +20,10 @@ export class KeyValueTableComponent extends BaseComponent { this.container = page.getByTestId(`${testId}-container`); this.table = page.getByTestId(`${testId}-table`); this.nameInputs = page.getByTestId(`${testId}-name-input`); + this.nameTexts = page.getByTestId(`${testId}-name-text`); this.valueInputs = page.getByTestId(`${testId}-value-input`); this.descriptionInputs = page.getByTestId(`${testId}-description-input`); + this.nameHeader = page.getByTestId(`${testId}-name-header`); this.descriptionHeader = page.getByTestId(`${testId}-description-header`); this.cellErrors = page.getByTestId(`${testId}-error`); this.autocomplete = page.getByTestId('variable-autocomplete'); diff --git a/packages/bruno-api-docs/e2e/components/playground.component.ts b/packages/bruno-api-docs/e2e/components/playground.component.ts index b8e5df11..2b6b19ae 100644 --- a/packages/bruno-api-docs/e2e/components/playground.component.ts +++ b/packages/bruno-api-docs/e2e/components/playground.component.ts @@ -12,6 +12,7 @@ import type { DockMode } from '../../src/utils/playgroundDock'; export class PlaygroundComponent extends BaseComponent { readonly keyValueTable = new KeyValueTableComponent(this.page); readonly preRequestVars = new KeyValueTableComponent(this.page, 'variables-pre-request'); + readonly pathParams = new KeyValueTableComponent(this.page, 'params-path'); // The Auth tab lives inside the playground request pane; open it with selectTab('auth'). readonly auth = new RequestAuthComponent(this.page); readonly methodSelector = new MethodSelectorComponent(this.page); @@ -140,8 +141,13 @@ export class PlaygroundComponent extends BaseComponent { async selectTab(id: string): Promise { const direct = this.tab(id); if ((await direct.count()) > 0 && (await direct.isVisible())) { - await direct.click(); - return; + try { + await direct.click({ timeout: 1000 }); + return; + } catch { + // The responsive tab bar re-measures a frame after a switch and may have just moved this + // tab into the overflow menu, detaching the button we resolved. + } } await this.page.getByTestId('tabs-more').click(); await this.page.getByTestId(`tabs-more-${id}`).click(); diff --git a/packages/bruno-api-docs/e2e/tests/playground/env-table-values.spec.ts b/packages/bruno-api-docs/e2e/tests/playground/env-table-values.spec.ts new file mode 100644 index 00000000..ab9502c2 --- /dev/null +++ b/packages/bruno-api-docs/e2e/tests/playground/env-table-values.spec.ts @@ -0,0 +1,41 @@ +import { test, expect } from '../../playwright'; + +// The bottom dock renders environments as a KeyValueTable (the inline dock uses cards instead). +test.describe('Environment variables: value cells (table view)', () => { + test.beforeEach(async ({ playground }) => { + await playground.open('bottom'); + await playground.openEnvironments(); + await expect(playground.keyValueTable.root).toBeVisible(); + }); + + test('a long multi-line value grows its cell to fit instead of scrolling inside it', async ({ playground }) => { + const valueInput = playground.keyValueTable.valueInputs.first(); + const oneLineHeight = (await valueInput.boundingBox())!.height; + + const lines = Array.from({ length: 16 }, (_, i) => `line ${i + 1}`); + await valueInput.fill(lines.join('\n')); + + await expect.poll(async () => (await valueInput.boundingBox())!.height).toBeGreaterThan(oneLineHeight * 4); + await expect.poll(() => valueInput.evaluate((el) => el.scrollHeight > el.clientHeight + 1)).toBe(false); + }); + + test('a wrapped value re-fits its cell after the value column is dragged narrower', async ({ page, playground }) => { + const { keyValueTable } = playground; + const valueInput = keyValueTable.valueInputs.first(); + await valueInput.fill('word '.repeat(60).trim()); + await expect.poll(() => valueInput.evaluate((el) => el.scrollHeight > el.clientHeight + 1)).toBe(false); + const widthBefore = await valueInput.evaluate((el) => el.clientWidth); + + // Drag the Name/Value divider to the right so the value column shrinks and the text wraps onto more lines. + const handle = keyValueTable.resizeHandles.first(); + await handle.scrollIntoViewIfNeeded(); + const box = (await handle.boundingBox())!; + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + await page.mouse.move(box.x + box.width / 2 + 200, box.y + box.height / 2, { steps: 8 }); + await page.mouse.up(); + + await expect.poll(() => valueInput.evaluate((el) => el.clientWidth)).toBeLessThan(widthBefore - 50); + await expect.poll(() => valueInput.evaluate((el) => el.scrollHeight > el.clientHeight + 1)).toBe(false); + }); +}); diff --git a/packages/bruno-api-docs/e2e/tests/playground/key-value-table.spec.ts b/packages/bruno-api-docs/e2e/tests/playground/key-value-table.spec.ts new file mode 100644 index 00000000..5116700f --- /dev/null +++ b/packages/bruno-api-docs/e2e/tests/playground/key-value-table.spec.ts @@ -0,0 +1,155 @@ +import { test, expect } from '../../playwright'; + +/** Where an element's text begins: its left edge plus border and padding, as the browser lays it out. */ +const textStart = (el: HTMLElement) => + el.getBoundingClientRect().left + + parseFloat(getComputedStyle(el).borderLeftWidth) + + parseFloat(getComputedStyle(el).paddingLeft); + +test.describe('KeyValueTable: cells, scrolling and layout', () => { + test.beforeEach(async ({ page, playground }) => { + await page.goto('/#/?pg=1&dock=bottom'); + await playground.openSidebarItem('get users'); + await playground.selectTab('headers'); + await expect(playground.keyValueTable.root).toBeVisible(); + }); + + test('a cell shows neither a native title nor a hover tooltip, even when its text is clipped', async ({ page, playground, tooltip }) => { + const { keyValueTable } = playground; + // Pin the trailing blank row by index: naming it appends a new blank row, so `.last()` would move on. + const blankRowIndex = (await keyValueTable.nameInputs.count()) - 1; + const nameInput = keyValueTable.nameInputs.nth(blankRowIndex); + await nameInput.fill('X-A-Very-Long-Custom-Header-Name-That-Overflows-Its-Cell-Width'); + await expect(nameInput).toHaveValue(/Overflows/); + await expect(nameInput).not.toHaveAttribute('title', /.+/); + + const valueInput = keyValueTable.valueInputs.nth(blankRowIndex); + await valueInput.fill('a value long enough to overflow the value cell and be clipped by the column width in the table'); + await valueInput.hover(); + await page.waitForTimeout(400); + await expect(tooltip.popup).toHaveCount(0); + }); + + test('the table caps its height and scrolls its rows inside the container', async ({ playground }) => { + const { keyValueTable } = playground; + for (let i = 0; i < 14; i++) { + await keyValueTable.nameInputs.last().fill(`X-Row-${i}`); + } + await expect(keyValueTable.container).toHaveCSS('max-height', '384px'); // 24rem @16px + await expect + .poll(() => keyValueTable.container.evaluate((el) => el.scrollHeight > el.clientHeight + 1)) + .toBe(true); + }); + + test('the first column header lines up with the name text in the rows below it', async ({ playground }) => { + const { keyValueTable } = playground; + const headerStart = await keyValueTable.nameHeader.evaluate(textStart); + const cellStart = await keyValueTable.nameInputs.first().evaluate(textStart); + expect(Math.abs(headerStart - cellStart)).toBeLessThan(0.5); + }); + + test('a one-line description cell is exactly as tall as the name cell beside it', async ({ playground }) => { + const { keyValueTable } = playground; + const nameHeight = (await keyValueTable.nameInputs.first().boundingBox())!.height; + const descriptionHeight = (await keyValueTable.descriptionInputs.first().boundingBox())!.height; + expect(Math.abs(descriptionHeight - nameHeight)).toBeLessThan(0.5); + }); + + test('a read-only path-param key lines up with its header and matches the value cell height', async ({ playground }) => { + await playground.openSidebarItem('Jokes'); + await playground.selectTab('params'); + const { pathParams } = playground; + await expect(pathParams.nameTexts.first()).toHaveText('postId'); + + const headerStart = await pathParams.nameHeader.evaluate(textStart); + const keyStart = await pathParams.nameTexts.first().evaluate(textStart); + expect(Math.abs(headerStart - keyStart)).toBeLessThan(0.5); + + const keyHeight = (await pathParams.nameTexts.first().boundingBox())!.height; + const valueHeight = (await pathParams.valueInputs.first().boundingBox())!.height; + expect(Math.abs(keyHeight - valueHeight)).toBeLessThan(0.5); + }); + + test('the query params table labels its first column Name, like the app', async ({ playground }) => { + await playground.selectTab('params'); + await expect(playground.keyValueTable.nameHeader).toHaveText('Name'); + }); + + test('the table has a min-width and a horizontally-scrollable container', async ({ page, playground }) => { + const { keyValueTable } = playground; + await expect(keyValueTable.container).toHaveCSS('overflow-x', 'auto'); + await expect(keyValueTable.table).toHaveCSS('min-width', '448px'); // 28rem @16px + + // Narrow the viewport below the min-width → the container actually overflows and scrolls. + await page.setViewportSize({ width: 360, height: 800 }); + const overflows = await keyValueTable.container.evaluate((el) => el.scrollWidth > el.clientWidth + 1); + expect(overflows).toBe(true); + }); + + test('offers {{variable}} autocomplete in the value cell but not the name cell', async ({ page, playground }) => { + const { keyValueTable } = playground; + // Value cell: a `{{` reference surfaces the collection's variables. + await keyValueTable.valueInputs.last().click(); + await page.keyboard.type('{{coll'); + await expect(keyValueTable.autocomplete).toBeVisible(); + + await page.keyboard.press('Escape'); + await expect(keyValueTable.autocomplete).toHaveCount(0); + + // Name cell: the same reference must not open the dropdown. The app only + // autocompletes variables in value cells, never in param/variable name cells. + await keyValueTable.nameInputs.last().click(); + await page.keyboard.type('{{coll'); + await page.waitForTimeout(250); + await expect(keyValueTable.autocomplete).toHaveCount(0); + }); + + test('the header-name suggestions list scrolls with the thin themed scrollbar', async ({ page, playground }) => { + const { keyValueTable } = playground; + await keyValueTable.nameInputs.last().click(); + await page.keyboard.type('Content'); + await expect(keyValueTable.autocomplete).toBeVisible(); + await expect(keyValueTable.autocomplete).toHaveCSS('scrollbar-width', 'thin'); + }); + + test('flags a header name that contains a space with an inline error', async ({ page, playground }) => { + const { keyValueTable } = playground; + await keyValueTable.nameInputs.last().click(); + await page.keyboard.type('Bad Name'); + const error = keyValueTable.cellErrors.first(); + await expect(error).toBeVisible(); + await expect(error).toHaveAttribute('aria-label', 'Header name cannot contain spaces or newlines'); + }); + + test('a named row can be enabled and disabled via its checkbox', async ({ playground }) => { + const { keyValueTable } = playground; + // The trailing blank row has no checkbox; naming a row promotes it to a real row with one. + await keyValueTable.nameInputs.last().fill('X-Custom'); + + const toggle = keyValueTable.enableToggle('X-Custom'); + await expect(toggle).toBeChecked(); + + await toggle.uncheck(); + await expect(toggle).not.toBeChecked(); + + await toggle.check(); + await expect(toggle).toBeChecked(); + }); + + test('columns are resizable by dragging a header divider', async ({ page, playground }) => { + const { keyValueTable } = playground; + const valueHeader = keyValueTable.columnHeader('col-value'); + const before = (await valueHeader.boundingBox())!.width; + + // Drag the Name/Value divider (the first handle) to the right: Name grows and Value shrinks by + // the same amount (zero-sum), so the Value column gets measurably narrower. + const handle = keyValueTable.resizeHandles.first(); + const box = (await handle.boundingBox())!; + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + await page.mouse.move(box.x + box.width / 2 + 80, box.y + box.height / 2, { steps: 6 }); + await page.mouse.up(); + + await expect.poll(async () => (await valueHeader.boundingBox())!.width).toBeLessThan(before - 30); + }); +}); diff --git a/packages/bruno-api-docs/e2e/tests/playground/keyvalue-table.spec.ts b/packages/bruno-api-docs/e2e/tests/playground/keyvalue-table.spec.ts deleted file mode 100644 index 38dfcd51..00000000 --- a/packages/bruno-api-docs/e2e/tests/playground/keyvalue-table.spec.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { test, expect } from '../../playwright'; - -test.describe('KeyValueTable — tooltips & mobile scroll', () => { - test.beforeEach(async ({ page, playground }) => { - await page.goto('/#/?pg=1&dock=bottom'); - await playground.openSidebarItem('get users'); - await playground.selectTab('headers'); - await expect(playground.keyValueTable.root).toBeVisible(); - }); - - test('a typed cell carries a native title tooltip with its full value', async ({ page, playground }) => { - const { keyValueTable } = playground; - const nameInput = keyValueTable.nameInputs.first(); - await nameInput.click(); - await page.keyboard.type('-A-Very-Long-Custom-Header-Name-That-Truncates'); - // The full value is exposed as a native title tooltip so a truncated cell stays readable. - const value = await nameInput.inputValue(); - expect(value.length).toBeGreaterThan(0); - await expect(nameInput).toHaveAttribute('title', value); - }); - - test('the table has a min-width and a horizontally-scrollable container', async ({ page, playground }) => { - const { keyValueTable } = playground; - await expect(keyValueTable.container).toHaveCSS('overflow-x', 'auto'); - await expect(keyValueTable.table).toHaveCSS('min-width', '448px'); // 28rem @16px - - // Narrow the viewport below the min-width → the container actually overflows and scrolls. - await page.setViewportSize({ width: 360, height: 800 }); - const overflows = await keyValueTable.container.evaluate((el) => el.scrollWidth > el.clientWidth + 1); - expect(overflows).toBe(true); - }); - - test('offers {{variable}} autocomplete in the value cell but not the name cell', async ({ page, playground }) => { - const { keyValueTable } = playground; - // Value cell: a `{{` reference surfaces the collection's variables. - await keyValueTable.valueInputs.last().click(); - await page.keyboard.type('{{coll'); - await expect(keyValueTable.autocomplete).toBeVisible(); - - await page.keyboard.press('Escape'); - await expect(keyValueTable.autocomplete).toHaveCount(0); - - // Name cell: the same reference must not open the dropdown — the app only - // autocompletes variables in value cells, never in param/variable name cells. - await keyValueTable.nameInputs.last().click(); - await page.keyboard.type('{{coll'); - await page.waitForTimeout(250); - await expect(keyValueTable.autocomplete).toHaveCount(0); - }); - - test('flags a header name that contains a space with an inline error', async ({ page, playground }) => { - const { keyValueTable } = playground; - await keyValueTable.nameInputs.last().click(); - await page.keyboard.type('Bad Name'); - const error = keyValueTable.cellErrors.first(); - await expect(error).toBeVisible(); - await expect(error).toHaveAttribute('aria-label', 'Header name cannot contain spaces or newlines'); - }); - - test('a named row can be enabled and disabled via its checkbox', async ({ playground }) => { - const { keyValueTable } = playground; - // The trailing blank row has no checkbox; naming a row promotes it to a real row with one. - await keyValueTable.nameInputs.last().fill('X-Custom'); - - const toggle = keyValueTable.enableToggle('X-Custom'); - await expect(toggle).toBeChecked(); - - await toggle.uncheck(); - await expect(toggle).not.toBeChecked(); - - await toggle.check(); - await expect(toggle).toBeChecked(); - }); - - test('columns are resizable by dragging a header divider', async ({ page, playground }) => { - const { keyValueTable } = playground; - const valueHeader = keyValueTable.columnHeader('col-value'); - const before = (await valueHeader.boundingBox())!.width; - - // Drag the Name/Value divider (the first handle) to the right: Name grows and Value shrinks by - // the same amount (zero-sum), so the Value column gets measurably narrower. - const handle = keyValueTable.resizeHandles.first(); - const box = (await handle.boundingBox())!; - await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); - await page.mouse.down(); - await page.mouse.move(box.x + box.width / 2 + 80, box.y + box.height / 2, { steps: 6 }); - await page.mouse.up(); - - await expect.poll(async () => (await valueHeader.boundingBox())!.width).toBeLessThan(before - 30); - }); -}); diff --git a/packages/bruno-api-docs/e2e/tests/playground/playground-descriptions.spec.ts b/packages/bruno-api-docs/e2e/tests/playground/playground-descriptions.spec.ts index 2293398f..2d0646cb 100644 --- a/packages/bruno-api-docs/e2e/tests/playground/playground-descriptions.spec.ts +++ b/packages/bruno-api-docs/e2e/tests/playground/playground-descriptions.spec.ts @@ -1,6 +1,6 @@ import { test, expect } from '../../playwright'; -// A request whose params, headers, variables and form body all carry authored descriptions. +// A request whose params, headers, variables, assertions and form body all carry authored descriptions. const DESCRIBED = '/?fixture=descriptions#/?pg=1&dock=bottom'; test.describe('Playground — field descriptions', () => { @@ -29,6 +29,22 @@ test.describe('Playground — field descriptions', () => { await expect(playground.preRequestVars.descriptionInputs.first()).toHaveValue('The order identifier under test'); }); + test('assertions show a Description column with the authored text', async ({ playground }) => { + await playground.selectTab('assertions'); + await expect(playground.keyValueTable.descriptionHeader).toBeVisible(); + await expect(playground.keyValueTable.descriptionInputs.first()).toHaveValue('Creating an order returns Created'); + }); + + test('an assertion description is editable and the edit persists across a tab switch', async ({ playground }) => { + await playground.selectTab('assertions'); + const descriptionInput = playground.keyValueTable.descriptionInputs.first(); + await descriptionInput.fill('Created, with the new order in the body'); + await expect(descriptionInput).toHaveValue('Created, with the new order in the body'); + await playground.selectTab('headers'); + await playground.selectTab('assertions'); + await expect(playground.keyValueTable.descriptionInputs.first()).toHaveValue('Created, with the new order in the body'); + }); + test('form-urlencoded body fields show the authored description', async ({ playground }) => { await playground.selectTab('body'); await expect(playground.keyValueTable.descriptionInputs.first()).toHaveValue('The OAuth2 grant type to use'); diff --git a/packages/bruno-api-docs/src/components/HighlightedInput/HighlightedInput.tsx b/packages/bruno-api-docs/src/components/HighlightedInput/HighlightedInput.tsx index 989ca1d0..8984d968 100644 --- a/packages/bruno-api-docs/src/components/HighlightedInput/HighlightedInput.tsx +++ b/packages/bruno-api-docs/src/components/HighlightedInput/HighlightedInput.tsx @@ -22,7 +22,6 @@ interface HighlightedInputProps { names: string[]; anywordHints?: string[]; variablesAutocomplete?: boolean; - title?: string; testId?: string; multiline?: boolean; /** Key handler forwarded only when the autocomplete dropdown is closed (e.g. Enter-to-send). */ @@ -81,7 +80,6 @@ export const HighlightedInput: React.FC = ({ names, anywordHints, variablesAutocomplete = true, - title, testId, multiline = false, onKeyDown, @@ -186,17 +184,34 @@ export const HighlightedInput: React.FC = ({ } }, [value]); - useLayoutEffect(() => { + const fitFieldHeight = useCallback(() => { const el = inputRef.current; if (!multiline || !el) return; el.style.height = 'auto'; - el.style.height = `${el.scrollHeight}px`; + // scrollHeight excludes the border, which border-box sizing would otherwise take from the content. + el.style.height = `${el.scrollHeight + el.offsetHeight - el.clientHeight}px`; const mirror = mirrorRef.current; if (mirror) { mirror.scrollTop = el.scrollTop; mirror.scrollLeft = el.scrollLeft; } - }, [value, multiline]); + }, [multiline]); + + useLayoutEffect(fitFieldHeight, [value, fitFieldHeight]); + + // A narrower column re-wraps the text onto more lines, so the height must follow width changes too. + useEffect(() => { + const el = inputRef.current; + if (!multiline || !el || typeof ResizeObserver === 'undefined') return; + let width = el.clientWidth; + const observer = new ResizeObserver(() => { + if (el.clientWidth === width) return; + width = el.clientWidth; + fitFieldHeight(); + }); + observer.observe(el); + return () => observer.disconnect(); + }, [multiline, fitFieldHeight]); useLayoutEffect(() => { if (!hovered || !cardEl) { @@ -358,7 +373,6 @@ export const HighlightedInput: React.FC = ({ 'className': 'text-input', 'data-testid': testId, value, - title, placeholder, 'onChange': handleChange, 'onKeyDown': handleKeyDown, diff --git a/packages/bruno-api-docs/src/components/HighlightedInput/StyledWrapper.ts b/packages/bruno-api-docs/src/components/HighlightedInput/StyledWrapper.ts index dcfd8ef2..01fa941c 100644 --- a/packages/bruno-api-docs/src/components/HighlightedInput/StyledWrapper.ts +++ b/packages/bruno-api-docs/src/components/HighlightedInput/StyledWrapper.ts @@ -19,7 +19,7 @@ export const StyledWrapper = styled.div` padding: 0.625rem 0; font-size: 0.8125rem; font-weight: 400; - line-height: 1; + line-height: 1.25; letter-spacing: 0; border-radius: 0.25rem; transition: all 0.15s ease; @@ -52,7 +52,7 @@ export const StyledWrapper = styled.div` font-family: inherit; font-size: 0.8125rem; font-weight: 400; - line-height: 1; + line-height: 1.25; letter-spacing: 0; color: var(--oc-table-input-color); } @@ -71,22 +71,13 @@ export const StyledWrapper = styled.div` &.highlight-input--multiline .text-input { resize: none; - overflow-x: hidden; - overflow-y: auto; - max-height: 12.5rem; - line-height: 1.4; + overflow: hidden; white-space: pre-wrap; overflow-wrap: break-word; - scrollbar-width: none; - } - - &.highlight-input--multiline .text-input::-webkit-scrollbar { - display: none; } &.highlight-input--multiline .highlight-input-mirror { overflow: hidden; - line-height: 1.4; white-space: pre-wrap; overflow-wrap: break-word; } @@ -95,6 +86,11 @@ export const StyledWrapper = styled.div` overflow-x: auto; white-space: pre; overflow-wrap: normal; + scrollbar-width: none; + } + + &.highlight-input--nowrap .text-input::-webkit-scrollbar { + display: none; } &.highlight-input--nowrap .highlight-input-mirror { @@ -117,6 +113,8 @@ export const Suggestions = styled.ul` list-style: none; max-height: 12.5rem; overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: var(--oc-scrollbar-color) transparent; background: var(--bg-primary); border: 0.0625rem solid var(--border-color); border-radius: var(--oc-radius); @@ -125,6 +123,15 @@ export const Suggestions = styled.ul` font-size: 0.75rem; pointer-events: auto; + &::-webkit-scrollbar { + width: 0.375rem; + } + + &::-webkit-scrollbar-thumb { + background-color: var(--oc-scrollbar-color); + border-radius: 0.375rem; + } + .highlight-input-suggestion { padding: 0.25rem 0.5rem; border-radius: var(--oc-radius); diff --git a/packages/bruno-api-docs/src/components/KeyValueTable/KeyValueTable.css b/packages/bruno-api-docs/src/components/KeyValueTable/KeyValueTable.css deleted file mode 100644 index c4a94841..00000000 --- a/packages/bruno-api-docs/src/components/KeyValueTable/KeyValueTable.css +++ /dev/null @@ -1,289 +0,0 @@ -.key-value-table-wrapper { - display: flex; - flex-direction: column; - width: 100%; -} - -.key-value-table-container { - overflow: auto; - border-radius: 0.375rem; - border: 0.0625rem solid var(--oc-table-border); - /* Thin, auto-hidden scrollbar — revealed only while pointing at the table. */ - scrollbar-width: thin; - scrollbar-color: transparent transparent; -} - -.key-value-table-container:hover { - scrollbar-color: var(--oc-scrollbar-color) transparent; -} - -.key-value-table-container::-webkit-scrollbar { - width: 0.375rem; - height: 0.375rem; -} - -.key-value-table-container::-webkit-scrollbar-thumb { - background-color: transparent; - border-radius: 0.375rem; -} - -.key-value-table-container:hover::-webkit-scrollbar-thumb { - background-color: var(--oc-scrollbar-color); -} - -.key-value-table { - width: 100%; - /* Keep columns readable on narrow/mobile viewports; the container scrolls past this. */ - min-width: 28rem; - border-collapse: collapse; - table-layout: fixed; - font-size: 12px; -} - -.key-value-table thead { - background-color: var(--oc-background-mantle); - position: sticky; - top: 0; - z-index: 1; - border-bottom: 0.0625rem solid var(--oc-table-border); -} - -.key-value-table thead th { - position: relative; - padding: 0.5rem 0.625rem 0.4375rem; - text-align: left; - font-size: 0.75rem; - font-weight: 600; - line-height: 1; - letter-spacing: 0; - color: var(--oc-table-thead-color); - border-bottom: 0.0625rem solid var(--oc-table-border); - border-right: 0.0625rem solid var(--oc-table-border); - user-select: none; -} - -.key-value-table thead th:last-child { - border-right: none; -} - -.key-value-table .col-resize-handle { - position: absolute; - top: 0; - bottom: 0; - height: var(--kvt-height); - right: -0.125rem; - z-index: 2; - width: 0.25rem; - cursor: col-resize; - touch-action: none; - background-color: transparent; -} - -.key-value-table .col-resize-handle:hover, -.key-value-table .col-resize-handle.is-resizing { - background-color: var(--primary-color); -} - -.key-value-table-wrapper.is-resizing { - cursor: col-resize; - user-select: none; -} - -.key-value-table col.col-key { - width: calc(30% + 1.625rem); -} - -.key-value-table col.col-value { - width: auto; -} - -.key-value-table col.col-description { - width: 25%; -} - -.key-value-table col.col-actions { - width: 3.75rem; -} - -.key-value-table .col-description { - vertical-align: middle; -} - -.key-value-table .col-description .highlight-input { - width: 100%; -} - -.key-value-table tbody tr { - transition: background-color 0.1s ease; -} - -.key-value-table tbody tr:hover { - background-color: transparent; -} - -.key-value-table tbody tr:last-child td { - border-bottom: none; -} - -.key-value-table tbody td { - padding: 0; - vertical-align: middle; - border-bottom: 0.0625rem solid var(--oc-table-border); - border-right: 0.0625rem solid var(--oc-table-border); -} - -.key-value-table tbody td:last-child { - border-right: none; -} - -.key-value-table .key-cell { - display: flex; - align-items: center; - padding-left: 0.625rem; -} - -.key-value-table .checkbox-slot { - flex: none; - display: inline-flex; - width: 1rem; - height: 1rem; -} - -.key-value-table .key-cell .highlight-input, -.key-value-table .key-cell .text-readonly { - flex: 1; - min-width: 0; -} - -.key-value-table .cell-error { - flex: none; - display: inline-flex; - align-items: center; - margin-left: 0.375rem; - color: var(--oc-status-danger-text); - cursor: default; - margin-right: 0.4rem; -} - -.key-value-table .cell-error svg { - width: 0.875rem; - height: 0.875rem; -} - -.key-value-table .col-actions { - text-align: center; - vertical-align: middle; -} - -.key-value-table .text-input { - width: 100%; - border: 0.0625rem solid transparent; - outline: none; - background-color: transparent; - color: var(--oc-table-input-color); - padding: 0.625rem; - font-size: 0.8125rem; - font-weight: 400; - line-height: 1; - letter-spacing: 0; - border-radius: 0.25rem; - transition: all 0.15s ease; - font-family: inherit; - vertical-align: middle; -} - -.key-value-table .text-input:focus { - outline: none; -} - -/* Read-only key cell: matches the .text-input box model for column alignment, - but renders as static, non-editable text. */ -.key-value-table .text-readonly { - display: block; - width: 100%; - border: 1px solid transparent; - padding: 5px 8px; - font-size: 12px; - color: var(--text-primary); - font-family: inherit; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.key-value-table .text-input::placeholder { - color: var(--oc-colors-text-subtext0); - opacity: 0.6; -} - -.key-value-table .value-cell { - display: flex; - align-items: center; - width: 100%; -} - -.key-value-table .value-cell-field { - flex: 1; - min-width: 0; -} - -.key-value-table .value-input-tip { - display: block; - min-width: 0; -} - -.key-value-table .col-value .secret-value { - padding: 0.625rem; -} - -.key-value-table .col-value .secret-value .secret-value-input { - font-size: 0.8125rem; -} - -.key-value-table .value-cell-trailing { - flex: none; - display: flex; - align-items: center; - gap: 0.5rem; - padding-left: 0.625rem; - padding-right: 0.625rem; -} - -.key-value-table .value-cell:has(.secret-value) .value-cell-trailing { - gap: 0.625rem; - padding-left: 0; - padding-right: 0.625rem; -} - -.key-value-table .delete-button { - display: inline-flex; - align-items: center; - justify-content: center; - padding: 0; - color: var(--oc-colors-text-muted); - background: transparent; - border: none; - cursor: pointer; - border-radius: 0.25rem; - transition: color 0.15s ease, background-color 0.15s ease; -} - -.key-value-table .delete-button:hover { - color: var(--oc-colors-text-danger); - background-color: color-mix(in srgb, var(--oc-colors-text-danger) 10%, transparent); -} - -.key-value-table .delete-button svg { - width: 1rem; - height: 1rem; -} - -/* Empty row styling - slightly dimmed */ -.key-value-table tbody tr.empty-row { - opacity: 0.7; -} - -.key-value-table tbody tr.empty-row:hover { - background-color: transparent; -} - diff --git a/packages/bruno-api-docs/src/components/KeyValueTable/KeyValueTable.spec.tsx b/packages/bruno-api-docs/src/components/KeyValueTable/KeyValueTable.spec.tsx index 32917d07..038d44e4 100644 --- a/packages/bruno-api-docs/src/components/KeyValueTable/KeyValueTable.spec.tsx +++ b/packages/bruno-api-docs/src/components/KeyValueTable/KeyValueTable.spec.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { describe, it, expect } from 'vitest'; import { useRenderToDom } from '@/hooks/useRenderToDom'; -import { query } from '@/test-utils/dom'; +import { getByTestId, headerTexts, query } from '@/test-utils/dom'; import KeyValueTable, { type KeyValueRow } from './KeyValueTable'; const noop = () => {}; @@ -9,9 +9,6 @@ const rows: KeyValueRow[] = [ { id: 'r1', name: 'X-Trace', value: 'abc', enabled: true, description: 'Correlation id' } ]; -const headerTexts = (root: ReturnType) => - root.querySelectorAll('thead th').map((th) => th.text.trim()); - describe('KeyValueTable — description column', () => { it('renders a Description column and the authored description when showDescription is set', () => { const root = useRenderToDom(); @@ -77,3 +74,22 @@ describe('KeyValueTable — a row that has a value but no name', () => { expect(root.querySelectorAll('tbody input[type="checkbox"]').length).toBe(0); }); }); + +describe('KeyValueTable: cell tooltips', () => { + it('does not expose the row name as a native title tooltip', () => { + const root = useRenderToDom(); + expect(getByTestId(root, 'key-value-table-name-input').hasAttribute('title')).toBe(false); + }); +}); + +describe('KeyValueTable: first column header alignment', () => { + it('flags the table when rows carry an enable checkbox, so the header indents past it', () => { + const root = useRenderToDom(); + expect(getByTestId(root, 'key-value-table-table').classList.contains('key-value-table--with-toggle')).toBe(true); + }); + + it('does not flag the table when the checkbox column is hidden', () => { + const root = useRenderToDom(); + expect(getByTestId(root, 'key-value-table-table').classList.contains('key-value-table--with-toggle')).toBe(false); + }); +}); diff --git a/packages/bruno-api-docs/src/components/KeyValueTable/KeyValueTable.tsx b/packages/bruno-api-docs/src/components/KeyValueTable/KeyValueTable.tsx index 1988fda5..a47aa9d5 100644 --- a/packages/bruno-api-docs/src/components/KeyValueTable/KeyValueTable.tsx +++ b/packages/bruno-api-docs/src/components/KeyValueTable/KeyValueTable.tsx @@ -5,8 +5,8 @@ import { Tooltip } from '@/ui/Tooltip/Tooltip'; import { WarningIcon } from '@/assets/icons'; import HighlightedInput from '../HighlightedInput/HighlightedInput'; import { SecretValue } from '@/ui/SecretValue/SecretValue'; -import './KeyValueTable.css'; import Checkbox from '@/ui/Checkbox/Checkbox'; +import { StyledWrapper } from './StyledWrapper'; // Smallest a column may be dragged to; the neighbour it trades width with is held to the same floor. const MIN_COLUMN_WIDTH = 60; @@ -56,11 +56,6 @@ interface KeyValueTableProps { testId?: string; } -const valueTruncated = (anchor: HTMLElement): boolean => { - const field = anchor.querySelector('input, textarea'); - return !!field && (field.scrollWidth > field.clientWidth || field.scrollHeight > field.clientHeight); -}; - const KeyValueTable: React.FC = ({ data, onChange, @@ -221,9 +216,13 @@ const KeyValueTable: React.FC = ({ }; return ( -
+
- +
@@ -236,7 +235,7 @@ const KeyValueTable: React.FC = ({ - @@ -300,20 +299,16 @@ const KeyValueTable: React.FC = ({ onChange={(v) => updateField(index, 'value', v)} /> ) : ( - - - updateField(index, 'value', v)} - isFound={isFound} - names={names} - anywordHints={valueAutocomplete} - multiline={multilineValues} - testId={`${testId}-value-input`} - /> - - + updateField(index, 'value', v)} + isFound={isFound} + names={names} + anywordHints={valueAutocomplete} + multiline={multilineValues} + testId={`${testId}-value-input`} + /> ); return ( @@ -332,7 +327,7 @@ const KeyValueTable: React.FC = ({ )} {readOnlyKey ? ( - + {row.name} ) : ( @@ -344,7 +339,6 @@ const KeyValueTable: React.FC = ({ names={names} anywordHints={keyAutocomplete} variablesAutocomplete={false} - title={row.name} testId={`${testId}-name-input`} /> )} @@ -399,7 +393,7 @@ const KeyValueTable: React.FC = ({
+ {keyPlaceholder} {resizeHandle('key')}
-
+ ); }; diff --git a/packages/bruno-api-docs/src/components/KeyValueTable/StyledWrapper.ts b/packages/bruno-api-docs/src/components/KeyValueTable/StyledWrapper.ts new file mode 100644 index 00000000..7de6e26d --- /dev/null +++ b/packages/bruno-api-docs/src/components/KeyValueTable/StyledWrapper.ts @@ -0,0 +1,267 @@ +import styled from '@emotion/styled'; + +export const StyledWrapper = styled.div` + --kvt-cell-inset: 0.625rem; + --kvt-field-inset: 0.6875rem; + --kvt-toggle-width: 1rem; + display: flex; + flex-direction: column; + width: 100%; + + .key-value-table-container { + overflow: auto; + max-height: 24rem; + border-radius: 0.375rem; + border: 0.0625rem solid var(--oc-table-border); + scrollbar-width: thin; + scrollbar-color: transparent transparent; + } + + .key-value-table-container:hover { + scrollbar-color: var(--oc-scrollbar-color) transparent; + } + + .key-value-table-container::-webkit-scrollbar { + width: 0.375rem; + height: 0.375rem; + } + + .key-value-table-container::-webkit-scrollbar-thumb { + background-color: transparent; + border-radius: 0.375rem; + } + + .key-value-table-container:hover::-webkit-scrollbar-thumb { + background-color: var(--oc-scrollbar-color); + } + + .key-value-table { + width: 100%; + min-width: 28rem; + border-collapse: collapse; + table-layout: fixed; + font-size: 12px; + } + + .key-value-table thead { + background-color: var(--oc-background-mantle); + position: sticky; + top: 0; + z-index: 1; + border-bottom: 0.0625rem solid var(--oc-table-border); + } + + .key-value-table thead th { + position: relative; + padding: 0.5rem 0.625rem 0.4375rem; + text-align: left; + font-size: 0.75rem; + font-weight: 600; + line-height: 1; + letter-spacing: 0; + color: var(--oc-table-thead-color); + border-bottom: 0.0625rem solid var(--oc-table-border); + border-right: 0.0625rem solid var(--oc-table-border); + user-select: none; + } + + .key-value-table thead th:last-child { + border-right: none; + } + + .key-value-table thead th.col-key { + padding-left: calc(var(--kvt-cell-inset) + var(--kvt-field-inset)); + } + + .key-value-table--with-toggle thead th.col-key { + padding-left: calc(var(--kvt-cell-inset) + var(--kvt-toggle-width) + var(--kvt-field-inset)); + } + + .key-value-table .col-resize-handle { + position: absolute; + top: 0; + bottom: 0; + height: var(--kvt-height); + right: -0.125rem; + z-index: 2; + width: 0.25rem; + cursor: col-resize; + touch-action: none; + background-color: transparent; + } + + .key-value-table .col-resize-handle:hover, + .key-value-table .col-resize-handle.is-resizing { + background-color: var(--primary-color); + } + + &.is-resizing { + cursor: col-resize; + user-select: none; + } + + .key-value-table col.col-key { + width: calc(30% + 1.625rem); + } + + .key-value-table col.col-value { + width: auto; + } + + .key-value-table col.col-description { + width: 25%; + } + + .key-value-table col.col-actions { + width: 3.75rem; + } + + .key-value-table .col-description { + vertical-align: middle; + } + + .key-value-table .col-description .highlight-input { + width: 100%; + } + + .key-value-table tbody tr { + transition: background-color 0.1s ease; + } + + .key-value-table tbody tr:hover { + background-color: transparent; + } + + .key-value-table tbody tr:last-child td { + border-bottom: none; + } + + .key-value-table tbody td { + padding: 0; + vertical-align: middle; + border-bottom: 0.0625rem solid var(--oc-table-border); + border-right: 0.0625rem solid var(--oc-table-border); + } + + .key-value-table tbody td:last-child { + border-right: none; + } + + .key-value-table .key-cell { + display: flex; + align-items: center; + padding-left: var(--kvt-cell-inset); + } + + .key-value-table .checkbox-slot { + flex: none; + display: inline-flex; + width: var(--kvt-toggle-width); + height: var(--kvt-toggle-width); + } + + .key-value-table .key-cell .highlight-input, + .key-value-table .key-cell .text-readonly { + flex: 1; + min-width: 0; + } + + .key-value-table .cell-error { + flex: none; + display: inline-flex; + align-items: center; + margin-left: 0.375rem; + color: var(--oc-status-danger-text); + cursor: default; + margin-right: 0.4rem; + } + + .key-value-table .cell-error svg { + width: 0.875rem; + height: 0.875rem; + } + + .key-value-table .col-actions { + text-align: center; + vertical-align: middle; + } + + .key-value-table .text-readonly { + display: block; + width: 100%; + border: 0.0625rem solid transparent; + padding: 0.625rem; + font-size: 0.8125rem; + line-height: 1.25; + color: var(--text-primary); + font-family: inherit; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .key-value-table .value-cell { + display: flex; + align-items: center; + width: 100%; + } + + .key-value-table .value-cell-field { + flex: 1; + min-width: 0; + } + + .key-value-table .col-value .secret-value { + padding: 0.625rem; + } + + .key-value-table .col-value .secret-value .secret-value-input { + font-size: 0.8125rem; + } + + .key-value-table .value-cell-trailing { + flex: none; + display: flex; + align-items: center; + gap: 0.5rem; + padding-left: 0.625rem; + padding-right: 0.625rem; + } + + .key-value-table .value-cell:has(.secret-value) .value-cell-trailing { + gap: 0.625rem; + padding-left: 0; + padding-right: 0.625rem; + } + + .key-value-table .delete-button { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + color: var(--oc-colors-text-muted); + background: transparent; + border: none; + cursor: pointer; + border-radius: 0.25rem; + transition: color 0.15s ease, background-color 0.15s ease; + } + + .key-value-table .delete-button:hover { + color: var(--oc-colors-text-danger); + background-color: color-mix(in srgb, var(--oc-colors-text-danger) 10%, transparent); + } + + .key-value-table .delete-button svg { + width: 1rem; + height: 1rem; + } + + .key-value-table tbody tr.empty-row { + opacity: 0.7; + } + + .key-value-table tbody tr.empty-row:hover { + background-color: transparent; + } +`; diff --git a/packages/bruno-api-docs/src/components/Playground/Content/Views/Common/AssertsTab/AssertsTab.spec.tsx b/packages/bruno-api-docs/src/components/Playground/Content/Views/Common/AssertsTab/AssertsTab.spec.tsx index cf3b558b..f1c1d8a0 100644 --- a/packages/bruno-api-docs/src/components/Playground/Content/Views/Common/AssertsTab/AssertsTab.spec.tsx +++ b/packages/bruno-api-docs/src/components/Playground/Content/Views/Common/AssertsTab/AssertsTab.spec.tsx @@ -1,29 +1,22 @@ import React from 'react'; -import { renderToStaticMarkup } from 'react-dom/server'; -import { parse } from 'node-html-parser'; import { describe, it, expect } from 'vitest'; import type { Assertion } from '@opencollection/types/common/assertions'; +import { useRenderToDom } from '@/hooks/useRenderToDom'; +import { getByTestId, headerTexts } from '@/test-utils/dom'; import { AssertsTab } from './AssertsTab'; -import { getByTestId } from '@/test-utils/dom'; const noop = () => {}; -const render = (ui: React.ReactElement) => { - const root = parse(renderToStaticMarkup(ui)); - root.querySelectorAll('style').forEach((style) => style.remove()); - return root; -}; - describe('AssertsTab', () => { it('defaults the operator trigger to the first operator when a row has none', () => { - const root = render( + const root = useRenderToDom( ); expect(getByTestId(root, 'assertion-operator-0').text).toContain('equals'); }); it('shows the stored operator label when one is set', () => { - const root = render( + const root = useRenderToDom( { ); expect(getByTestId(root, 'assertion-operator-0').text).toContain('greater than'); }); + + it('labels the columns Expr, Value, Operator and Description like the app', () => { + const root = useRenderToDom( + + ); + expect(headerTexts(root).slice(0, 4)).toEqual(['Expr', 'Value', 'Operator', 'Description']); + }); + + it('shows a Description column with the authored assertion description', () => { + const root = useRenderToDom( + + ); + expect(headerTexts(root)).toContain('Description'); + expect(getByTestId(root, 'key-value-table-description-input').text).toBe('Echoes the requested id'); + }); }); diff --git a/packages/bruno-api-docs/src/components/Playground/Content/Views/Common/AssertsTab/AssertsTab.tsx b/packages/bruno-api-docs/src/components/Playground/Content/Views/Common/AssertsTab/AssertsTab.tsx index 97b91158..ab6d7574 100644 --- a/packages/bruno-api-docs/src/components/Playground/Content/Views/Common/AssertsTab/AssertsTab.tsx +++ b/packages/bruno-api-docs/src/components/Playground/Content/Views/Common/AssertsTab/AssertsTab.tsx @@ -3,6 +3,12 @@ import { IconCaretDown } from '@tabler/icons'; import type { Assertion } from '@opencollection/types/common/assertions'; import KeyValueTable, { type KeyValueRow } from '@/components/KeyValueTable/KeyValueTable'; import MenuDropdown from '@/ui/MenuDropdown'; +import { + assertionsToRows, + rowsToAssertions, + DEFAULT_ASSERTION_OPERATOR, + UNARY_ASSERTION_OPERATORS +} from '@/utils/assertionRows'; import { StyledWrapper } from './StyledWrapper'; /** @@ -77,22 +83,8 @@ const ASSERTION_OPERATORS = [ { value: 'isArray', label: 'is array' } ]; -const DEFAULT_OPERATOR = ASSERTION_OPERATORS[0].value; - -const UNARY_OPERATORS = new Set([ - 'isEmpty', - 'isNotEmpty', - 'isNull', - 'isUndefined', - 'isDefined', - 'isTruthy', - 'isFalsy', - 'isJson', - 'isNumber', - 'isString', - 'isBoolean', - 'isArray' -]); +const DEFAULT_OPERATOR_OPTION + = ASSERTION_OPERATORS.find((op) => op.value === DEFAULT_ASSERTION_OPERATOR) ?? ASSERTION_OPERATORS[0]; interface AssertsTabProps { assertions: Assertion[]; @@ -107,32 +99,16 @@ export const AssertsTab: React.FC = ({ title, description }) => { - const assertionsData: KeyValueRow[] = (assertions || []).map((assertion, index) => ({ - id: `assertion-${index}`, - name: assertion.expression || '', - value: assertion.value || '', - operator: assertion.operator || DEFAULT_OPERATOR, - enabled: !assertion.disabled - })); + const assertionsData = assertionsToRows(assertions || []); const handleAssertionsChange = (rows: KeyValueRow[]) => { - const updatedAssertions: Assertion[] = rows.map((row) => { - const operator = row.operator || DEFAULT_OPERATOR; - const isUnary = UNARY_OPERATORS.has(operator); - return { - expression: row.name, - operator, - value: isUnary ? undefined : row.value, - disabled: !row.enabled - }; - }); - onAssertionsChange(updatedAssertions); + onAssertionsChange(rowsToAssertions(rows)); }; const handleOperatorChange = (index: number, newOperator: string) => { const updatedRows = [...assertionsData]; updatedRows[index] = { ...updatedRows[index], operator: newOperator }; - if (UNARY_OPERATORS.has(newOperator)) { + if (UNARY_ASSERTION_OPERATORS.has(newOperator)) { updatedRows[index].value = ''; } handleAssertionsChange(updatedRows); @@ -149,16 +125,17 @@ export const AssertsTab: React.FC = ({ { const currentOperator - = ASSERTION_OPERATORS.find((op) => op.value === row.operator) ?? ASSERTION_OPERATORS[0]; + = ASSERTION_OPERATORS.find((op) => op.value === row.operator) ?? DEFAULT_OPERATOR_OPTION; return ( {}; -const columnLabels = (root: ReturnType) => - root.querySelectorAll('thead th').map((th) => th.text.trim()); - describe('ParamsTab — descriptions', () => { it('shows a Description column with authored query-param descriptions', () => { const root = useRenderToDom( @@ -16,7 +14,7 @@ describe('ParamsTab — descriptions', () => { onParamsChange={noop} /> ); - expect(columnLabels(root)).toContain('Description'); + expect(headerTexts(root)).toContain('Description'); expect(root.text).toContain('Page number, 1-based'); }); @@ -30,3 +28,10 @@ describe('ParamsTab — descriptions', () => { expect(root.text).toContain('Search query'); }); }); + +describe('ParamsTab, column labels', () => { + it('labels the first query column Name, matching the app', () => { + const root = useRenderToDom(); + expect(headerTexts(root)[0]).toBe('Name'); + }); +}); diff --git a/packages/bruno-api-docs/src/components/Playground/Content/Views/Common/ParamsTab/ParamsTab.tsx b/packages/bruno-api-docs/src/components/Playground/Content/Views/Common/ParamsTab/ParamsTab.tsx index ec76b8f1..871b8d35 100644 --- a/packages/bruno-api-docs/src/components/Playground/Content/Views/Common/ParamsTab/ParamsTab.tsx +++ b/packages/bruno-api-docs/src/components/Playground/Content/Views/Common/ParamsTab/ParamsTab.tsx @@ -15,11 +15,11 @@ interface ParamsSectionProps { description?: string; data: KeyValueRow[]; onChange: (rows: KeyValueRow[]) => void; - keyLabel?: string; showEnabled?: boolean; showActions?: boolean; disableNewRow?: boolean; readOnlyKey?: boolean; + testId?: string; } /** @@ -32,11 +32,11 @@ const ParamsSection: React.FC = React.memo(({ description, data, onChange, - keyLabel = 'Key', showEnabled = true, showActions = true, disableNewRow = false, - readOnlyKey = false + readOnlyKey = false, + testId }) => (
@@ -52,12 +52,12 @@ const ParamsSection: React.FC = React.memo(({
)); @@ -128,11 +128,11 @@ export const ParamsTab: React.FC = ({ title="Path" data={pathData} onChange={handlePathChange} - keyLabel="Name" showEnabled={false} showActions={false} disableNewRow={true} readOnlyKey={true} + testId="params-path" /> )} diff --git a/packages/bruno-api-docs/src/e2eFixtures/descriptionsCollection.ts b/packages/bruno-api-docs/src/e2eFixtures/descriptionsCollection.ts index 56279e4e..a21eea59 100644 --- a/packages/bruno-api-docs/src/e2eFixtures/descriptionsCollection.ts +++ b/packages/bruno-api-docs/src/e2eFixtures/descriptionsCollection.ts @@ -1,8 +1,8 @@ import type { OpenCollection } from '@opencollection/types'; // A single request carrying authored descriptions on every editable surface (query params, headers, -// pre-request variables, form-urlencoded body fields), used to prove the playground shows and edits -// them. One header uses the legacy `{ content }` object form to exercise description normalization. +// pre-request variables, assertions, form-urlencoded body fields), used to prove the playground shows +// and edits them. One header uses the legacy `{ content }` object form to exercise description normalization. export const descriptionsFixtureCollection = { opencollection: '1.0.0', info: { name: 'Descriptions Demo', version: '1.0.0' }, @@ -29,7 +29,10 @@ export const descriptionsFixtureCollection = { data: [{ name: 'grant_type', value: 'client_credentials', description: 'The OAuth2 grant type to use' }] }, runtime: { - variables: [{ name: 'orderId', value: 'ord-42', description: 'The order identifier under test' }] + variables: [{ name: 'orderId', value: 'ord-42', description: 'The order identifier under test' }], + assertions: [ + { expression: 'res.status', operator: 'eq', value: '201', description: 'Creating an order returns Created' } + ] } } ] diff --git a/packages/bruno-api-docs/src/test-utils/dom.ts b/packages/bruno-api-docs/src/test-utils/dom.ts index 0cd1ab6b..2e36aa6d 100644 --- a/packages/bruno-api-docs/src/test-utils/dom.ts +++ b/packages/bruno-api-docs/src/test-utils/dom.ts @@ -9,6 +9,10 @@ export const query = (root: HTMLElement, selector: string): HTMLElement => { /** Find an element by its `data-testid`, throwing if it is absent. */ export const getByTestId = (root: HTMLElement, testId: string): HTMLElement => query(root, `[data-testid="${testId}"]`); +/** The trimmed text of every table header cell, in document order. */ +export const headerTexts = (root: HTMLElement): string[] => + root.querySelectorAll('thead th').map((th) => th.text.trim()); + /** Find an element by its `data-testid`, or `null` when absent. */ export const queryByTestId = (root: HTMLElement, testId: string): HTMLElement | null => root.querySelector(`[data-testid="${testId}"]`); diff --git a/packages/bruno-api-docs/src/utils/assertionRows.spec.ts b/packages/bruno-api-docs/src/utils/assertionRows.spec.ts new file mode 100644 index 00000000..a7ea4c5c --- /dev/null +++ b/packages/bruno-api-docs/src/utils/assertionRows.spec.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from 'vitest'; +import type { Assertion } from '@opencollection/types/common/assertions'; +import type { KeyValueRow } from '@/components/KeyValueTable/KeyValueTable'; +import { assertionsToRows, rowsToAssertions } from './assertionRows'; + +describe('assertionsToRows', () => { + it('maps expression, operator, value and enabled state onto a table row', () => { + const rows = assertionsToRows([{ expression: 'res.status', operator: 'gt', value: '1', disabled: true }]); + expect(rows).toEqual([ + { id: 'assertion-0', name: 'res.status', operator: 'gt', value: '1', enabled: false } + ]); + }); + + it('defaults a missing operator to equals', () => { + expect(assertionsToRows([{ expression: 'res.status', value: '200' } as Assertion])[0].operator).toBe('eq'); + }); + + it('carries a bare-string description onto the row', () => { + const rows = assertionsToRows([ + { expression: 'res.body.id', operator: 'eq', value: '1', description: 'Echoes the requested id' } + ]); + expect(rows[0].description).toBe('Echoes the requested id'); + }); + + it('normalizes the object-form description to its text', () => { + const rows = assertionsToRows([ + { expression: 'res.body.id', operator: 'eq', value: '1', description: { content: 'From an object', type: 'text' } } + ] as Assertion[]); + expect(rows[0].description).toBe('From an object'); + }); + + it('leaves description undefined when the assertion has none', () => { + expect(assertionsToRows([{ expression: 'res.status', operator: 'eq', value: '200' }])[0].description).toBeUndefined(); + }); +}); + +describe('rowsToAssertions', () => { + const row = (overrides: Partial): KeyValueRow => ({ + id: 'assertion-0', + name: 'res.status', + operator: 'eq', + value: '200', + enabled: true, + ...overrides + }); + + it('writes the row back as an assertion', () => { + expect(rowsToAssertions([row({})])).toEqual([ + { expression: 'res.status', operator: 'eq', value: '200', disabled: false } + ]); + }); + + it('keeps an authored description', () => { + expect(rowsToAssertions([row({ description: 'Status must be OK' })])[0].description).toBe('Status must be OK'); + }); + + it('omits a blank description', () => { + expect(rowsToAssertions([row({ description: ' ' })])[0]).not.toHaveProperty('description'); + }); + + it('drops the value for a unary operator', () => { + expect(rowsToAssertions([row({ operator: 'isNull', value: 'stale' })])[0].value).toBeUndefined(); + }); + + it('falls back to equals when the row has no operator', () => { + expect(rowsToAssertions([row({ operator: undefined })])[0].operator).toBe('eq'); + }); +}); diff --git a/packages/bruno-api-docs/src/utils/assertionRows.ts b/packages/bruno-api-docs/src/utils/assertionRows.ts new file mode 100644 index 00000000..cbb9a037 --- /dev/null +++ b/packages/bruno-api-docs/src/utils/assertionRows.ts @@ -0,0 +1,47 @@ +import type { Assertion } from '@opencollection/types/common/assertions'; +import type { KeyValueRow } from '@/components/KeyValueTable/KeyValueTable'; +import { descriptionText, resolveDescription } from './description'; + +export const DEFAULT_ASSERTION_OPERATOR = 'eq'; + +/** Operators that test the expression alone, so the assertion carries no expected value. */ +export const UNARY_ASSERTION_OPERATORS = new Set([ + 'isEmpty', + 'isNotEmpty', + 'isNull', + 'isUndefined', + 'isDefined', + 'isTruthy', + 'isFalsy', + 'isJson', + 'isNumber', + 'isString', + 'isBoolean', + 'isArray' +]); + +export const assertionsToRows = (assertions: Assertion[]): KeyValueRow[] => + assertions.map((assertion, index) => { + const description = descriptionText(assertion.description); + return { + id: `assertion-${index}`, + name: assertion.expression || '', + operator: assertion.operator || DEFAULT_ASSERTION_OPERATOR, + value: assertion.value || '', + enabled: !assertion.disabled, + ...(description !== undefined ? { description } : {}) + }; + }); + +export const rowsToAssertions = (rows: KeyValueRow[]): Assertion[] => + rows.map((row) => { + const operator = row.operator || DEFAULT_ASSERTION_OPERATOR; + const description = resolveDescription(row.description); + return { + expression: row.name, + operator, + value: UNARY_ASSERTION_OPERATORS.has(operator) ? undefined : row.value, + disabled: !row.enabled, + ...(description !== undefined ? { description } : {}) + }; + }); From c863479893f2f310f2db3c9238916f613d008bc6 Mon Sep 17 00:00:00 2001 From: Sundram Gupta Date: Thu, 17 Sep 2026 02:15:07 +0530 Subject: [PATCH 3/7] fix(playground): keep the selected tab across a dock change Switching the dock placement swaps the dock component and remounts the request and response panes, so their locally held tab reset to the default. Both panes now keep the active tab in session storage, the same lane the collapsible sections and dock sizes use, so it survives the remount and a reload. --- .../playground/response-pane.component.ts | 5 +++ .../tests/playground/tab-persistence.spec.ts | 39 +++++++++++++++++++ .../RequestPane/RequestPane.tsx | 5 ++- .../ResponsePane/ResponsePane.tsx | 3 +- 4 files changed, 49 insertions(+), 3 deletions(-) create mode 100644 packages/bruno-api-docs/e2e/tests/playground/tab-persistence.spec.ts diff --git a/packages/bruno-api-docs/e2e/components/playground/response-pane.component.ts b/packages/bruno-api-docs/e2e/components/playground/response-pane.component.ts index 0aab8505..f8cf67b5 100644 --- a/packages/bruno-api-docs/e2e/components/playground/response-pane.component.ts +++ b/packages/bruno-api-docs/e2e/components/playground/response-pane.component.ts @@ -101,6 +101,11 @@ export class ResponsePaneComponent extends BaseComponent { * switch to settle (the target becomes the selected tab) so a following switch never races the * layout mid-transition. */ + /** A response tab button by id, as rendered inline in the tab bar. */ + tab(id: string): Locator { + return this.page.getByTestId(`response-tabs-tab-${id}`); + } + async switchToTab(id: string): Promise { const inlineTab = this.page.getByTestId(`response-tabs-tab-${id}`); if (await inlineTab.isVisible()) { diff --git a/packages/bruno-api-docs/e2e/tests/playground/tab-persistence.spec.ts b/packages/bruno-api-docs/e2e/tests/playground/tab-persistence.spec.ts new file mode 100644 index 00000000..eeb00c80 --- /dev/null +++ b/packages/bruno-api-docs/e2e/tests/playground/tab-persistence.spec.ts @@ -0,0 +1,39 @@ +import { test, expect } from '../../playwright'; + +const USERS_BODY = '{"data":[{"id":1,"name":"Alice"}]}'; + +// Changing the dock placement swaps the dock component, which remounts the request and response +// panes; the selected tab in each pane must survive that remount, as it does in the app. +test.describe('Playground tabs across a dock change', () => { + test.use({ viewport: { width: 1280, height: 900 } }); + + test('the selected request tab is kept when the playground moves to another dock', async ({ playground }) => { + await playground.open('bottom'); + await playground.openRequest('get users'); + await playground.selectTab('headers'); + await expect(playground.tab('headers')).toHaveAttribute('aria-selected', 'true'); + + await playground.selectDock('inline'); + await expect(playground.inlinePanel).toBeVisible(); + await expect(playground.tab('headers')).toHaveAttribute('aria-selected', 'true'); + + await playground.selectDock('modal'); + await expect(playground.modalPanel).toBeVisible(); + await expect(playground.tab('headers')).toHaveAttribute('aria-selected', 'true'); + }); + + test('the selected response tab is kept when the playground moves to another dock', async ({ + playground, + responsePane + }) => { + await responsePane.mockUsersResponse(USERS_BODY); + await playground.open('bottom'); + await playground.openRequest('get users'); + await responsePane.send(); + await responsePane.switchToTab('headers'); + + await playground.selectDock('inline'); + await expect(playground.inlinePanel).toBeVisible(); + await expect(responsePane.tab('headers')).toHaveAttribute('aria-selected', 'true'); + }); +}); diff --git a/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/RequestPane/RequestPane.tsx b/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/RequestPane/RequestPane.tsx index fc5758d4..ad44c18b 100644 --- a/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/RequestPane/RequestPane.tsx +++ b/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/RequestPane/RequestPane.tsx @@ -1,7 +1,8 @@ -import React, { useState } from 'react'; +import React from 'react'; import type { HttpRequest } from '@opencollection/types/requests/http'; import type { Assertion } from '@opencollection/types/common/assertions'; import Tabs from '@/ui/Tabs/Tabs'; +import { useSessionStorage } from '@/hooks'; import type { KeyValueRow } from '@/components/KeyValueTable/KeyValueTable'; import { rowToVariable } from '@/utils/variableDataType'; import HeadersTab from '../../Common/HeadersTab/HeadersTab'; @@ -40,7 +41,7 @@ interface RequestPaneProps { } const RequestPane: React.FC = ({ item, onItemChange, inheritedAuth }) => { - const [activeTab, setActiveTab] = useState('overview'); + const [activeTab, setActiveTab] = useSessionStorage('playground-request-tab', 'overview'); const handleParamsChange = (params: KeyValueRow[]) => { const updatedParams = params.map((p) => ({ ...keyValueRowToEntry(p), type: p.type })); diff --git a/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponsePane.tsx b/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponsePane.tsx index 1b46bee1..609a3381 100644 --- a/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponsePane.tsx +++ b/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponsePane.tsx @@ -1,5 +1,6 @@ import React, { useState } from 'react'; import Tabs from '@/ui/Tabs/Tabs'; +import { useSessionStorage } from '@/hooks'; import ResponseBodyTab from '../../Common/ResponseBodyTab'; import ResponseHeadersTab from '../../Common/ResponseHeadersTab'; import TestResultsTab from '../../Common/TestResultsTab'; @@ -26,7 +27,7 @@ interface ResponsePaneProps { } const ResponsePane: React.FC = ({ response, isLoading, orientation, itemUuid }) => { - const [activeTab, setActiveTab] = useState('response'); + const [activeTab, setActiveTab] = useSessionStorage('playground-response-tab', 'response'); const [dismissedScriptErrorKeys, setDismissedScriptErrorKeys] = useState([]); const { actionsExpandedWidth, measureActions } = useResponseActions(); From 5e1199f0b6b1f10790e90a238a550627bb3ecd62 Mon Sep 17 00:00:00 2001 From: Sundram Gupta Date: Tue, 22 Sep 2026 10:35:55 +0530 Subject: [PATCH 4/7] chore: code cleanup --- .../playground/response-pane.component.ts | 1 - .../e2e/tests/playground/env-table-values.spec.ts | 2 -- .../e2e/tests/playground/key-value-table.spec.ts | 13 ++----------- .../e2e/tests/playground/tab-persistence.spec.ts | 2 -- .../HighlightedInput/HighlightedInput.tsx | 2 -- packages/bruno-api-docs/src/test-utils/dom.ts | 1 - packages/bruno-api-docs/src/utils/assertionRows.ts | 1 - 7 files changed, 2 insertions(+), 20 deletions(-) diff --git a/packages/bruno-api-docs/e2e/components/playground/response-pane.component.ts b/packages/bruno-api-docs/e2e/components/playground/response-pane.component.ts index f8cf67b5..670dea69 100644 --- a/packages/bruno-api-docs/e2e/components/playground/response-pane.component.ts +++ b/packages/bruno-api-docs/e2e/components/playground/response-pane.component.ts @@ -101,7 +101,6 @@ export class ResponsePaneComponent extends BaseComponent { * switch to settle (the target becomes the selected tab) so a following switch never races the * layout mid-transition. */ - /** A response tab button by id, as rendered inline in the tab bar. */ tab(id: string): Locator { return this.page.getByTestId(`response-tabs-tab-${id}`); } diff --git a/packages/bruno-api-docs/e2e/tests/playground/env-table-values.spec.ts b/packages/bruno-api-docs/e2e/tests/playground/env-table-values.spec.ts index ab9502c2..23a98600 100644 --- a/packages/bruno-api-docs/e2e/tests/playground/env-table-values.spec.ts +++ b/packages/bruno-api-docs/e2e/tests/playground/env-table-values.spec.ts @@ -1,6 +1,5 @@ import { test, expect } from '../../playwright'; -// The bottom dock renders environments as a KeyValueTable (the inline dock uses cards instead). test.describe('Environment variables: value cells (table view)', () => { test.beforeEach(async ({ playground }) => { await playground.open('bottom'); @@ -26,7 +25,6 @@ test.describe('Environment variables: value cells (table view)', () => { await expect.poll(() => valueInput.evaluate((el) => el.scrollHeight > el.clientHeight + 1)).toBe(false); const widthBefore = await valueInput.evaluate((el) => el.clientWidth); - // Drag the Name/Value divider to the right so the value column shrinks and the text wraps onto more lines. const handle = keyValueTable.resizeHandles.first(); await handle.scrollIntoViewIfNeeded(); const box = (await handle.boundingBox())!; diff --git a/packages/bruno-api-docs/e2e/tests/playground/key-value-table.spec.ts b/packages/bruno-api-docs/e2e/tests/playground/key-value-table.spec.ts index 5116700f..cfe11d67 100644 --- a/packages/bruno-api-docs/e2e/tests/playground/key-value-table.spec.ts +++ b/packages/bruno-api-docs/e2e/tests/playground/key-value-table.spec.ts @@ -1,6 +1,5 @@ import { test, expect } from '../../playwright'; -/** Where an element's text begins: its left edge plus border and padding, as the browser lays it out. */ const textStart = (el: HTMLElement) => el.getBoundingClientRect().left + parseFloat(getComputedStyle(el).borderLeftWidth) @@ -16,7 +15,6 @@ test.describe('KeyValueTable: cells, scrolling and layout', () => { test('a cell shows neither a native title nor a hover tooltip, even when its text is clipped', async ({ page, playground, tooltip }) => { const { keyValueTable } = playground; - // Pin the trailing blank row by index: naming it appends a new blank row, so `.last()` would move on. const blankRowIndex = (await keyValueTable.nameInputs.count()) - 1; const nameInput = keyValueTable.nameInputs.nth(blankRowIndex); await nameInput.fill('X-A-Very-Long-Custom-Header-Name-That-Overflows-Its-Cell-Width'); @@ -35,7 +33,7 @@ test.describe('KeyValueTable: cells, scrolling and layout', () => { for (let i = 0; i < 14; i++) { await keyValueTable.nameInputs.last().fill(`X-Row-${i}`); } - await expect(keyValueTable.container).toHaveCSS('max-height', '384px'); // 24rem @16px + await expect(keyValueTable.container).toHaveCSS('max-height', '384px'); await expect .poll(() => keyValueTable.container.evaluate((el) => el.scrollHeight > el.clientHeight + 1)) .toBe(true); @@ -78,9 +76,8 @@ test.describe('KeyValueTable: cells, scrolling and layout', () => { test('the table has a min-width and a horizontally-scrollable container', async ({ page, playground }) => { const { keyValueTable } = playground; await expect(keyValueTable.container).toHaveCSS('overflow-x', 'auto'); - await expect(keyValueTable.table).toHaveCSS('min-width', '448px'); // 28rem @16px + await expect(keyValueTable.table).toHaveCSS('min-width', '448px'); - // Narrow the viewport below the min-width → the container actually overflows and scrolls. await page.setViewportSize({ width: 360, height: 800 }); const overflows = await keyValueTable.container.evaluate((el) => el.scrollWidth > el.clientWidth + 1); expect(overflows).toBe(true); @@ -88,7 +85,6 @@ test.describe('KeyValueTable: cells, scrolling and layout', () => { test('offers {{variable}} autocomplete in the value cell but not the name cell', async ({ page, playground }) => { const { keyValueTable } = playground; - // Value cell: a `{{` reference surfaces the collection's variables. await keyValueTable.valueInputs.last().click(); await page.keyboard.type('{{coll'); await expect(keyValueTable.autocomplete).toBeVisible(); @@ -96,8 +92,6 @@ test.describe('KeyValueTable: cells, scrolling and layout', () => { await page.keyboard.press('Escape'); await expect(keyValueTable.autocomplete).toHaveCount(0); - // Name cell: the same reference must not open the dropdown. The app only - // autocompletes variables in value cells, never in param/variable name cells. await keyValueTable.nameInputs.last().click(); await page.keyboard.type('{{coll'); await page.waitForTimeout(250); @@ -123,7 +117,6 @@ test.describe('KeyValueTable: cells, scrolling and layout', () => { test('a named row can be enabled and disabled via its checkbox', async ({ playground }) => { const { keyValueTable } = playground; - // The trailing blank row has no checkbox; naming a row promotes it to a real row with one. await keyValueTable.nameInputs.last().fill('X-Custom'); const toggle = keyValueTable.enableToggle('X-Custom'); @@ -141,8 +134,6 @@ test.describe('KeyValueTable: cells, scrolling and layout', () => { const valueHeader = keyValueTable.columnHeader('col-value'); const before = (await valueHeader.boundingBox())!.width; - // Drag the Name/Value divider (the first handle) to the right: Name grows and Value shrinks by - // the same amount (zero-sum), so the Value column gets measurably narrower. const handle = keyValueTable.resizeHandles.first(); const box = (await handle.boundingBox())!; await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); diff --git a/packages/bruno-api-docs/e2e/tests/playground/tab-persistence.spec.ts b/packages/bruno-api-docs/e2e/tests/playground/tab-persistence.spec.ts index eeb00c80..4bff8649 100644 --- a/packages/bruno-api-docs/e2e/tests/playground/tab-persistence.spec.ts +++ b/packages/bruno-api-docs/e2e/tests/playground/tab-persistence.spec.ts @@ -2,8 +2,6 @@ import { test, expect } from '../../playwright'; const USERS_BODY = '{"data":[{"id":1,"name":"Alice"}]}'; -// Changing the dock placement swaps the dock component, which remounts the request and response -// panes; the selected tab in each pane must survive that remount, as it does in the app. test.describe('Playground tabs across a dock change', () => { test.use({ viewport: { width: 1280, height: 900 } }); diff --git a/packages/bruno-api-docs/src/components/HighlightedInput/HighlightedInput.tsx b/packages/bruno-api-docs/src/components/HighlightedInput/HighlightedInput.tsx index 8984d968..5ea8a135 100644 --- a/packages/bruno-api-docs/src/components/HighlightedInput/HighlightedInput.tsx +++ b/packages/bruno-api-docs/src/components/HighlightedInput/HighlightedInput.tsx @@ -188,7 +188,6 @@ export const HighlightedInput: React.FC = ({ const el = inputRef.current; if (!multiline || !el) return; el.style.height = 'auto'; - // scrollHeight excludes the border, which border-box sizing would otherwise take from the content. el.style.height = `${el.scrollHeight + el.offsetHeight - el.clientHeight}px`; const mirror = mirrorRef.current; if (mirror) { @@ -199,7 +198,6 @@ export const HighlightedInput: React.FC = ({ useLayoutEffect(fitFieldHeight, [value, fitFieldHeight]); - // A narrower column re-wraps the text onto more lines, so the height must follow width changes too. useEffect(() => { const el = inputRef.current; if (!multiline || !el || typeof ResizeObserver === 'undefined') return; diff --git a/packages/bruno-api-docs/src/test-utils/dom.ts b/packages/bruno-api-docs/src/test-utils/dom.ts index 2e36aa6d..ceff450e 100644 --- a/packages/bruno-api-docs/src/test-utils/dom.ts +++ b/packages/bruno-api-docs/src/test-utils/dom.ts @@ -9,7 +9,6 @@ export const query = (root: HTMLElement, selector: string): HTMLElement => { /** Find an element by its `data-testid`, throwing if it is absent. */ export const getByTestId = (root: HTMLElement, testId: string): HTMLElement => query(root, `[data-testid="${testId}"]`); -/** The trimmed text of every table header cell, in document order. */ export const headerTexts = (root: HTMLElement): string[] => root.querySelectorAll('thead th').map((th) => th.text.trim()); diff --git a/packages/bruno-api-docs/src/utils/assertionRows.ts b/packages/bruno-api-docs/src/utils/assertionRows.ts index cbb9a037..e8d1699d 100644 --- a/packages/bruno-api-docs/src/utils/assertionRows.ts +++ b/packages/bruno-api-docs/src/utils/assertionRows.ts @@ -4,7 +4,6 @@ import { descriptionText, resolveDescription } from './description'; export const DEFAULT_ASSERTION_OPERATOR = 'eq'; -/** Operators that test the expression alone, so the assertion carries no expected value. */ export const UNARY_ASSERTION_OPERATORS = new Set([ 'isEmpty', 'isNotEmpty', From 261cbcb8792a41b760f277e0649edf0aa4363a27 Mon Sep 17 00:00:00 2001 From: Sundram Gupta Date: Tue, 22 Sep 2026 17:48:35 +0530 Subject: [PATCH 5/7] revert(playground): restore key-value table height and cell tooltips Parity item 17 was worded ambiguously and the implementation followed the wrong reading, so the table height cap and the removal of the cell tooltips both come out. Tables grow with their content again and a clipped cell shows its full text on hover and through its title. --- .../tests/playground/key-value-table.spec.ts | 33 +++++-------------- .../HighlightedInput/HighlightedInput.tsx | 3 ++ .../KeyValueTable/KeyValueTable.spec.tsx | 7 ---- .../KeyValueTable/KeyValueTable.tsx | 32 +++++++++++------- .../components/KeyValueTable/StyledWrapper.ts | 6 +++- 5 files changed, 38 insertions(+), 43 deletions(-) diff --git a/packages/bruno-api-docs/e2e/tests/playground/key-value-table.spec.ts b/packages/bruno-api-docs/e2e/tests/playground/key-value-table.spec.ts index cfe11d67..4d82d1d1 100644 --- a/packages/bruno-api-docs/e2e/tests/playground/key-value-table.spec.ts +++ b/packages/bruno-api-docs/e2e/tests/playground/key-value-table.spec.ts @@ -5,7 +5,7 @@ const textStart = (el: HTMLElement) => + parseFloat(getComputedStyle(el).borderLeftWidth) + parseFloat(getComputedStyle(el).paddingLeft); -test.describe('KeyValueTable: cells, scrolling and layout', () => { +test.describe('KeyValueTable: cells and layout', () => { test.beforeEach(async ({ page, playground }) => { await page.goto('/#/?pg=1&dock=bottom'); await playground.openSidebarItem('get users'); @@ -13,30 +13,15 @@ test.describe('KeyValueTable: cells, scrolling and layout', () => { await expect(playground.keyValueTable.root).toBeVisible(); }); - test('a cell shows neither a native title nor a hover tooltip, even when its text is clipped', async ({ page, playground, tooltip }) => { + test('a typed cell carries a native title tooltip with its full value', async ({ page, playground }) => { const { keyValueTable } = playground; - const blankRowIndex = (await keyValueTable.nameInputs.count()) - 1; - const nameInput = keyValueTable.nameInputs.nth(blankRowIndex); - await nameInput.fill('X-A-Very-Long-Custom-Header-Name-That-Overflows-Its-Cell-Width'); - await expect(nameInput).toHaveValue(/Overflows/); - await expect(nameInput).not.toHaveAttribute('title', /.+/); - - const valueInput = keyValueTable.valueInputs.nth(blankRowIndex); - await valueInput.fill('a value long enough to overflow the value cell and be clipped by the column width in the table'); - await valueInput.hover(); - await page.waitForTimeout(400); - await expect(tooltip.popup).toHaveCount(0); - }); - - test('the table caps its height and scrolls its rows inside the container', async ({ playground }) => { - const { keyValueTable } = playground; - for (let i = 0; i < 14; i++) { - await keyValueTable.nameInputs.last().fill(`X-Row-${i}`); - } - await expect(keyValueTable.container).toHaveCSS('max-height', '384px'); - await expect - .poll(() => keyValueTable.container.evaluate((el) => el.scrollHeight > el.clientHeight + 1)) - .toBe(true); + const nameInput = keyValueTable.nameInputs.first(); + await nameInput.click(); + await page.keyboard.type('-A-Very-Long-Custom-Header-Name-That-Truncates'); + // The full value is exposed as a native title tooltip so a truncated cell stays readable. + const value = await nameInput.inputValue(); + expect(value.length).toBeGreaterThan(0); + await expect(nameInput).toHaveAttribute('title', value); }); test('the first column header lines up with the name text in the rows below it', async ({ playground }) => { diff --git a/packages/bruno-api-docs/src/components/HighlightedInput/HighlightedInput.tsx b/packages/bruno-api-docs/src/components/HighlightedInput/HighlightedInput.tsx index 5ea8a135..80ad27f5 100644 --- a/packages/bruno-api-docs/src/components/HighlightedInput/HighlightedInput.tsx +++ b/packages/bruno-api-docs/src/components/HighlightedInput/HighlightedInput.tsx @@ -22,6 +22,7 @@ interface HighlightedInputProps { names: string[]; anywordHints?: string[]; variablesAutocomplete?: boolean; + title?: string; testId?: string; multiline?: boolean; /** Key handler forwarded only when the autocomplete dropdown is closed (e.g. Enter-to-send). */ @@ -80,6 +81,7 @@ export const HighlightedInput: React.FC = ({ names, anywordHints, variablesAutocomplete = true, + title, testId, multiline = false, onKeyDown, @@ -371,6 +373,7 @@ export const HighlightedInput: React.FC = ({ 'className': 'text-input', 'data-testid': testId, value, + title, placeholder, 'onChange': handleChange, 'onKeyDown': handleKeyDown, diff --git a/packages/bruno-api-docs/src/components/KeyValueTable/KeyValueTable.spec.tsx b/packages/bruno-api-docs/src/components/KeyValueTable/KeyValueTable.spec.tsx index 038d44e4..85b21f71 100644 --- a/packages/bruno-api-docs/src/components/KeyValueTable/KeyValueTable.spec.tsx +++ b/packages/bruno-api-docs/src/components/KeyValueTable/KeyValueTable.spec.tsx @@ -75,13 +75,6 @@ describe('KeyValueTable — a row that has a value but no name', () => { }); }); -describe('KeyValueTable: cell tooltips', () => { - it('does not expose the row name as a native title tooltip', () => { - const root = useRenderToDom(); - expect(getByTestId(root, 'key-value-table-name-input').hasAttribute('title')).toBe(false); - }); -}); - describe('KeyValueTable: first column header alignment', () => { it('flags the table when rows carry an enable checkbox, so the header indents past it', () => { const root = useRenderToDom(); diff --git a/packages/bruno-api-docs/src/components/KeyValueTable/KeyValueTable.tsx b/packages/bruno-api-docs/src/components/KeyValueTable/KeyValueTable.tsx index a47aa9d5..d363061c 100644 --- a/packages/bruno-api-docs/src/components/KeyValueTable/KeyValueTable.tsx +++ b/packages/bruno-api-docs/src/components/KeyValueTable/KeyValueTable.tsx @@ -56,6 +56,11 @@ interface KeyValueTableProps { testId?: string; } +const valueTruncated = (anchor: HTMLElement): boolean => { + const field = anchor.querySelector('input, textarea'); + return !!field && (field.scrollWidth > field.clientWidth || field.scrollHeight > field.clientHeight); +}; + const KeyValueTable: React.FC = ({ data, onChange, @@ -299,16 +304,20 @@ const KeyValueTable: React.FC = ({ onChange={(v) => updateField(index, 'value', v)} /> ) : ( - updateField(index, 'value', v)} - isFound={isFound} - names={names} - anywordHints={valueAutocomplete} - multiline={multilineValues} - testId={`${testId}-value-input`} - /> + + + updateField(index, 'value', v)} + isFound={isFound} + names={names} + anywordHints={valueAutocomplete} + multiline={multilineValues} + testId={`${testId}-value-input`} + /> + + ); return ( @@ -327,7 +336,7 @@ const KeyValueTable: React.FC = ({ )} {readOnlyKey ? ( - + {row.name} ) : ( @@ -339,6 +348,7 @@ const KeyValueTable: React.FC = ({ names={names} anywordHints={keyAutocomplete} variablesAutocomplete={false} + title={row.name} testId={`${testId}-name-input`} /> )} diff --git a/packages/bruno-api-docs/src/components/KeyValueTable/StyledWrapper.ts b/packages/bruno-api-docs/src/components/KeyValueTable/StyledWrapper.ts index 7de6e26d..0a177785 100644 --- a/packages/bruno-api-docs/src/components/KeyValueTable/StyledWrapper.ts +++ b/packages/bruno-api-docs/src/components/KeyValueTable/StyledWrapper.ts @@ -10,7 +10,6 @@ export const StyledWrapper = styled.div` .key-value-table-container { overflow: auto; - max-height: 24rem; border-radius: 0.375rem; border: 0.0625rem solid var(--oc-table-border); scrollbar-width: thin; @@ -211,6 +210,11 @@ export const StyledWrapper = styled.div` min-width: 0; } + .key-value-table .value-input-tip { + display: block; + min-width: 0; + } + .key-value-table .col-value .secret-value { padding: 0.625rem; } From 3edab3d8c2ef0e945890eb426d9861665c58d119 Mon Sep 17 00:00:00 2001 From: Sundram Gupta Date: Wed, 23 Sep 2026 07:04:28 +0530 Subject: [PATCH 6/7] style(playground): use rem for the key-value table font size --- .../src/components/KeyValueTable/StyledWrapper.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/bruno-api-docs/src/components/KeyValueTable/StyledWrapper.ts b/packages/bruno-api-docs/src/components/KeyValueTable/StyledWrapper.ts index 0a177785..a4e82fa5 100644 --- a/packages/bruno-api-docs/src/components/KeyValueTable/StyledWrapper.ts +++ b/packages/bruno-api-docs/src/components/KeyValueTable/StyledWrapper.ts @@ -39,7 +39,7 @@ export const StyledWrapper = styled.div` min-width: 28rem; border-collapse: collapse; table-layout: fixed; - font-size: 12px; + font-size: 0.75rem; } .key-value-table thead { From eb9c6ac500d6264e00ab74220a18327e97bba51e Mon Sep 17 00:00:00 2001 From: Arpit Date: Thu, 24 Sep 2026 04:39:26 +0530 Subject: [PATCH 7/7] Fix UI parity bugs and improve playground features Updated UI components for better consistency and usability across the playground, including multiline fields, header suggestions, and tab persistence. --- .changeset/ten-things-see.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 .changeset/ten-things-see.md diff --git a/.changeset/ten-things-see.md b/.changeset/ten-things-see.md new file mode 100644 index 00000000..cec825fd --- /dev/null +++ b/.changeset/ten-things-see.md @@ -0,0 +1,19 @@ +--- +"@usebruno/api-docs": patch +--- + +What changed +18. Environment value fields. Multiline value fields grow to fit their content instead of scrolling inside a fixed box, and they re-fit when a column is dragged narrower. Table, card and secret variants now behave the same. + +21. Header name suggestions. The suggestions list renders in a portal, so the playground's scrollbar styling never reached it. It now uses the same thin themed scrollbar as the rest of the app. + +24. Request tabs across a dock change. Changing the dock placement swaps the dock component and remounts the request and response panes, which reset their tab to the default. Both panes now keep the selected tab in session storage, the lane the collapsible sections and dock sizes already use. + +26. Assertion descriptions. The Assertions tab was the only tab without a Description column, although the format and the desktop app both carry the field. It now shows and persists the description, and its column labels match the app (Expr, Value). + +29. First column alignment. The first column header now starts exactly where the cell text below it starts, with and without the enable checkbox. The query params table labels that column Name instead of Key, as the app does. + +31. Script error cards. When both the post-response and tests scripts failed, closing one error card closed both, and the cards took the panel's height from the content below them. Each card now closes on its own and the response body and test results keep their full height. + +Also in this PR +KeyValueTable.css becomes an Emotion StyledWrapper, matching every other component in the package. The legacy .text-input rules are dropped because HighlightedInput already owns those fields.