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. 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/components/playground/response-pane.component.ts b/packages/bruno-api-docs/e2e/components/playground/response-pane.component.ts index 0aab8505..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,6 +101,10 @@ export class ResponsePaneComponent extends BaseComponent { * switch to settle (the target becomes the selected tab) so a following switch never races the * layout mid-transition. */ + 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/env-table-values.spec.ts b/packages/bruno-api-docs/e2e/tests/playground/env-table-values.spec.ts new file mode 100644 index 00000000..23a98600 --- /dev/null +++ b/packages/bruno-api-docs/e2e/tests/playground/env-table-values.spec.ts @@ -0,0 +1,39 @@ +import { test, expect } from '../../playwright'; + +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); + + 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/keyvalue-table.spec.ts b/packages/bruno-api-docs/e2e/tests/playground/key-value-table.spec.ts similarity index 59% rename from packages/bruno-api-docs/e2e/tests/playground/keyvalue-table.spec.ts rename to packages/bruno-api-docs/e2e/tests/playground/key-value-table.spec.ts index 38dfcd51..4d82d1d1 100644 --- a/packages/bruno-api-docs/e2e/tests/playground/keyvalue-table.spec.ts +++ b/packages/bruno-api-docs/e2e/tests/playground/key-value-table.spec.ts @@ -1,6 +1,11 @@ import { test, expect } from '../../playwright'; -test.describe('KeyValueTable — tooltips & mobile scroll', () => { +const textStart = (el: HTMLElement) => + el.getBoundingClientRect().left + + parseFloat(getComputedStyle(el).borderLeftWidth) + + parseFloat(getComputedStyle(el).paddingLeft); + +test.describe('KeyValueTable: cells and layout', () => { test.beforeEach(async ({ page, playground }) => { await page.goto('/#/?pg=1&dock=bottom'); await playground.openSidebarItem('get users'); @@ -19,12 +24,45 @@ test.describe('KeyValueTable — tooltips & mobile scroll', () => { await expect(nameInput).toHaveAttribute('title', value); }); + 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 + 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); @@ -32,7 +70,6 @@ test.describe('KeyValueTable — tooltips & mobile scroll', () => { 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(); @@ -40,14 +77,20 @@ test.describe('KeyValueTable — tooltips & mobile scroll', () => { 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(); @@ -59,7 +102,6 @@ test.describe('KeyValueTable — tooltips & mobile scroll', () => { 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'); @@ -77,8 +119,6 @@ test.describe('KeyValueTable — tooltips & mobile scroll', () => { 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/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/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/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..4bff8649 --- /dev/null +++ b/packages/bruno-api-docs/e2e/tests/playground/tab-persistence.spec.ts @@ -0,0 +1,37 @@ +import { test, expect } from '../../playwright'; + +const USERS_BODY = '{"data":[{"id":1,"name":"Alice"}]}'; + +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/HighlightedInput/HighlightedInput.tsx b/packages/bruno-api-docs/src/components/HighlightedInput/HighlightedInput.tsx index 989ca1d0..80ad27f5 100644 --- a/packages/bruno-api-docs/src/components/HighlightedInput/HighlightedInput.tsx +++ b/packages/bruno-api-docs/src/components/HighlightedInput/HighlightedInput.tsx @@ -186,17 +186,32 @@ 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`; + 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]); + + 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) { 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..85b21f71 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,15 @@ describe('KeyValueTable — a row that has a value but no name', () => { expect(root.querySelectorAll('tbody input[type="checkbox"]').length).toBe(0); }); }); + +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..d363061c 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; @@ -221,9 +221,13 @@ const KeyValueTable: React.FC = ({ }; return ( -
+
- +
@@ -236,7 +240,7 @@ const KeyValueTable: React.FC = ({ - @@ -332,7 +336,7 @@ const KeyValueTable: React.FC = ({ )} {readOnlyKey ? ( - + {row.name} ) : ( @@ -399,7 +403,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..a4e82fa5 --- /dev/null +++ b/packages/bruno-api-docs/src/components/KeyValueTable/StyledWrapper.ts @@ -0,0 +1,271 @@ +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; + 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: 0.75rem; + } + + .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 .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; + } + + .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/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 4237a9ec..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,8 +27,8 @@ interface ResponsePaneProps { } const ResponsePane: React.FC = ({ response, isLoading, orientation, itemUuid }) => { - const [activeTab, setActiveTab] = useState('response'); - const [dismissedScriptErrorsRequestId, setDismissedScriptErrorsRequestId] = useState(); + const [activeTab, setActiveTab] = useSessionStorage('playground-response-tab', 'response'); + const [dismissedScriptErrorKeys, setDismissedScriptErrorKeys] = useState([]); const { actionsExpandedWidth, measureActions } = useResponseActions(); const { @@ -73,8 +74,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 +86,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 +100,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; } 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..ceff450e 100644 --- a/packages/bruno-api-docs/src/test-utils/dom.ts +++ b/packages/bruno-api-docs/src/test-utils/dom.ts @@ -9,6 +9,9 @@ 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}"]`); +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..e8d1699d --- /dev/null +++ b/packages/bruno-api-docs/src/utils/assertionRows.ts @@ -0,0 +1,46 @@ +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'; + +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 } : {}) + }; + });