From 0a4fec858bee6b639d7b3183dc3ad1ef71dadc77 Mon Sep 17 00:00:00 2001 From: Sachin Venugopalan Date: Mon, 27 Jul 2026 15:51:40 +0530 Subject: [PATCH 1/8] feat: add new description columns for variables, headers, params section --- .../key-value-table.component.ts | 11 ++ .../e2e/components/playground.component.ts | 15 +- .../tests/playground/keyvalue-table.spec.ts | 17 ++ .../playground-descriptions.spec.ts | 48 +++++ .../KeyValueTable/KeyValueTable.css | 52 +++++- .../KeyValueTable/KeyValueTable.spec.tsx | 49 ++++++ .../KeyValueTable/KeyValueTable.tsx | 164 ++++++++++++++++-- .../CollectionSettingsView.tsx | 16 +- .../Content/Views/Common/BodyTab.spec.tsx | 34 ++++ .../Content/Views/Common/BodyTab.tsx | 23 ++- .../Views/Common/BulkEdit/BulkEdit.tsx | 15 +- .../Common/HeadersTab/HeadersTab.spec.tsx | 16 ++ .../Views/Common/HeadersTab/HeadersTab.tsx | 4 +- .../Views/Common/ParamsTab/ParamsTab.spec.tsx | 32 ++++ .../Views/Common/ParamsTab/ParamsTab.tsx | 7 +- .../Common/VariablesTab/VariablesTab.spec.tsx | 13 ++ .../Common/VariablesTab/VariablesTab.tsx | 8 +- .../FolderSettingsView/FolderSettingsView.tsx | 92 ++-------- .../RequestPane/RequestPane.tsx | 45 ++--- packages/bruno-api-docs/src/dev.tsx | 5 +- .../src/e2eFixtures/descriptionsCollection.ts | 36 ++++ .../src/utils/bulkKeyValue.spec.ts | 51 +++++- .../bruno-api-docs/src/utils/bulkKeyValue.ts | 41 +++++ .../src/utils/keyValueRow.spec.ts | 29 ++++ .../bruno-api-docs/src/utils/keyValueRow.ts | 21 +++ packages/bruno-api-docs/src/utils/request.ts | 2 +- .../src/utils/variableDataType.spec.ts | 9 + .../src/utils/variableDataType.ts | 2 +- 28 files changed, 670 insertions(+), 187 deletions(-) create mode 100644 packages/bruno-api-docs/e2e/tests/playground/playground-descriptions.spec.ts create mode 100644 packages/bruno-api-docs/src/components/KeyValueTable/KeyValueTable.spec.tsx create mode 100644 packages/bruno-api-docs/src/components/Playground/Content/Views/Common/BodyTab.spec.tsx create mode 100644 packages/bruno-api-docs/src/components/Playground/Content/Views/Common/ParamsTab/ParamsTab.spec.tsx create mode 100644 packages/bruno-api-docs/src/e2eFixtures/descriptionsCollection.ts create mode 100644 packages/bruno-api-docs/src/utils/keyValueRow.spec.ts create mode 100644 packages/bruno-api-docs/src/utils/keyValueRow.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 ab45e001..35a18b14 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 @@ -7,8 +7,11 @@ export class KeyValueTableComponent extends BaseComponent { readonly table: Locator; readonly nameInputs: Locator; readonly valueInputs: Locator; + readonly descriptionInputs: Locator; + readonly descriptionHeader: Locator; readonly cellErrors: Locator; readonly autocomplete: Locator; + readonly resizeHandles: Locator; constructor(page: Page, testId = 'key-value-table') { super(page, page.getByTestId(testId)); @@ -16,12 +19,20 @@ export class KeyValueTableComponent extends BaseComponent { this.table = page.getByTestId(`${testId}-table`); this.nameInputs = page.getByTestId(`${testId}-name-input`); this.valueInputs = page.getByTestId(`${testId}-value-input`); + this.descriptionInputs = page.getByTestId(`${testId}-description-input`); + this.descriptionHeader = page.getByTestId(`${testId}-description-header`); this.cellErrors = page.getByTestId(`${testId}-error`); this.autocomplete = page.getByTestId('variable-autocomplete'); + this.resizeHandles = this.table.locator('.col-resize-handle'); } /** The per-row enable/disable checkbox, addressed by the row name via its aria-label. */ enableToggle(name: string): Locator { return this.table.getByRole('checkbox', { name: `Enable ${name}` }); } + + /** A column's header cell, addressed by its `col-*` class — used to measure the column width. */ + columnHeader(colClass: string): Locator { + return this.table.locator(`thead th.${colClass}`); + } } diff --git a/packages/bruno-api-docs/e2e/components/playground.component.ts b/packages/bruno-api-docs/e2e/components/playground.component.ts index 75f42bcb..13220a3b 100644 --- a/packages/bruno-api-docs/e2e/components/playground.component.ts +++ b/packages/bruno-api-docs/e2e/components/playground.component.ts @@ -6,6 +6,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 preRequestScriptEditor = new CodeEditorComponent(this.page, 'scripts-editor-pre-request'); readonly postResponseScriptEditor = new CodeEditorComponent(this.page, 'scripts-editor-post-response'); readonly bodyEditor = new CodeEditorComponent(this.page, 'body-editor'); @@ -21,10 +22,10 @@ export class PlaygroundComponent extends BaseComponent { readonly sidebarBackdrop = this.page.getByTestId('playground-sidebar-backdrop'); readonly collectionNode = this.page.getByTestId('sidebar-collection-root'); readonly collectionCollapseToggle = this.collectionNode.getByRole('button', { - name: /Collapse collection|Expand collection/, + name: /Collapse collection|Expand collection/ }); readonly collectionRootLink = this.collectionNode.getByRole('button', { - name: /Bruno Testbench|Collection/, + name: /Bruno Testbench|Collection/ }); readonly envSwitcher = this.page.getByTestId('playground-env-switcher'); readonly gear = this.page.getByTestId('playground-env-settings'); @@ -57,6 +58,7 @@ export class PlaygroundComponent extends BaseComponent { async open(dock: DockMode = 'bottom'): Promise { await this.page.goto(`/#/?pg=1&dock=${dock}`); + await this.runner.waitFor({ state: 'visible' }); } sidebarItem(name: string): Locator { @@ -77,7 +79,7 @@ export class PlaygroundComponent extends BaseComponent { async openTreeItem(names: string[]): Promise { for (const name of names) { - await this.treeItems.filter({ hasText: name }).first().click(); + await this.sidebarItem(name).click(); } } @@ -89,13 +91,8 @@ export class PlaygroundComponent extends BaseComponent { return this.page.getByTestId(`playground-dock-${mode}-panel`); } - async open(mode: DockMode): Promise { - await this.page.goto(`/#/?pg=1&dock=${mode}`); - await this.runner.waitFor({ state: 'visible' }); - } - async openRequest(name: string): Promise { - await this.treeItems.filter({ hasText: name }).first().click(); + await this.sidebarItem(name).click(); await this.view.waitFor({ state: 'visible' }); } 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 index fa98c474..38dfcd51 100644 --- a/packages/bruno-api-docs/e2e/tests/playground/keyvalue-table.spec.ts +++ b/packages/bruno-api-docs/e2e/tests/playground/keyvalue-table.spec.ts @@ -71,4 +71,21 @@ test.describe('KeyValueTable — tooltips & mobile scroll', () => { 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 new file mode 100644 index 00000000..a07587a6 --- /dev/null +++ b/packages/bruno-api-docs/e2e/tests/playground/playground-descriptions.spec.ts @@ -0,0 +1,48 @@ +import { test, expect } from '../../playwright'; + +// A request whose params, headers, variables and form body all carry authored descriptions. +const DESCRIBED = '/?fixture=descriptions#/?pg=1&dock=bottom'; + +test.describe('Playground — field descriptions', () => { + test.beforeEach(async ({ page, playground }) => { + await page.goto(DESCRIBED); + await playground.openRequest('Described Request'); + }); + + test('query params show a Description column with the authored text', async ({ playground }) => { + await playground.selectTab('params'); + await expect(playground.keyValueTable.descriptionHeader).toBeVisible(); + await expect(playground.keyValueTable.descriptionInputs.first()).toHaveValue( + 'Filter orders by their fulfilment status' + ); + }); + + test('headers show descriptions, normalizing the legacy {content} object form to text', async ({ playground }) => { + await playground.selectTab('headers'); + const descriptions = playground.keyValueTable.descriptionInputs; + await expect(descriptions.nth(0)).toHaveValue('Bearer access token for the API'); + await expect(descriptions.nth(1)).toHaveValue('Correlation id echoed back on the response'); + }); + + test('pre-request variables show the authored description', async ({ playground }) => { + await playground.selectTab('variables'); + await expect(playground.preRequestVars.descriptionInputs.first()).toHaveValue('The order identifier under test'); + }); + + 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'); + }); + + test('a description is editable and the edit persists across a tab switch', async ({ playground }) => { + await playground.selectTab('params'); + const description = playground.keyValueTable.descriptionInputs.first(); + await description.fill('Only open, unfulfilled orders'); + await expect(description).toHaveValue('Only open, unfulfilled orders'); + + // Leaving the tab unmounts the table; returning re-reads from the edited request state. + await playground.selectTab('headers'); + await playground.selectTab('params'); + await expect(playground.keyValueTable.descriptionInputs.first()).toHaveValue('Only open, unfulfilled orders'); + }); +}); diff --git a/packages/bruno-api-docs/src/components/KeyValueTable/KeyValueTable.css b/packages/bruno-api-docs/src/components/KeyValueTable/KeyValueTable.css index 7b7a522b..c4f0ada9 100644 --- a/packages/bruno-api-docs/src/components/KeyValueTable/KeyValueTable.css +++ b/packages/bruno-api-docs/src/components/KeyValueTable/KeyValueTable.css @@ -49,6 +49,7 @@ } .key-value-table thead th { + position: relative; padding: 0.5rem 0.625rem 0.4375rem; text-align: left; font-size: 0.75rem; @@ -65,6 +66,32 @@ border-right: none; } +/* Drag handle straddling a header cell's right border to resize its column. Transparent until + hovered/active so it only shows the col-resize affordance where a divider actually is. */ +.key-value-table .col-resize-handle { + position: absolute; + /* top+bottom (not height:100%) — percentage heights are unreliable inside a table cell. */ + top: 0; + bottom: 0; + /* 4px divider centred on the column border, matching the app's thickness. */ + right: -0.125rem; + z-index: 2; + width: 0.25rem; + cursor: col-resize; + background-color: transparent; +} + +.key-value-table .col-resize-handle:hover, +.key-value-table .col-resize-handle.is-resizing { + background-color: var(--primary-color); +} + +/* While dragging, keep the resize cursor and stop text selection across the whole table. */ +.key-value-table-wrapper.is-resizing { + cursor: col-resize; + user-select: none; +} + .key-value-table col.col-key { width: calc(30% + 1.625rem); } @@ -73,10 +100,22 @@ 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; } @@ -210,19 +249,16 @@ align-items: center; gap: 0.5rem; padding-left: 0.625rem; -} - -.key-value-table .value-cell-trailing .delete-button { - margin-right: 1.25rem; + /* Right gap keeps the last trailing control (the variable-type dropdown, or the delete button when + it renders inline) clear of the cell's right border — delete no longer supplies it once it moves + to its own column. */ + padding-right: 1.25rem; } .key-value-table .value-cell:has(.secret-value) .value-cell-trailing { gap: 0.625rem; padding-left: 0; -} - -.key-value-table .value-cell:has(.secret-value) .value-cell-trailing .delete-button { - margin-right: 0.625rem; + padding-right: 0.625rem; } .key-value-table .delete-button { diff --git a/packages/bruno-api-docs/src/components/KeyValueTable/KeyValueTable.spec.tsx b/packages/bruno-api-docs/src/components/KeyValueTable/KeyValueTable.spec.tsx new file mode 100644 index 00000000..e476f982 --- /dev/null +++ b/packages/bruno-api-docs/src/components/KeyValueTable/KeyValueTable.spec.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { describe, it, expect } from 'vitest'; +import { useRenderToDom } from '../../hooks/useRenderToDom'; +import KeyValueTable, { type KeyValueRow } from './KeyValueTable'; + +const noop = () => {}; +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(); + expect(headerTexts(root)).toContain('Description'); + expect(root.querySelector('[data-testid="key-value-table-description-input"]')).toBeTruthy(); + expect(root.text).toContain('Correlation id'); + }); + + it('omits the Description column by default', () => { + const root = useRenderToDom(); + expect(headerTexts(root)).not.toContain('Description'); + expect(root.querySelector('[data-testid="key-value-table-description-input"]')).toBeFalsy(); + }); + + it('orders columns name → value → Description → delete when actions are inline', () => { + // Headers/vars pass inlineActions (delete normally sits inside the value cell); adding the + // description column pushes delete back out to its own trailing column, matching the app. + const root = useRenderToDom(); + const columnClasses = root.querySelectorAll('colgroup col').map((col) => col.getAttribute('class')); + expect(columnClasses).toEqual(['col-key', 'col-value', 'col-description', 'col-actions']); + }); +}); + +describe('KeyValueTable — resizable columns', () => { + it('renders a resize handle on every resizable column except the last, by default', () => { + // Key, Value and Description are resizable; the delete column is fixed and the last resizable + // column (Description) has no right neighbour to trade width with, so handles land on Key + Value. + const root = useRenderToDom(); + expect(root.querySelectorAll('.col-resize-handle').length).toBe(2); + }); + + it('renders no resize handles when resizableColumns is disabled', () => { + const root = useRenderToDom(); + expect(root.querySelector('.col-resize-handle')).toBeFalsy(); + }); +}); diff --git a/packages/bruno-api-docs/src/components/KeyValueTable/KeyValueTable.tsx b/packages/bruno-api-docs/src/components/KeyValueTable/KeyValueTable.tsx index dea08e61..6fc5498c 100644 --- a/packages/bruno-api-docs/src/components/KeyValueTable/KeyValueTable.tsx +++ b/packages/bruno-api-docs/src/components/KeyValueTable/KeyValueTable.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { useResolvedVariables } from '../../hooks/useVariableResolver'; import { useEditableRows } from '../../hooks/useEditableRows'; import { Tooltip } from '../../ui/Tooltip/Tooltip'; @@ -8,6 +8,9 @@ import { SecretValue } from '../../ui/SecretValue/SecretValue'; import './KeyValueTable.css'; import Checkbox from '../../ui/Checkbox/Checkbox'; +// Smallest a column may be dragged to; the neighbour it trades width with is held to the same floor. +const MIN_COLUMN_WIDTH = 60; + export interface KeyValueRow { id: string; name: string; @@ -47,6 +50,9 @@ interface KeyValueTableProps { inlineActions?: boolean; multilineValues?: boolean; secretEditByDefault?: boolean; + showDescription?: boolean; + /** Let the user drag column dividers to resize (on by default; the delete column stays fixed). */ + resizableColumns?: boolean; testId?: string; } @@ -76,11 +82,114 @@ const KeyValueTable: React.FC = ({ inlineActions = false, multilineValues = false, secretEditByDefault = false, + showDescription = false, + resizableColumns = true, testId = 'key-value-table' }) => { const { isFound, names } = useResolvedVariables(); const { rows, updateField, removeRow } = useEditableRows(data, onChange, { disableNewRow, makeNewRow, addWhenComplete }); + // With a description column the delete action must sit in its own trailing column (after + // Description) rather than inline in the value cell, so any inline extras (e.g. the variable + // type dropdown) still render beside the value while delete stays last — matching the app. + const actionsAsColumn = showActions && (!inlineActions || showDescription); + const inlineDelete = showActions && !disableDelete && !actionsAsColumn; + + const tableRef = useRef(null); + const [columnWidths, setColumnWidths] = useState>({}); + const [resizingKey, setResizingKey] = useState(null); + const dragCleanup = useRef<(() => void) | null>(null); + useEffect(() => () => dragCleanup.current?.(), []); + + // The resize divider spans the whole column (header + rows), like the app, so the handle — anchored + // in the header cell — is stretched to the full table height. Tracked with a ResizeObserver so it + // follows rows being added or removed. (Effects don't run under SSR, so this is server-safe.) + const [tableHeight, setTableHeight] = useState(0); + useEffect(() => { + const table = tableRef.current; + if (!table || typeof ResizeObserver === 'undefined') return; + const measure = () => setTableHeight(table.offsetHeight); + measure(); + const observer = new ResizeObserver(measure); + observer.observe(table); + return () => observer.disconnect(); + }, []); + + // Columns the user can resize, left→right. The delete column is fixed chrome, and the last + // resizable column gets no handle (it has no right neighbour to trade width with) — like the app. + const resizableKeys = resizableColumns + ? [ + 'key', + 'value', + ...(!inlineActions ? additionalColumns.map((col) => col.key) : []), + ...(showDescription ? ['description'] : []) + ] + : []; + const lastResizableKey = resizableKeys[resizableKeys.length - 1]; + + // Drag a divider to resize: the dragged column and its right neighbour trade width zero-sum, so the + // table width never changes and no other column shifts. Each new width is written straight to the + // elements during the drag (no re-render per mouse-move) and committed to state as percentages + // on release, so the columns keep scaling with the pane afterwards. + const startResize = (event: React.MouseEvent, columnKey: string) => { + event.preventDefault(); + event.stopPropagation(); + const table = tableRef.current; + const nextKey = resizableKeys[resizableKeys.indexOf(columnKey) + 1]; + if (!table || !nextKey) return; + + const columnCol = table.querySelector(`col.col-${columnKey}`); + const nextCol = table.querySelector(`col.col-${nextKey}`); + const columnHeader = table.querySelector(`thead th.col-${columnKey}`); + const nextHeader = table.querySelector(`thead th.col-${nextKey}`); + if (!columnCol || !nextCol || !columnHeader || !nextHeader) return; + + const startX = event.clientX; + const startWidth = columnHeader.offsetWidth; + const nextStartWidth = nextHeader.offsetWidth; + + const onMove = (moveEvent: MouseEvent) => { + const delta = moveEvent.clientX - startX; + const clamped = Math.max(MIN_COLUMN_WIDTH - startWidth, Math.min(nextStartWidth - MIN_COLUMN_WIDTH, delta)); + columnCol.style.width = `${startWidth + clamped}px`; + nextCol.style.width = `${nextStartWidth - clamped}px`; + }; + + const finish = () => { + dragCleanup.current?.(); + dragCleanup.current = null; + setResizingKey(null); + const tableWidth = table.offsetWidth; + if (tableWidth <= 0) return; + setColumnWidths((current) => { + const next = { ...current }; + resizableKeys.forEach((key) => { + const header = table.querySelector(`thead th.col-${key}`); + if (header) next[key] = `${(header.offsetWidth / tableWidth) * 100}%`; + }); + return next; + }); + }; + + dragCleanup.current = () => { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', finish); + }; + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', finish); + setResizingKey(columnKey); + }; + + const resizeHandle = (columnKey: string) => + resizableColumns && columnKey !== lastResizableKey ? ( + startResize(event, columnKey)} + aria-hidden="true" + /> + ) : null; + const cellError = (row: KeyValueRow, index: number, field: 'name' | 'value') => { const message = getRowError?.(row, index, field); if (!message) return null; @@ -94,27 +203,43 @@ const KeyValueTable: React.FC = ({ }; return ( -
+
- +
- - + + {!inlineActions && - additionalColumns.map((col) => )} - {!inlineActions && showActions && } + additionalColumns.map((col) => ( + + ))} + {showDescription && } + {actionsAsColumn && } - - + + {!inlineActions && additionalColumns.map((col) => ( ))} - {!inlineActions && showActions && } + {showDescription && ( + + )} + {actionsAsColumn && } @@ -213,12 +338,12 @@ const KeyValueTable: React.FC = ({
{valueField}
{!isLastEmptyRow && cellError(row, index, 'value')} - {!isLastEmptyRow && ( + {!isLastEmptyRow && (additionalColumns.length > 0 || inlineDelete) && (
{additionalColumns.map((col) => ( {col.render(row, index, updateCell)} ))} - {showActions && !disableDelete && deleteButton} + {inlineDelete && deleteButton}
)}
@@ -232,7 +357,20 @@ const KeyValueTable: React.FC = ({ {!isLastEmptyRow && col.render(row, index, updateCell)} ))} - {!inlineActions && showActions && ( + {showDescription && ( +
+ )} + {actionsAsColumn && ( )} diff --git a/packages/bruno-api-docs/src/components/Playground/Content/Views/CollectionSettingsView/CollectionSettingsView.tsx b/packages/bruno-api-docs/src/components/Playground/Content/Views/CollectionSettingsView/CollectionSettingsView.tsx index a743c5cc..768dc9bf 100644 --- a/packages/bruno-api-docs/src/components/Playground/Content/Views/CollectionSettingsView/CollectionSettingsView.tsx +++ b/packages/bruno-api-docs/src/components/Playground/Content/Views/CollectionSettingsView/CollectionSettingsView.tsx @@ -13,6 +13,7 @@ import { useAppDispatch } from '../../../../../store/hooks'; import { updateCollectionSettings } from '@slices/playground'; import { countEnabled, getItemDocs, scriptsArrayToObject, scriptsObjectToArray } from '../../../../../utils/schemaHelpers'; import { rowToVariable } from '../../../../../utils/variableDataType'; +import { keyValueRowToEntry } from '../../../../../utils/keyValueRow'; import { actionsToPostResponseVars, postResponseVarsToActions } from '../../../../../utils/request'; import { StyledWrapper } from './StyledWrapper'; @@ -29,20 +30,7 @@ const CollectionSettings: React.FC = ({ collection }) = }; const handleHeadersChange = (rows: KeyValueRow[]) => { - const originals = collection.request?.headers ?? []; - const originalByName = new Map(originals.filter((header) => header.name).map((header): [string, typeof header] => [header.name as string, header])); - updateRequest({ - ...collection.request, - headers: rows.map((row) => { - const description = 'description' in row ? row.description : originalByName.get(row.name)?.description; - return { - name: row.name, - value: row.value, - disabled: !row.enabled, - ...(description !== undefined ? { description } : {}) - }; - }) - }); + updateRequest({ ...collection.request, headers: rows.map(keyValueRowToEntry) }); }; const handleVariablesChange = (rows: KeyValueRow[]) => { diff --git a/packages/bruno-api-docs/src/components/Playground/Content/Views/Common/BodyTab.spec.tsx b/packages/bruno-api-docs/src/components/Playground/Content/Views/Common/BodyTab.spec.tsx new file mode 100644 index 00000000..7ffd7151 --- /dev/null +++ b/packages/bruno-api-docs/src/components/Playground/Content/Views/Common/BodyTab.spec.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { describe, it, expect } from 'vitest'; +import type { HttpRequest } from '@opencollection/types/requests/http'; +import { useRenderToDom } from '../../../../../hooks/useRenderToDom'; +import { BodyTab } from './BodyTab'; +import type { RequestBody } from '../../../../../utils/schemaHelpers'; + +const noop = () => {}; +const item = { http: { method: 'POST', url: 'https://api.example.com' } } as HttpRequest; + +const columnLabels = (root: ReturnType) => + root.querySelectorAll('thead th').map((th) => th.text.trim()); + +describe('BodyTab — form body descriptions', () => { + it('shows a Description column with authored form-urlencoded field descriptions', () => { + const body = { + type: 'form-urlencoded', + data: [{ name: 'grant_type', value: 'client_credentials', description: 'The OAuth grant type' }] + } as RequestBody; + const root = useRenderToDom(); + expect(columnLabels(root)).toContain('Description'); + expect(root.text).toContain('The OAuth grant type'); + }); + + it('shows a Description column with authored multipart text-field descriptions', () => { + const body = { + type: 'multipart-form', + data: [{ name: 'meta', value: '{}', type: 'text', description: 'JSON metadata part' }] + } as RequestBody; + const root = useRenderToDom(); + expect(columnLabels(root)).toContain('Description'); + expect(root.text).toContain('JSON metadata part'); + }); +}); diff --git a/packages/bruno-api-docs/src/components/Playground/Content/Views/Common/BodyTab.tsx b/packages/bruno-api-docs/src/components/Playground/Content/Views/Common/BodyTab.tsx index 89d11c2f..bcabb562 100644 --- a/packages/bruno-api-docs/src/components/Playground/Content/Views/Common/BodyTab.tsx +++ b/packages/bruno-api-docs/src/components/Playground/Content/Views/Common/BodyTab.tsx @@ -2,6 +2,8 @@ import React from 'react'; import type { HttpRequest } from '@opencollection/types/requests/http'; import CodeEditor from '../../../../../ui/CodeEditor/CodeEditor'; import KeyValueTable, { type KeyValueRow } from '../../../../../components/KeyValueTable/KeyValueTable'; +import { getDescription } from '../../../../../utils/request'; +import { keyValueRowToEntry } from '../../../../../utils/keyValueRow'; import type { RequestBody } from '../../../../../utils/schemaHelpers'; interface BodyTabProps { @@ -20,11 +22,7 @@ export const BodyTab: React.FC = ({ const handleFormBodyChange = (formData: KeyValueRow[]) => { const updatedBody = { type: 'form-urlencoded' as const, - data: formData.map(f => ({ - name: f.name, - value: f.value, - disabled: !f.enabled - })) + data: formData.map(keyValueRowToEntry) }; onItemChange({ ...item, @@ -37,12 +35,7 @@ export const BodyTab: React.FC = ({ const handleMultipartChange = (rows: KeyValueRow[]) => { // Rebuild the text fields from the table; preserve any file fields untouched. - const textEntries = rows.map(r => ({ - name: r.name, - value: r.value, - type: 'text' as const, - disabled: !r.enabled - })); + const textEntries = rows.map((r) => ({ ...keyValueRowToEntry(r), type: 'text' as const })); const fileEntries = (((body as { data?: any[] })?.data as any[]) || []).filter(e => e?.type === 'file'); onItemChange({ ...item, @@ -94,7 +87,8 @@ export const BodyTab: React.FC = ({ id: `form-${index}`, name: field.name || '', value: field.value || '', - enabled: field.disabled !== true + enabled: field.disabled !== true, + description: getDescription(field) })); return ( @@ -110,6 +104,7 @@ export const BodyTab: React.FC = ({ keyPlaceholder="Key" valuePlaceholder="Value" showEnabled={true} + showDescription /> ); @@ -123,7 +118,8 @@ export const BodyTab: React.FC = ({ id: `mp-${index}`, name: e.name || '', value: e.value || '', - enabled: e.disabled !== true + enabled: e.disabled !== true, + description: getDescription(e) })); const fileEntries = entries.filter(e => e?.type === 'file'); @@ -140,6 +136,7 @@ export const BodyTab: React.FC = ({ keyPlaceholder="Key" valuePlaceholder="Value" showEnabled={true} + showDescription /> {fileEntries.length > 0 && (
diff --git a/packages/bruno-api-docs/src/components/Playground/Content/Views/Common/BulkEdit/BulkEdit.tsx b/packages/bruno-api-docs/src/components/Playground/Content/Views/Common/BulkEdit/BulkEdit.tsx index 77c8f563..1664bef1 100644 --- a/packages/bruno-api-docs/src/components/Playground/Content/Views/Common/BulkEdit/BulkEdit.tsx +++ b/packages/bruno-api-docs/src/components/Playground/Content/Views/Common/BulkEdit/BulkEdit.tsx @@ -1,7 +1,7 @@ -import React, { useState } from 'react'; +import React, { useRef, useState } from 'react'; import CodeEditor from '../../../../../../ui/CodeEditor/CodeEditor'; import type { KeyValueRow } from '../../../../../../components/KeyValueTable/KeyValueTable'; -import { parseBulkKeyValue, serializeBulkKeyValue } from '../../../../../../utils/bulkKeyValue'; +import { parseBulkKeyValue, preserveDescriptions, serializeBulkKeyValue } from '../../../../../../utils/bulkKeyValue'; import { StyledWrapper } from './StyledWrapper'; export interface BulkEditProps { @@ -11,19 +11,12 @@ export interface BulkEditProps { } const BulkEdit: React.FC = ({ data, onChange, idPrefix = 'bulk' }) => { + const originalRef = useRef(data); const [text, setText] = useState(() => serializeBulkKeyValue(data)); const handleChange = (value: string) => { setText(value); - const parsed = parseBulkKeyValue(value); - onChange( - parsed.map((item, index) => ({ - id: `${idPrefix}-${index}`, - name: item.name, - value: item.value, - enabled: item.enabled - })) - ); + onChange(preserveDescriptions(parseBulkKeyValue(value), originalRef.current, idPrefix)); }; return ( diff --git a/packages/bruno-api-docs/src/components/Playground/Content/Views/Common/HeadersTab/HeadersTab.spec.tsx b/packages/bruno-api-docs/src/components/Playground/Content/Views/Common/HeadersTab/HeadersTab.spec.tsx index 53b3eb8d..bd6d1cd4 100644 --- a/packages/bruno-api-docs/src/components/Playground/Content/Views/Common/HeadersTab/HeadersTab.spec.tsx +++ b/packages/bruno-api-docs/src/components/Playground/Content/Views/Common/HeadersTab/HeadersTab.spec.tsx @@ -57,4 +57,20 @@ describe('HeadersTab', () => { ); expect(root.querySelector('.cell-error')).toBeNull(); }); + + it('shows a Description column with authored descriptions (bare string or {content} form)', () => { + const root = useRenderToDom( + + ); + const columns = root.querySelectorAll('thead th').map((th) => th.text.trim()); + expect(columns).toContain('Description'); + expect(root.text).toContain('The bearer token'); + expect(root.text).toContain('The media type'); + }); }); diff --git a/packages/bruno-api-docs/src/components/Playground/Content/Views/Common/HeadersTab/HeadersTab.tsx b/packages/bruno-api-docs/src/components/Playground/Content/Views/Common/HeadersTab/HeadersTab.tsx index c68b9edd..70917c86 100644 --- a/packages/bruno-api-docs/src/components/Playground/Content/Views/Common/HeadersTab/HeadersTab.tsx +++ b/packages/bruno-api-docs/src/components/Playground/Content/Views/Common/HeadersTab/HeadersTab.tsx @@ -2,6 +2,7 @@ import React, { useCallback, useState } from 'react'; import KeyValueTable, { type KeyValueRow } from '../../../../../../components/KeyValueTable/KeyValueTable'; import { STANDARD_HTTP_HEADERS, MIME_TYPES } from '../../../../../../constants/httpHeaders'; import { HEADER_NAME_REGEX, HEADER_VALUE_REGEX } from '../../../../../../constants/regex'; +import { getDescription } from '../../../../../../utils/request'; import BulkEdit from '../BulkEdit/BulkEdit'; import { StyledWrapper } from './StyledWrapper'; @@ -31,7 +32,7 @@ const HeadersDisplay: React.FC> = name: header.name || '', value: header.value || '', enabled: !header.disabled, - description: header.description + description: getDescription(header) })); const toggleBulkEditView = useCallback(() => { @@ -51,6 +52,7 @@ const HeadersDisplay: React.FC> = keyAutocomplete={STANDARD_HTTP_HEADERS} valueAutocomplete={MIME_TYPES} getRowError={getHeaderError} + showDescription /> ) : ( diff --git a/packages/bruno-api-docs/src/components/Playground/Content/Views/Common/ParamsTab/ParamsTab.spec.tsx b/packages/bruno-api-docs/src/components/Playground/Content/Views/Common/ParamsTab/ParamsTab.spec.tsx new file mode 100644 index 00000000..582e0e49 --- /dev/null +++ b/packages/bruno-api-docs/src/components/Playground/Content/Views/Common/ParamsTab/ParamsTab.spec.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { describe, it, expect } from 'vitest'; +import { useRenderToDom } from '../../../../../../hooks/useRenderToDom'; +import { ParamsTab } from './ParamsTab'; + +const noop = () => {}; + +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( + + ); + expect(columnLabels(root)).toContain('Description'); + expect(root.text).toContain('Page number, 1-based'); + }); + + it('normalizes an object-form ({content}) param description to its text', () => { + const root = useRenderToDom( + + ); + expect(root.text).toContain('Search query'); + }); +}); 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 ab64f4f7..6e76b4ef 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 @@ -1,9 +1,10 @@ import React, { useCallback, useMemo } from 'react'; import KeyValueTable, { type KeyValueRow } from '../../../../../KeyValueTable/KeyValueTable'; +import { getDescription } from '../../../../../../utils/request'; import { StyledWrapper } from './StyledWrapper'; interface ParamsTabProps { - params: Array<{ name?: string; value?: string; disabled?: boolean; type?: string }>; + params: Array<{ name?: string; value?: string; disabled?: boolean; type?: string; description?: unknown }>; onParamsChange: (params: KeyValueRow[]) => void; title?: string; description?: string; @@ -56,6 +57,7 @@ const ParamsSection: React.FC = React.memo(({ showActions={showActions} disableNewRow={disableNewRow} readOnlyKey={readOnlyKey} + showDescription />
)); @@ -80,7 +82,8 @@ export const ParamsTab: React.FC = ({ name: param.name || '', value: param.value || '', enabled: !param.disabled, - type: param.type || 'query' + type: param.type || 'query', + description: getDescription(param) }; (row.type === 'path' ? pathData : queryData).push(row); }); diff --git a/packages/bruno-api-docs/src/components/Playground/Content/Views/Common/VariablesTab/VariablesTab.spec.tsx b/packages/bruno-api-docs/src/components/Playground/Content/Views/Common/VariablesTab/VariablesTab.spec.tsx index 8122347d..2558dce4 100644 --- a/packages/bruno-api-docs/src/components/Playground/Content/Views/Common/VariablesTab/VariablesTab.spec.tsx +++ b/packages/bruno-api-docs/src/components/Playground/Content/Views/Common/VariablesTab/VariablesTab.spec.tsx @@ -95,4 +95,17 @@ describe('VariablesTab', () => { expect(query(root, 'td.col-value textarea').text).toBe('localhost'); expect(query(root, '.var-type-label').text.trim()).toBe('string'); }); + + it('shows a Description column with the authored variable description', () => { + const root = useRenderToDom( + + ); + const columns = root.querySelectorAll('thead th').map((th) => th.text.trim()); + expect(columns).toContain('Description'); + // The description cell is a single-line input (not multiline), so long text scrolls in place. + expect(query(root, 'td.col-description input.text-input').getAttribute('value')).toBe('The auth token'); + }); }); diff --git a/packages/bruno-api-docs/src/components/Playground/Content/Views/Common/VariablesTab/VariablesTab.tsx b/packages/bruno-api-docs/src/components/Playground/Content/Views/Common/VariablesTab/VariablesTab.tsx index acf4e71d..b7017354 100644 --- a/packages/bruno-api-docs/src/components/Playground/Content/Views/Common/VariablesTab/VariablesTab.tsx +++ b/packages/bruno-api-docs/src/components/Playground/Content/Views/Common/VariablesTab/VariablesTab.tsx @@ -6,7 +6,7 @@ import { unwrapVariableTyped } from '../../../../../../utils/variableResolution' import { toDataType } from '../../../../../../utils/variableDataType'; import { VARIABLE_NAME_REGEX } from '../../../../../../constants/regex'; import { variableTypeColumn } from '../VariableTypeControl/variableTypeColumn'; -import type { PostResponseVar } from '../../../../../../utils/request'; +import { getDescription, type PostResponseVar } from '../../../../../../utils/request'; import { StyledWrapper } from './StyledWrapper'; interface VariablesTabProps { @@ -49,7 +49,7 @@ export const VariablesTab: React.FC = ({ value, enabled: !variable.disabled, dataType: toDataType(dataType), - description: (variable as Variable).description, + description: getDescription(variable as Variable), originalValue: (variable as Variable).value }; }), @@ -64,7 +64,7 @@ export const VariablesTab: React.FC = ({ value: variable.expr || '', enabled: !variable.disabled, scope: variable.scope, - description: variable.description + description: getDescription(variable) })), [postResponseVars] ); @@ -91,6 +91,7 @@ export const VariablesTab: React.FC = ({ multilineValues={true} additionalColumns={dataTypeColumns} getRowError={getVariableError} + showDescription /> @@ -112,6 +113,7 @@ export const VariablesTab: React.FC = ({ showEnabled={true} inlineActions={true} getRowError={getVariableError} + showDescription /> )} diff --git a/packages/bruno-api-docs/src/components/Playground/Content/Views/FolderSettingsView/FolderSettingsView.tsx b/packages/bruno-api-docs/src/components/Playground/Content/Views/FolderSettingsView/FolderSettingsView.tsx index 81c772a6..f0d72153 100644 --- a/packages/bruno-api-docs/src/components/Playground/Content/Views/FolderSettingsView/FolderSettingsView.tsx +++ b/packages/bruno-api-docs/src/components/Playground/Content/Views/FolderSettingsView/FolderSettingsView.tsx @@ -5,6 +5,7 @@ import Tabs from '../../../../../ui/Tabs/Tabs'; import TitleLabel from '../../../../TitleLabel/TitleLabel'; import { type KeyValueRow } from '../../../../../components/KeyValueTable/KeyValueTable'; import { rowToVariable } from '../../../../../utils/variableDataType'; +import { keyValueRowToEntry } from '../../../../../utils/keyValueRow'; import HeadersTab from '../Common/HeadersTab/HeadersTab'; import VariablesTab from '../Common/VariablesTab/VariablesTab'; import AuthTab from '../Common/AuthTab/AuthTab'; @@ -31,101 +32,40 @@ const FolderSettings: React.FC = ({ folder, onFolderChange const dispatch = useAppDispatch(); const [activeTab, setActiveTab] = useState('overview'); - const handleHeadersChange = (headers: KeyValueRow[]) => { - const originals = folder.request?.headers ?? []; - const originalByName = new Map( - originals.filter((header) => header.name).map((header): [string, typeof header] => [header.name as string, header]) - ); - const updatedHeaders = headers.map((h) => { - const description = 'description' in h ? h.description : originalByName.get(h.name)?.description; - return { - name: h.name, - value: h.value, - disabled: !h.enabled, - ...(description !== undefined ? { description } : {}) - }; - }); - - const updatedFolder = { - ...folder, - request: { - ...folder.request, - headers: updatedHeaders - } - }; - - const uuid = (folder as any).uuid; + // Every edit rebuilds the folder and persists it the same way: push it into the collection (by the + // hydrated uuid) and bubble it up. Centralised so the handlers below stay one-liners. + const commitFolder = (updatedFolder: Folder) => { + const uuid = (updatedFolder as Folder & { uuid?: string }).uuid; if (uuid) { dispatch(updateFolderInCollection({ uuid, folder: updatedFolder })); } onFolderChange(updatedFolder); }; - const handleVariablesChange = (variables: KeyValueRow[]) => { - const updatedFolder = { - ...folder, - request: { - ...folder.request, - variables: variables.map(rowToVariable) - } - }; + const handleHeadersChange = (headers: KeyValueRow[]) => { + commitFolder({ ...folder, request: { ...folder.request, headers: headers.map(keyValueRowToEntry) } }); + }; - const uuid = (folder as any).uuid; - if (uuid) { - dispatch(updateFolderInCollection({ uuid, folder: updatedFolder })); - } - onFolderChange(updatedFolder); + const handleVariablesChange = (variables: KeyValueRow[]) => { + commitFolder({ ...folder, request: { ...folder.request, variables: variables.map(rowToVariable) } }); }; const handleScriptChange = (scriptType: 'preRequest' | 'postResponse' | 'tests', value: string) => { - const currentScriptsObj = scriptsArrayToObject(folder.request?.scripts); - const updatedScriptsObj = { ...currentScriptsObj, [scriptType]: value }; - - const updatedFolder = { + const updatedScriptsObj = { ...scriptsArrayToObject(folder.request?.scripts), [scriptType]: value }; + commitFolder({ ...folder, - request: { - ...folder.request, - scripts: scriptsObjectToArray(updatedScriptsObj) - } - } as Folder; - - const uuid = (folder as any).uuid; - if (uuid) { - dispatch(updateFolderInCollection({ uuid, folder: updatedFolder })); - } - onFolderChange(updatedFolder); + request: { ...folder.request, scripts: scriptsObjectToArray(updatedScriptsObj) } + } as Folder); }; const handleAuthChange = (authType: string) => { let auth: any = 'inherit'; - if (authType !== 'none' && authType !== 'inherit') { auth = { type: authType }; } else if (authType === 'none') { auth = undefined; } - - const updatedFolder = { - ...folder, - request: { - ...folder.request, - auth - } - }; - - const uuid = (folder as any).uuid; - if (uuid) { - dispatch(updateFolderInCollection({ uuid, folder: updatedFolder })); - } - onFolderChange(updatedFolder); - }; - - const handleFolderAuthChange = (updatedFolder: Folder) => { - const uuid = (updatedFolder as any).uuid; - if (uuid) { - dispatch(updateFolderInCollection({ uuid, folder: updatedFolder })); - } - onFolderChange(updatedFolder); + commitFolder({ ...folder, request: { ...folder.request, auth } }); }; const renderOverview = () => ( @@ -155,7 +95,7 @@ const FolderSettings: React.FC = ({ folder, onFolderChange = ({ item, onItemChange }) => { const [activeTab, setActiveTab] = useState('overview'); const handleParamsChange = (params: KeyValueRow[]) => { - const originals = getHttpParams(item) as Array<{ name?: string }>; - const originalByName = new Map( - originals.filter((param) => param.name).map((param): [string, typeof param] => [param.name as string, param]) - ); - const updatedParams = params.map(p => ({ - ...(originalByName.get(p?.name) ?? {}), - name: p?.name, - value: p?.value, - disabled: !p?.enabled, - type: p?.type - })); + const updatedParams = params.map((p) => ({ ...keyValueRowToEntry(p), type: p.type })); const url = setUrlQueryParams(getRequestUrl(item), updatedParams); onItemChange({ - ...item, - http: { - ...item.http, + ...item, + http: { + ...item.http, params: updatedParams, url } @@ -63,25 +54,13 @@ const RequestPane: React.FC = ({ item, onItemChange }) => { }; const handleHeadersChange = (headers: KeyValueRow[]) => { - const originals = getHttpHeaders(item); - const originalByName = new Map( - originals.filter((header) => header.name).map((header): [string, typeof header] => [header.name as string, header]) - ); - const updatedHeaders = headers.map(h => { - const description = 'description' in h ? h.description : originalByName.get(h.name)?.description; - return { - name: h.name, - value: h.value, - disabled: !h.enabled, - ...(description !== undefined ? { description } : {}) - }; - }); - onItemChange({ - ...item, - http: { - ...item.http, - headers: updatedHeaders - } + const updatedHeaders = headers.map(keyValueRowToEntry); + onItemChange({ + ...item, + http: { + ...item.http, + headers: updatedHeaders + } }); }; diff --git a/packages/bruno-api-docs/src/dev.tsx b/packages/bruno-api-docs/src/dev.tsx index 9c2268df..7463e197 100644 --- a/packages/bruno-api-docs/src/dev.tsx +++ b/packages/bruno-api-docs/src/dev.tsx @@ -10,6 +10,7 @@ import { createOpenCollectionStore } from './store/store'; import { sampleCollectionYaml } from './sampleCollection'; import { foldersFixtureCollection } from './e2eFixtures/foldersCollection'; import { variablesFixtureCollection } from './e2eFixtures/variablesCollection'; +import { descriptionsFixtureCollection } from './e2eFixtures/descriptionsCollection'; // `?fixture=folders` mounts a nested-folder collection for routing e2e tests; const fixture = new URLSearchParams(window.location.search).get('fixture'); @@ -18,7 +19,9 @@ const devCollection = ? foldersFixtureCollection : fixture === 'vars' ? variablesFixtureCollection - : sampleCollectionYaml; + : fixture === 'descriptions' + ? descriptionsFixtureCollection + : sampleCollectionYaml; // Ensure Prism is available globally for any code that might access it if (typeof window !== 'undefined') { diff --git a/packages/bruno-api-docs/src/e2eFixtures/descriptionsCollection.ts b/packages/bruno-api-docs/src/e2eFixtures/descriptionsCollection.ts new file mode 100644 index 00000000..56279e4e --- /dev/null +++ b/packages/bruno-api-docs/src/e2eFixtures/descriptionsCollection.ts @@ -0,0 +1,36 @@ +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. +export const descriptionsFixtureCollection = { + opencollection: '1.0.0', + info: { name: 'Descriptions Demo', version: '1.0.0' }, + items: [ + { + name: 'Described Request', + type: 'http', + seq: 1, + method: 'POST', + url: 'https://api.example.com/orders?status=open', + params: [ + { name: 'status', value: 'open', type: 'query', description: 'Filter orders by their fulfilment status' } + ], + headers: [ + { name: 'Authorization', value: 'Bearer token', description: 'Bearer access token for the API' }, + { + name: 'X-Trace', + value: 'abc-123', + description: { content: 'Correlation id echoed back on the response', type: 'text' } + } + ], + body: { + type: 'form-urlencoded', + 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' }] + } + } + ] +} as unknown as OpenCollection; diff --git a/packages/bruno-api-docs/src/utils/bulkKeyValue.spec.ts b/packages/bruno-api-docs/src/utils/bulkKeyValue.spec.ts index a21b379b..2bce0030 100644 --- a/packages/bruno-api-docs/src/utils/bulkKeyValue.spec.ts +++ b/packages/bruno-api-docs/src/utils/bulkKeyValue.spec.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; -import { parseBulkKeyValue, serializeBulkKeyValue } from './bulkKeyValue'; +import type { KeyValueRow } from '../components/KeyValueTable/KeyValueTable'; +import { parseBulkKeyValue, preserveDescriptions, serializeBulkKeyValue } from './bulkKeyValue'; describe('parseBulkKeyValue', () => { it('parses `name: value` lines and trims whitespace', () => { @@ -55,3 +56,51 @@ describe('round-trip', () => { expect(parseBulkKeyValue(serializeBulkKeyValue(rows))).toEqual(rows); }); }); + +describe('preserveDescriptions', () => { + const original: KeyValueRow[] = [ + { id: 'h-0', name: 'Accept', value: 'application/json', enabled: true, description: 'media type' }, + { id: 'h-1', name: 'Authorization', value: 'Bearer x', enabled: true, description: 'the token' } + ]; + + it('re-attaches a description to the matching name', () => { + const rows = preserveDescriptions(parseBulkKeyValue('Accept:application/json\nAuthorization:Bearer x'), original, 'h'); + expect(rows[0]).toMatchObject({ name: 'Accept', description: 'media type' }); + expect(rows[1]).toMatchObject({ name: 'Authorization', description: 'the token' }); + }); + + it('preserves descriptions when the rows are reordered', () => { + const rows = preserveDescriptions(parseBulkKeyValue('Authorization:Bearer x\nAccept:application/json'), original, 'h'); + expect(rows[0]).toMatchObject({ name: 'Authorization', description: 'the token' }); + expect(rows[1]).toMatchObject({ name: 'Accept', description: 'media type' }); + }); + + it('drops the description when a key is renamed', () => { + const rows = preserveDescriptions(parseBulkKeyValue('Accepts:application/json'), original, 'h'); + expect(rows[0]).not.toHaveProperty('description'); + }); + + it('gives a brand-new entry no description', () => { + const rows = preserveDescriptions(parseBulkKeyValue('X-New:1'), original, 'h'); + expect(rows[0]).not.toHaveProperty('description'); + }); + + it('matches duplicate names by closest index and consumes each original once', () => { + const dupes: KeyValueRow[] = [ + { id: 'd-0', name: 'X', value: 'a', enabled: true, description: 'first' }, + { id: 'd-1', name: 'X', value: 'b', enabled: true, description: 'second' } + ]; + const rows = preserveDescriptions(parseBulkKeyValue('X:a\nX:b\nX:c'), dupes, 'd'); + expect(rows[0]).toMatchObject({ description: 'first' }); + expect(rows[1]).toMatchObject({ description: 'second' }); + expect(rows[2]).not.toHaveProperty('description'); + }); + + it('does not attach an empty description and never mutates the snapshot', () => { + const withEmpty: KeyValueRow[] = [{ id: 'e-0', name: 'A', value: '1', enabled: true, description: '' }]; + const snapshot = JSON.stringify(withEmpty); + const rows = preserveDescriptions(parseBulkKeyValue('A:1'), withEmpty, 'e'); + expect(rows[0]).not.toHaveProperty('description'); + expect(JSON.stringify(withEmpty)).toBe(snapshot); + }); +}); diff --git a/packages/bruno-api-docs/src/utils/bulkKeyValue.ts b/packages/bruno-api-docs/src/utils/bulkKeyValue.ts index b5664295..1b0c50a2 100644 --- a/packages/bruno-api-docs/src/utils/bulkKeyValue.ts +++ b/packages/bruno-api-docs/src/utils/bulkKeyValue.ts @@ -31,3 +31,44 @@ export function parseBulkKeyValue(value: string): BulkKeyValueItem[] { export function serializeBulkKeyValue(items: BulkKeyValueItem[]): string { return items.map((item) => `${item.enabled ? '' : '//'}${item.name}:${item.value}`).join('\n'); } + +/** + * Re-attach descriptions to freshly-parsed bulk rows. The bulk text only carries name/value/enabled, + * so each row's description is matched back from `original` — a snapshot of the rows taken when bulk + * edit was entered — by exact name, then (among same-named rows) the closest by index, consuming each + * original once. A reordered row keeps its description, while + * a renamed key or an extra duplicate gets none. `original` is not mutated. + */ +export function preserveDescriptions( + parsed: BulkKeyValueItem[], + original: KeyValueRow[], + idPrefix: string +): KeyValueRow[] { + const candidatesByName = new Map(); + original.forEach((row, index) => { + const name = row.name || ''; + const candidates = candidatesByName.get(name) ?? []; + candidates.push({ index, description: row.description, matched: false }); + candidatesByName.set(name, candidates); + }); + + return parsed.map((item, index) => { + const row: KeyValueRow = { id: `${idPrefix}-${index}`, name: item.name, value: item.value, enabled: item.enabled }; + const candidates = candidatesByName.get(item.name || ''); + if (!candidates?.length) return row; + + let best: { index: number; description: unknown; matched: boolean } | null = null; + let bestDistance = Infinity; + for (const candidate of candidates) { + if (candidate.matched) continue; + const distance = Math.abs(candidate.index - index); + if (distance < bestDistance) { + bestDistance = distance; + best = candidate; + } + } + if (!best) return row; + best.matched = true; + return best.description ? { ...row, description: best.description } : row; + }); +} diff --git a/packages/bruno-api-docs/src/utils/keyValueRow.spec.ts b/packages/bruno-api-docs/src/utils/keyValueRow.spec.ts new file mode 100644 index 00000000..35aa925b --- /dev/null +++ b/packages/bruno-api-docs/src/utils/keyValueRow.spec.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from 'vitest'; +import { keyValueRowToEntry } from './keyValueRow'; + +describe('keyValueRowToEntry', () => { + it('inverts enabled to disabled and carries name/value', () => { + expect(keyValueRowToEntry({ id: '1', name: 'X-Trace', value: 'abc', enabled: true })).toEqual({ + name: 'X-Trace', + value: 'abc', + disabled: false + }); + expect(keyValueRowToEntry({ id: '1', name: 'X-Trace', value: 'abc', enabled: false })).toEqual({ + name: 'X-Trace', + value: 'abc', + disabled: true + }); + }); + + it('writes a non-empty description and omits an empty or absent one', () => { + expect( + keyValueRowToEntry({ id: '1', name: 'X', value: 'v', enabled: true, description: 'why it exists' }) + ).toEqual({ name: 'X', value: 'v', disabled: false, description: 'why it exists' }); + + expect( + keyValueRowToEntry({ id: '1', name: 'X', value: 'v', enabled: true, description: '' }) + ).not.toHaveProperty('description'); + + expect(keyValueRowToEntry({ id: '1', name: 'X', value: 'v', enabled: true })).not.toHaveProperty('description'); + }); +}); diff --git a/packages/bruno-api-docs/src/utils/keyValueRow.ts b/packages/bruno-api-docs/src/utils/keyValueRow.ts new file mode 100644 index 00000000..0d9b4806 --- /dev/null +++ b/packages/bruno-api-docs/src/utils/keyValueRow.ts @@ -0,0 +1,21 @@ +import type { KeyValueRow } from '../components/KeyValueTable/KeyValueTable'; + +export interface KeyValueEntry { + name: string; + value: string; + disabled: boolean; + description?: string; +} + +/** + * Map an editable KeyValueTable row back to its stored entry: `enabled` inverts to `disabled`, and a + * description is written only when it is a non-empty string — clearing the cell drops it, matching + * how the app stores a bare description string and omits blanks. Callers add any entry-specific + * fields (e.g. a query param's `type`) around the result. + */ +export const keyValueRowToEntry = (row: KeyValueRow): KeyValueEntry => ({ + name: row.name, + value: row.value, + disabled: !row.enabled, + ...(row.description ? { description: String(row.description) } : {}) +}); diff --git a/packages/bruno-api-docs/src/utils/request.ts b/packages/bruno-api-docs/src/utils/request.ts index 9887a1d5..c44f681d 100644 --- a/packages/bruno-api-docs/src/utils/request.ts +++ b/packages/bruno-api-docs/src/utils/request.ts @@ -470,7 +470,7 @@ export const postResponseVarsToActions = (rows: PostResponseRowInput[], actions: selector: { expression: row.value ?? '', method: 'jsonq' }, variable: { name: row.name ?? '', scope: row.scope || 'runtime' }, disabled: !row.enabled, - ...(row.description !== undefined ? { description: row.description } : {}) + ...(row.description ? { description: row.description } : {}) })); return [...others, ...postActions]; }; diff --git a/packages/bruno-api-docs/src/utils/variableDataType.spec.ts b/packages/bruno-api-docs/src/utils/variableDataType.spec.ts index 3cf1b574..b21dedef 100644 --- a/packages/bruno-api-docs/src/utils/variableDataType.spec.ts +++ b/packages/bruno-api-docs/src/utils/variableDataType.spec.ts @@ -102,6 +102,15 @@ describe('rowToVariable', () => { }); }); + it('omits an empty or absent description rather than writing an empty string', () => { + expect(rowToVariable({ name: 'a', value: 'v', enabled: true, description: '' })).toEqual({ + name: 'a', + value: 'v', + disabled: false + }); + expect(rowToVariable({ name: 'a', value: 'v', enabled: true })).not.toHaveProperty('description'); + }); + it('keeps an unsupported-type value verbatim when the display value is unchanged (a sibling-row edit must not flatten it)', () => { const original = { type: 'null', data: '' } as never; expect(rowToVariable({ name: 'x', value: '', enabled: true, dataType: 'string', originalValue: original })).toEqual({ diff --git a/packages/bruno-api-docs/src/utils/variableDataType.ts b/packages/bruno-api-docs/src/utils/variableDataType.ts index 18e19ac4..1f8ab0a0 100644 --- a/packages/bruno-api-docs/src/utils/variableDataType.ts +++ b/packages/bruno-api-docs/src/utils/variableDataType.ts @@ -87,7 +87,7 @@ export interface VariableRowInput { */ export const rowToVariable = (row: VariableRowInput): Variable => { const dataType = row.dataType && row.dataType !== 'string' ? (row.dataType as VariableValueType) : undefined; - const description = row.description !== undefined ? { description: row.description } : {}; + const description = row.description ? { description: row.description } : {}; const nextValue = dataType ? { type: dataType, data: row.value } : row.value; From b875c0d6dca6743fe610ab335e440f05201e9a58 Mon Sep 17 00:00:00 2001 From: Sachin Venugopalan Date: Mon, 27 Jul 2026 17:19:18 +0530 Subject: [PATCH 2/8] Added description columns to environments page --- .../environments/env-editor.component.ts | 11 +++- .../tests/playground/env-add-variable.spec.ts | 29 ++++++++++ .../playground-descriptions.spec.ts | 24 ++++++++ .../HighlightedInput/HighlightedInput.tsx | 2 +- .../HighlightedInput/StyledWrapper.ts | 8 +-- .../KeyValueTable/KeyValueTable.css | 5 +- .../KeyValueTable/KeyValueTable.tsx | 1 + .../Common/VariablesTab/VariablesTab.spec.tsx | 4 +- .../EnvVarCards/EnvVarCards.spec.tsx | 57 ++++++++++++++----- .../EnvVarCards/EnvVarCards.tsx | 22 ++++++- .../EnvVarCards/StyledWrapper.ts | 17 ++++-- .../EnvironmentsView/EnvironmentsView.tsx | 13 ++++- .../src/utils/environments.spec.ts | 25 ++++++++ .../bruno-api-docs/src/utils/environments.ts | 6 +- 14 files changed, 184 insertions(+), 40 deletions(-) diff --git a/packages/bruno-api-docs/e2e/components/environments/env-editor.component.ts b/packages/bruno-api-docs/e2e/components/environments/env-editor.component.ts index 25d4342b..10c2fbcf 100644 --- a/packages/bruno-api-docs/e2e/components/environments/env-editor.component.ts +++ b/packages/bruno-api-docs/e2e/components/environments/env-editor.component.ts @@ -3,9 +3,14 @@ import { BaseComponent } from '../base.component'; export class EnvEditorComponent extends BaseComponent { readonly cards = this.page.getByTestId('env-var-cards'); - readonly cardItems = this.cards.locator('.env-card'); - readonly nameInputs = this.cards.getByPlaceholder('Name'); - readonly valueInputs = this.cards.getByPlaceholder('Value'); + readonly cardItems = this.cards.getByTestId('env-var-cards-card'); + readonly nameInputs = this.cards.getByTestId('env-var-cards-name-input'); + readonly valueInputs = this.cards.getByTestId('env-var-cards-value-input'); + readonly descriptionInputs = this.cards.getByTestId('env-var-cards-description-input'); + + async selectEnvironment(name: string): Promise { + await this.page.getByTestId(`env-pill-${name}`).click(); + } /** The per-variable enable/disable checkbox, addressed by the variable name via its aria-label. */ enableToggle(name: string): Locator { diff --git a/packages/bruno-api-docs/e2e/tests/playground/env-add-variable.spec.ts b/packages/bruno-api-docs/e2e/tests/playground/env-add-variable.spec.ts index cdaea6bc..c7a4faa4 100644 --- a/packages/bruno-api-docs/e2e/tests/playground/env-add-variable.spec.ts +++ b/packages/bruno-api-docs/e2e/tests/playground/env-add-variable.spec.ts @@ -38,4 +38,33 @@ test.describe('Environment variables — adding rows (card view)', () => { await expect(toggle).toBeChecked(); await expect(card).not.toHaveClass(disabled); }); + + test('a variable description is editable below the value and persists across an environment switch', async ({ + playground, + envEditor + }) => { + await playground.open('inline'); + await playground.openEnvironments(); + + // Each variable card carries a Description field (placeholder "Description") under its value. + const description = envEditor.descriptionInputs.first(); + await description.fill('The API host'); + await expect(description).toHaveValue('The API host'); + + // Switching environments and back re-derives the rows from the store; the edit was committed. + await envEditor.selectEnvironment('Prod'); + await envEditor.selectEnvironment('Local'); + await expect(envEditor.descriptionInputs.first()).toHaveValue('The API host'); + }); +}); + +test.describe('Environment variables — descriptions (desktop table)', () => { + test.use({ viewport: { width: 1400, height: 900 } }); + + test('the non-inline table renders a Description column for variables', async ({ page, playground }) => { + // A non-inline dock uses the full KeyValueTable (not the compact cards). + await playground.open('bottom'); + await playground.openEnvironments(); + await expect(page.getByRole('columnheader', { name: 'Description' })).toBeVisible(); + }); }); 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 a07587a6..2293398f 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 @@ -45,4 +45,28 @@ test.describe('Playground — field descriptions', () => { await playground.selectTab('params'); await expect(playground.keyValueTable.descriptionInputs.first()).toHaveValue('Only open, unfulfilled orders'); }); + + test('a description is multiline — pressing Enter starts a new line', async ({ page, playground }) => { + await playground.selectTab('params'); + const description = playground.keyValueTable.descriptionInputs.first(); + await description.click(); + await description.press('ControlOrMeta+a'); + await page.keyboard.type('first line'); + await page.keyboard.press('Enter'); + await page.keyboard.type('second line'); + await expect(description).toHaveValue('first line\nsecond line'); + }); + + test('a description does not soft-wrap — long text stays on one line until Enter', async ({ page, playground }) => { + await playground.selectTab('params'); + const description = playground.keyValueTable.descriptionInputs.first(); + await description.click(); + await description.press('ControlOrMeta+a'); + const oneLineHeight = (await description.boundingBox())!.height; + + await page.keyboard.type( + 'this is a very long single line of description text that would wrap onto several lines if soft wrapping were enabled in this column' + ); + expect((await description.boundingBox())!.height).toBeLessThan(oneLineHeight + 4); + }); }); diff --git a/packages/bruno-api-docs/src/components/HighlightedInput/HighlightedInput.tsx b/packages/bruno-api-docs/src/components/HighlightedInput/HighlightedInput.tsx index 08282f13..df5c5265 100644 --- a/packages/bruno-api-docs/src/components/HighlightedInput/HighlightedInput.tsx +++ b/packages/bruno-api-docs/src/components/HighlightedInput/HighlightedInput.tsx @@ -359,7 +359,7 @@ export const HighlightedInput: React.FC = ({ - {multiline ?
{keyPlaceholder}{valueHeader ?? valuePlaceholder} + {keyPlaceholder} + {resizeHandle('key')} + + {valueHeader ?? valuePlaceholder} + {resizeHandle('value')} + {col.label} + {resizeHandle(col.key)} + Description + {resizeHandle('description')} +
+ updateField(index, 'description', v)} + isFound={isFound} + names={names} + variablesAutocomplete={false} + testId={`${testId}-description-input`} + /> + {!isLastEmptyRow && !disableDelete && deleteButton}