From 3f712dcb652365a6dfdde99f15f68c56190c3f69 Mon Sep 17 00:00:00 2001 From: Adwait Aayush Date: Wed, 7 Oct 2026 16:31:35 +0530 Subject: [PATCH 1/3] feat(docs):adds read-only support for ws pages(3720) --- .changeset/feat-wspage.md | 5 + .../e2e/pages/unsupported-request.page.ts | 21 -- .../e2e/pages/websocket-request.page.ts | 28 ++ .../e2e/playwright/pages.fixture.ts | 10 +- .../tests/request/unsupported-request.spec.ts | 35 --- .../tests/request/websocket-request.spec.ts | 77 ++++++ .../e2e/tests/section-nav/section-nav.spec.ts | 6 +- .../bruno-api-docs/src/constants/index.ts | 3 +- .../bruno-api-docs/src/constants/request.ts | 6 + .../src/pages/Request/Request.tsx | 22 +- .../pages/WebsocketRequest/StyledWrapper.ts | 74 ++++++ .../WebsocketMessageCard/StyledWrapper.ts | 77 ++++++ .../WebsocketMessageCard.spec.tsx | 52 ++++ .../WebsocketMessageCard.tsx | 101 ++++++++ .../WebsocketMessages.spec.tsx | 38 +++ .../WebsocketMessages/WebsocketMessages.tsx | 62 +++++ .../WebsocketRequest.spec.tsx | 154 +++++++++++ .../WebsocketRequest/WebsocketRequest.tsx | 240 ++++++++++++++++++ .../bruno-api-docs/src/sampleCollection.ts | 41 ++- .../src/utils/schemaHelpers.spec.ts | 62 +++++ .../bruno-api-docs/src/utils/schemaHelpers.ts | 23 +- 21 files changed, 1051 insertions(+), 86 deletions(-) create mode 100644 .changeset/feat-wspage.md delete mode 100644 packages/bruno-api-docs/e2e/pages/unsupported-request.page.ts create mode 100644 packages/bruno-api-docs/e2e/pages/websocket-request.page.ts delete mode 100644 packages/bruno-api-docs/e2e/tests/request/unsupported-request.spec.ts create mode 100644 packages/bruno-api-docs/e2e/tests/request/websocket-request.spec.ts create mode 100644 packages/bruno-api-docs/src/pages/WebsocketRequest/StyledWrapper.ts create mode 100644 packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketMessages/WebsocketMessageCard/StyledWrapper.ts create mode 100644 packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketMessages/WebsocketMessageCard/WebsocketMessageCard.spec.tsx create mode 100644 packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketMessages/WebsocketMessageCard/WebsocketMessageCard.tsx create mode 100644 packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketMessages/WebsocketMessages.spec.tsx create mode 100644 packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketMessages/WebsocketMessages.tsx create mode 100644 packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketRequest.spec.tsx create mode 100644 packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketRequest.tsx diff --git a/.changeset/feat-wspage.md b/.changeset/feat-wspage.md new file mode 100644 index 00000000..862fee1a --- /dev/null +++ b/.changeset/feat-wspage.md @@ -0,0 +1,5 @@ +--- +"@usebruno/api-docs": minor +--- + +WebSocket requests now render as a read-only docs page instead of "Preview not available". The page shows the connection URL, the request docs, every message with its name and format (JSON and XML are pretty-printed), headers including inherited ones, and auth including inherited auth. diff --git a/packages/bruno-api-docs/e2e/pages/unsupported-request.page.ts b/packages/bruno-api-docs/e2e/pages/unsupported-request.page.ts deleted file mode 100644 index 6b74705a..00000000 --- a/packages/bruno-api-docs/e2e/pages/unsupported-request.page.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { BasePage } from './base.page'; -import { SidebarComponent } from '../components/sidebar.component'; -import { BreadcrumbComponent } from '../components/breadcrumb.component'; -import { RequestUrlBarComponent } from '../components/request/url-bar.component'; - -export class UnsupportedRequestPage extends BasePage { - readonly sidebar = new SidebarComponent(this.page); - readonly breadcrumb = new BreadcrumbComponent(this.page, 'unsupported-request-breadcrumb'); - readonly urlBar = new RequestUrlBarComponent(this.page); - - readonly root = this.page.getByTestId('unsupported-request'); - readonly title = this.page.getByTestId('unsupported-request-title'); - readonly message = this.page.getByTestId('unsupported-request-empty'); - readonly docs = this.root.getByTestId('overview-markdown-documentation'); - - async open(path: string[]): Promise { - await this.navigate('/'); - await this.sidebar.open(path); - await this.root.waitFor({ state: 'visible' }); - } -} diff --git a/packages/bruno-api-docs/e2e/pages/websocket-request.page.ts b/packages/bruno-api-docs/e2e/pages/websocket-request.page.ts new file mode 100644 index 00000000..34026fc1 --- /dev/null +++ b/packages/bruno-api-docs/e2e/pages/websocket-request.page.ts @@ -0,0 +1,28 @@ +import type { Locator } from '@playwright/test'; +import { BasePage } from './base.page'; +import { SidebarComponent } from '../components/sidebar.component'; +import { BreadcrumbComponent } from '../components/breadcrumb.component'; +import { RequestUrlBarComponent } from '../components/request/url-bar.component'; +import { GrpcMessagesComponent } from '../components/request/grpc-messages.component'; + +export class WebsocketRequestPage extends BasePage { + readonly sidebar = new SidebarComponent(this.page); + readonly breadcrumb = new BreadcrumbComponent(this.page, 'websocket-request-breadcrumb'); + readonly urlBar = new RequestUrlBarComponent(this.page); + readonly messages = new GrpcMessagesComponent(this.page, 'websocket-request-messages'); + + readonly root: Locator = this.page.getByTestId('websocket-request-page'); + readonly title: Locator = this.page.getByTestId('websocket-request-title'); + readonly description: Locator = this.page.getByTestId('websocket-request-description'); + + readonly messagesSection: Locator = this.page.getByTestId('websocket-request-section-messages'); + readonly headersSection: Locator = this.page.getByTestId('websocket-request-section-headers'); + readonly authSection: Locator = this.page.getByTestId('websocket-request-section-auth'); + readonly authInheritedBadge: Locator = this.page.getByTestId('websocket-request-auth-inherited'); + + async open(path: string[]): Promise { + await this.navigate('/'); + await this.sidebar.open(path); + await this.root.waitFor({ state: 'visible' }); + } +} diff --git a/packages/bruno-api-docs/e2e/playwright/pages.fixture.ts b/packages/bruno-api-docs/e2e/playwright/pages.fixture.ts index e35fdd2e..37f45326 100644 --- a/packages/bruno-api-docs/e2e/playwright/pages.fixture.ts +++ b/packages/bruno-api-docs/e2e/playwright/pages.fixture.ts @@ -5,8 +5,8 @@ import { RequestPage } from '../pages/request.page'; import { GraphqlRequestPage } from '../pages/graphql-request.page'; import { ScriptPage } from '../pages/script.page'; import { FolderPage } from '../pages/folder.page'; -import { UnsupportedRequestPage } from '../pages/unsupported-request.page'; import { GrpcRequestPage } from '../pages/grpc-request.page'; +import { WebsocketRequestPage } from '../pages/websocket-request.page'; import { SidebarComponent } from '../components/sidebar.component'; import { TooltipComponent } from '../components/tooltip.component'; import { PlaygroundComponent } from '../components/playground.component'; @@ -27,8 +27,8 @@ type Fixtures = { graphqlRequestPage: GraphqlRequestPage; scriptPage: ScriptPage; folderPage: FolderPage; - unsupportedRequestPage: UnsupportedRequestPage; grpcRequestPage: GrpcRequestPage; + websocketRequestPage: WebsocketRequestPage; sidebar: SidebarComponent; tooltip: TooltipComponent; playground: PlaygroundComponent; @@ -62,12 +62,12 @@ export const test = base.extend({ folderPage: async ({ page }, use) => { await use(new FolderPage(page)); }, - unsupportedRequestPage: async ({ page }, use) => { - await use(new UnsupportedRequestPage(page)); - }, grpcRequestPage: async ({ page }, use) => { await use(new GrpcRequestPage(page)); }, + websocketRequestPage: async ({ page }, use) => { + await use(new WebsocketRequestPage(page)); + }, sidebar: async ({ page }, use) => { await use(new SidebarComponent(page)); }, diff --git a/packages/bruno-api-docs/e2e/tests/request/unsupported-request.spec.ts b/packages/bruno-api-docs/e2e/tests/request/unsupported-request.spec.ts deleted file mode 100644 index 8a354cbf..00000000 --- a/packages/bruno-api-docs/e2e/tests/request/unsupported-request.spec.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { test, expect } from '../../playwright'; - -const UNSUPPORTED_REQUESTS = [ - { paths: ['Realtime', 'Live Updates'], name: 'Live Updates', typeLabel: 'Websocket', shortName: 'WS', url: '/ws/updates' } -]; - -test.describe('Request page — unsupported request types', () => { - for (const { paths, name, typeLabel, shortName, url } of UNSUPPORTED_REQUESTS) { - test(`shows a "preview not available" notice for a ${typeLabel} request`, async ({ unsupportedRequestPage }) => { - await unsupportedRequestPage.open(paths); - - await expect(unsupportedRequestPage.title).toHaveText(name); - await expect(unsupportedRequestPage.breadcrumb.segment('Realtime')).toBeVisible(); - await expect(unsupportedRequestPage.breadcrumb.current).toHaveText(name); - await expect(unsupportedRequestPage.message).toContainText('Preview not available'); - await expect(unsupportedRequestPage.message).toContainText(`${typeLabel} documentation`); - }); - - test(`renders the protocol method and url for a ${typeLabel} request`, async ({ unsupportedRequestPage }) => { - await unsupportedRequestPage.open(paths); - - await expect(unsupportedRequestPage.urlBar.root).toBeVisible(); - await expect(unsupportedRequestPage.urlBar.method).toHaveText(shortName); - await expect(unsupportedRequestPage.urlBar.url).toContainText(url); - }); - } - - test('renders request docs on the docs page when the request provides them', async ({ unsupportedRequestPage }) => { - // Only "Live Updates" carries a `docs` field in the sample collection. - await unsupportedRequestPage.open(['Realtime', 'Live Updates']); - - await expect(unsupportedRequestPage.docs).toBeVisible(); - await expect(unsupportedRequestPage.docs).toContainText('full-duplex communication channel'); - }); -}); diff --git a/packages/bruno-api-docs/e2e/tests/request/websocket-request.spec.ts b/packages/bruno-api-docs/e2e/tests/request/websocket-request.spec.ts new file mode 100644 index 00000000..f300ff22 --- /dev/null +++ b/packages/bruno-api-docs/e2e/tests/request/websocket-request.spec.ts @@ -0,0 +1,77 @@ +import { test, expect } from '../../playwright'; + +const LIVE_UPDATES = ['Realtime', 'Live Updates']; + +test.describe('Request page — WebSocket requests', () => { + test('renders the request identity without offering to run it', async ({ websocketRequestPage }) => { + await websocketRequestPage.open(LIVE_UPDATES); + + await expect(websocketRequestPage.title).toHaveText('Live Updates'); + await expect(websocketRequestPage.breadcrumb.segment('Realtime')).toBeVisible(); + await expect(websocketRequestPage.breadcrumb.current).toHaveText('Live Updates'); + await expect(websocketRequestPage.urlBar.method).toHaveText('WS'); + await expect(websocketRequestPage.urlBar.url).toContainText('/ws/updates'); + await expect(websocketRequestPage.urlBar.tryButton).toHaveCount(0); + }); + + test('renders the request docs', async ({ websocketRequestPage }) => { + await websocketRequestPage.open(LIVE_UPDATES); + + await expect(websocketRequestPage.description).toContainText('full-duplex communication channel'); + }); + + test('lists every message with its name and format, the first one open', async ({ websocketRequestPage }) => { + await websocketRequestPage.open(LIVE_UPDATES); + const { messages } = websocketRequestPage; + + await expect(websocketRequestPage.messagesSection).toContainText('5 messages'); + await expect(messages.card(0)).toContainText('Subscribe'); + await expect(messages.card(0)).toContainText('JSON'); + await expect(messages.card(1)).toContainText('Ping'); + await expect(messages.card(1)).toContainText('Text'); + + await expect(messages.toggle(0)).toHaveAttribute('aria-expanded', 'true'); + await expect(messages.toggle(1)).toHaveAttribute('aria-expanded', 'false'); + await expect(messages.code(1)).toHaveCount(0); + }); + + test('pretty-prints a JSON message stored on one line', async ({ websocketRequestPage }) => { + await websocketRequestPage.open(LIVE_UPDATES); + + // The sample stores Subscribe as a single line; each key lands on its own line once formatted. + await expect(websocketRequestPage.messages.code(0).locator('.code-line-numbers span')).toHaveCount(4); + }); + + test('opens a collapsed message on click', async ({ websocketRequestPage }) => { + await websocketRequestPage.open(LIVE_UPDATES); + const { messages } = websocketRequestPage; + + await messages.expand(1); + + await expect(messages.toggle(1)).toHaveAttribute('aria-expanded', 'true'); + await expect(messages.code(1)).toContainText('ping'); + }); + + test('shows the headers and the auth it inherits', async ({ websocketRequestPage }) => { + await websocketRequestPage.open(LIVE_UPDATES); + + await expect(websocketRequestPage.headersSection).toContainText('Accept'); + await expect(websocketRequestPage.headersSection).toContainText('X-Trace-Id'); + await expect(websocketRequestPage.authSection).toBeVisible(); + await expect(websocketRequestPage.authInheritedBadge).toBeVisible(); + }); + + test('shows only the first three messages until show more is used', async ({ websocketRequestPage }) => { + await websocketRequestPage.open(LIVE_UPDATES); + const { messages } = websocketRequestPage; + + await expect(messages.card(2)).toBeVisible(); + await expect(messages.card(3)).toHaveCount(0); + await expect(messages.showToggle).toHaveText('Show more'); + + await messages.showToggle.click(); + + await expect(messages.card(4)).toBeVisible(); + await expect(messages.showToggle).toHaveText('Show less'); + }); +}); diff --git a/packages/bruno-api-docs/e2e/tests/section-nav/section-nav.spec.ts b/packages/bruno-api-docs/e2e/tests/section-nav/section-nav.spec.ts index 72013453..c498ef16 100644 --- a/packages/bruno-api-docs/e2e/tests/section-nav/section-nav.spec.ts +++ b/packages/bruno-api-docs/e2e/tests/section-nav/section-nav.spec.ts @@ -170,11 +170,11 @@ test.describe('Section navigator (the "on this page" outline)', () => { await expect(page.getByTestId('section-nav-rail')).toBeHidden(); }); - test('appears on an unsupported-request page that has documentation headings', async ({ - unsupportedRequestPage, + test('appears on a websocket request page that has documentation headings', async ({ + websocketRequestPage, page }) => { - await unsupportedRequestPage.open(['Realtime', 'Live Updates']); + await websocketRequestPage.open(['Realtime', 'Live Updates']); await expect(page.getByTestId('section-nav-rail')).toBeVisible(); }); diff --git a/packages/bruno-api-docs/src/constants/index.ts b/packages/bruno-api-docs/src/constants/index.ts index b74f3850..6ee10c6e 100644 --- a/packages/bruno-api-docs/src/constants/index.ts +++ b/packages/bruno-api-docs/src/constants/index.ts @@ -16,7 +16,8 @@ export { REQUEST_TYPE_LABELS, BODY_LANGUAGE, BODY_CONTENT_TYPE, - GRPC_METHOD_TYPE_LABELS + GRPC_METHOD_TYPE_LABELS, + WEBSOCKET_MESSAGE_TYPE_LABELS } from './request'; export { RESPONSE_LANGUAGE, RESPONSE_CONTENT_TYPE, STATUS_CODE_PHRASES } from './response'; diff --git a/packages/bruno-api-docs/src/constants/request.ts b/packages/bruno-api-docs/src/constants/request.ts index 05d96089..53217392 100644 --- a/packages/bruno-api-docs/src/constants/request.ts +++ b/packages/bruno-api-docs/src/constants/request.ts @@ -44,6 +44,12 @@ export const GRPC_METHOD_TYPE_LABELS: Record = { 'bidi-streaming': 'Bidirectional Streaming' }; +export const WEBSOCKET_MESSAGE_TYPE_LABELS: Record = { + text: 'Text', + json: 'JSON', + xml: 'XML' +}; + export const PROTOCOL_BADGE_LABELS: Record = { GRAPHQL: 'GQL', GRPC: 'GRPC', diff --git a/packages/bruno-api-docs/src/pages/Request/Request.tsx b/packages/bruno-api-docs/src/pages/Request/Request.tsx index d2b79099..07426233 100644 --- a/packages/bruno-api-docs/src/pages/Request/Request.tsx +++ b/packages/bruno-api-docs/src/pages/Request/Request.tsx @@ -11,16 +11,14 @@ import { type RequestItem } from '@/utils/schemaHelpers'; import { getBodyView } from '@/utils/request'; -import { EyeOffIcon } from '@/assets/icons'; import { Section } from '../../components/Section/Section'; import { RequestBody } from '../../components/Request/RequestBody/RequestBody'; import { ContentTypeBadge } from '../../components/ContentTypeBadge/ContentTypeBadge'; import { Examples } from '../../components/Examples/Examples'; -import { PageWrapper } from '@/components/PageWrapper/PageWrapper'; -import { UnsupportedRequest } from '../../components/UnsupportedRequest/UnsupportedRequest'; import { useRequestPageData } from '@/hooks/useRequestPageData'; import { RequestPageLayout, NAV_GROUP, NAV_LEVEL } from '@/components/RequestPageLayout/RequestPageLayout'; import { GrpcRequest } from '@/pages/GrpcRequest/GrpcRequest'; +import { WebsocketRequest } from '@/pages/WebsocketRequest/WebsocketRequest'; interface RequestProps { item: RequestItem; @@ -113,18 +111,12 @@ export const Request: React.FC = ({ if (isWebSocketRequest(item)) { return ( - - , - heading: 'Preview not available', - subheadingSuffix: 'documentation isn\'t supported in this viewer.' - }} - /> - + ); } diff --git a/packages/bruno-api-docs/src/pages/WebsocketRequest/StyledWrapper.ts b/packages/bruno-api-docs/src/pages/WebsocketRequest/StyledWrapper.ts new file mode 100644 index 00000000..f8b9892d --- /dev/null +++ b/packages/bruno-api-docs/src/pages/WebsocketRequest/StyledWrapper.ts @@ -0,0 +1,74 @@ +import styled from '@emotion/styled'; + +export const StyledWrapper = styled.div` + max-width: 100rem; + margin: 0 auto; + color: var(--text-primary); + padding-top: 0.1rem; + padding-bottom: 0.1rem; + --sticky-url-clearance: 4.25rem; + + .websocket-request-title { + margin-top: 0.25rem; + } + + .websocket-request-url-sticky { + position: sticky; + top: 1rem; + z-index: 2; + margin-top: 0.75rem; + background: var(--content-bg); + box-shadow: 0 0 0 1rem var(--content-bg); + } + + .websocket-request-description { + margin-top: 1.5rem; + } + + .websocket-request-empty { + margin-top: 1.5rem; + } + + .websocket-request-messages { + margin-top: 1.25rem; + } + + .websocket-request-messages + .websocket-request-columns { + margin-top: 1.5rem; + } + + .websocket-request-columns { + display: grid; + grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr); + gap: 2.75rem; + align-items: start; + margin-top: 1.25rem; + } + + .websocket-request-col-left { + min-width: 0; + display: flex; + flex-direction: column; + gap: 1.5rem; + } + + .websocket-request-col-right { + min-width: 0; + display: flex; + flex-direction: column; + gap: 1.5rem; + position: sticky; + top: var(--sticky-url-clearance); + align-self: start; + } + + @container docs (max-width: 1024px) { + .websocket-request-columns { + grid-template-columns: 1fr; + gap: 1.75rem; + } + .websocket-request-col-right { + position: static; + } + } +`; diff --git a/packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketMessages/WebsocketMessageCard/StyledWrapper.ts b/packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketMessages/WebsocketMessageCard/StyledWrapper.ts new file mode 100644 index 00000000..5675f60e --- /dev/null +++ b/packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketMessages/WebsocketMessageCard/StyledWrapper.ts @@ -0,0 +1,77 @@ +import styled from '@emotion/styled'; + +export const StyledWrapper = styled.div` + border: 1px solid var(--border-color); + border-radius: var(--oc-radius); + overflow: hidden; + background: var(--oc-background-base); + + &:not(:first-of-type) { + margin-top: 0.75rem; + } + + .websocket-message-summary { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem; + } + + .websocket-message-toggle { + flex: 1 1 auto; + min-width: 0; + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0; + margin: 0; + background: none; + border: none; + cursor: pointer; + text-align: left; + color: inherit; + font: inherit; + } + .websocket-message-toggle:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 2px; + border-radius: 4px; + } + + .websocket-message-chevron { + flex: 0 0 auto; + color: var(--text-muted); + } + + .websocket-message-title { + font-family: var(--font-mono); + font-size: 0.75rem; + line-height: 1.125rem; + color: var(--text-primary); + } + + .websocket-message-detail { + display: grid; + grid-template-rows: 0fr; + transition: grid-template-rows 0.22s ease; + } + .websocket-message-detail.is-open { + grid-template-rows: 1fr; + } + .websocket-message-detail-clip { + overflow: hidden; + min-height: 0; + } + .websocket-message-detail-body { + border-top: 1px solid var(--border-color); + } + .websocket-message-detail-body .code-content-numbered { + min-height: 2.75rem; + } + + @media (prefers-reduced-motion: reduce) { + .websocket-message-detail { + transition: none; + } + } +`; diff --git a/packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketMessages/WebsocketMessageCard/WebsocketMessageCard.spec.tsx b/packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketMessages/WebsocketMessageCard/WebsocketMessageCard.spec.tsx new file mode 100644 index 00000000..ce2d0ef9 --- /dev/null +++ b/packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketMessages/WebsocketMessageCard/WebsocketMessageCard.spec.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { describe, it, expect } from 'vitest'; +import { useRenderToDom } from '@/hooks/useRenderToDom'; +import { getByTestId, queryByTestId } from '@/test-utils/dom'; +import { WebsocketMessageCard } from './WebsocketMessageCard'; + +const noop = () => {}; + +describe('WebsocketMessageCard', () => { + it('shows the title and format, with the body hidden while collapsed', () => { + const root = useRenderToDom( + + ); + + expect(getByTestId(root, 'websocket-message-card-title').text).toBe('Subscribe'); + expect(getByTestId(root, 'websocket-message-card-type').text).toBe('JSON'); + expect(getByTestId(root, 'websocket-message-card-toggle').getAttribute('aria-expanded')).toBe('false'); + expect(queryByTestId(root, 'websocket-message-card-code')).toBeNull(); + }); + + it('pretty-prints a JSON message and keeps its variables', () => { + const root = useRenderToDom( + + ); + + const code = getByTestId(root, 'websocket-message-card-code').text; + expect(code).toContain('"channel": "orders",\n "token": "{{token}}"'); + }); + + it('pretty-prints an XML message', () => { + const root = useRenderToDom( + + ); + + expect(getByTestId(root, 'websocket-message-card-code').text).toContain('\n 1\n'); + }); + + it('leaves a text message untouched', () => { + const root = useRenderToDom( + + ); + + expect(getByTestId(root, 'websocket-message-card-type').text).toBe('Text'); + expect(getByTestId(root, 'websocket-message-card-code').text).toContain(' ping '); + }); +}); diff --git a/packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketMessages/WebsocketMessageCard/WebsocketMessageCard.tsx b/packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketMessages/WebsocketMessageCard/WebsocketMessageCard.tsx new file mode 100644 index 00000000..a3530c8c --- /dev/null +++ b/packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketMessages/WebsocketMessageCard/WebsocketMessageCard.tsx @@ -0,0 +1,101 @@ +import React, { useId, useMemo, useState } from 'react'; +import type { WebSocketMessageType } from '@opencollection/types/requests/websocket'; +import cx from '@/utils/cx'; +import { prefersReducedMotion } from '@/utils/motion'; +import { prettifyJsonString, prettifyXmlString } from '@/utils/dataFormatter'; +import { BODY_LANGUAGE, WEBSOCKET_MESSAGE_TYPE_LABELS } from '@/constants'; +import { ChevronArrow } from '@/components/ChevronArrow/ChevronArrow'; +import { ContentTypeBadge } from '@/components/ContentTypeBadge/ContentTypeBadge'; +import { Code } from '@/components/Code/Code'; +import { StyledWrapper } from './StyledWrapper'; + +const prettify = (type: WebSocketMessageType, data: string): string => { + if (type === 'json') return prettifyJsonString(data); + if (type === 'xml') return prettifyXmlString(data); + return data; +}; + +interface WebsocketMessageCardProps { + title: string; + type: WebSocketMessageType; + data: string; + expanded: boolean; + onToggle: () => void; + testId?: string; +} + +export const WebsocketMessageCard: React.FC = ({ + title, + type, + data, + expanded, + onToggle, + testId = 'websocket-message-card' +}) => { + const [collapsing, setCollapsing] = useState(false); + const detailId = useId(); + const code = useMemo(() => prettify(type, data), [type, data]); + + const isOpen = expanded && !collapsing; + + const finishCollapse = () => { + setCollapsing(false); + onToggle(); + }; + + const handleToggle = () => { + if (collapsing) { + setCollapsing(false); + return; + } + if (!expanded || prefersReducedMotion()) { + onToggle(); + return; + } + setCollapsing(true); + }; + + const handleTransitionEnd = (event: React.TransitionEvent) => { + if (!collapsing) return; + if (event.propertyName !== 'grid-template-rows') return; + if (event.target !== event.currentTarget) return; + finishCollapse(); + }; + + return ( + +
+ + +
+ +
+
+ {expanded && ( +
+ +
+ )} +
+
+
+ ); +}; + +export default WebsocketMessageCard; diff --git a/packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketMessages/WebsocketMessages.spec.tsx b/packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketMessages/WebsocketMessages.spec.tsx new file mode 100644 index 00000000..9b7147fa --- /dev/null +++ b/packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketMessages/WebsocketMessages.spec.tsx @@ -0,0 +1,38 @@ +import { describe, it, expect } from 'vitest'; +import { useRenderToDom } from '@/hooks/useRenderToDom'; +import { getByTestId, queryByTestId } from '@/test-utils/dom'; +import type { WebSocketMessageEntry } from '@/utils/schemaHelpers'; +import { WebsocketMessages } from './WebsocketMessages'; + +const entries = (count: number): WebSocketMessageEntry[] => + Array.from({ length: count }, (_, index) => ({ + title: `Message ${index + 1}`, + type: 'json', + data: `{"payload":"body-${index + 1}"}` + })); + +describe('WebsocketMessages', () => { + it('renders nothing when there are no messages', () => { + const root = useRenderToDom(); + expect(queryByTestId(root, 'websocket-messages')).toBeNull(); + }); + + it('opens the first message and leaves the rest closed', () => { + const root = useRenderToDom(); + expect(getByTestId(root, 'websocket-messages-card-0-code').text).toContain('body-1'); + expect(queryByTestId(root, 'websocket-messages-card-1-code')).toBeNull(); + expect(queryByTestId(root, 'websocket-messages-card-2-code')).toBeNull(); + }); + + it('shows only the first three messages and offers to show more', () => { + const root = useRenderToDom(); + expect(getByTestId(root, 'websocket-messages-card-2-title').text).toBe('Message 3'); + expect(queryByTestId(root, 'websocket-messages-card-3')).toBeNull(); + expect(getByTestId(root, 'websocket-messages-show-toggle').text).toContain('Show more'); + }); + + it('offers no show-more control when everything already fits', () => { + const root = useRenderToDom(); + expect(queryByTestId(root, 'websocket-messages-show-toggle')).toBeNull(); + }); +}); diff --git a/packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketMessages/WebsocketMessages.tsx b/packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketMessages/WebsocketMessages.tsx new file mode 100644 index 00000000..3f2b1589 --- /dev/null +++ b/packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketMessages/WebsocketMessages.tsx @@ -0,0 +1,62 @@ +import React, { useState } from 'react'; +import type { WebSocketMessageEntry } from '@/utils/schemaHelpers'; +import { WebsocketMessageCard } from './WebsocketMessageCard/WebsocketMessageCard'; +import { ExpandToggle } from '@/components/ExpandToggle/ExpandToggle'; + +const COLLAPSED_COUNT = 3; + +interface WebsocketMessagesProps { + messages: WebSocketMessageEntry[]; + testId?: string; +} + +export const WebsocketMessages: React.FC = ({ messages, testId = 'websocket-messages' }) => { + const [expandedIndexes, setExpandedIndexes] = useState>(() => new Set([0])); + const [showAll, setShowAll] = useState(false); + + if (messages.length === 0) return null; + + const visible = showAll ? messages : messages.slice(0, COLLAPSED_COUNT); + const hasOverflow = messages.length > COLLAPSED_COUNT; + + const toggle = (index: number) => { + setExpandedIndexes((previous) => { + const next = new Set(previous); + if (next.has(index)) { + next.delete(index); + } else { + next.add(index); + } + return next; + }); + }; + + return ( +
+ {visible.map((entry, index) => ( + toggle(index)} + testId={`${testId}-card-${index}`} + /> + ))} + + {hasOverflow && ( + setShowAll((value) => !value)} + className="mt-3" + testId={`${testId}-show-toggle`} + /> + )} +
+ ); +}; + +export default WebsocketMessages; diff --git a/packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketRequest.spec.tsx b/packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketRequest.spec.tsx new file mode 100644 index 00000000..6d4b11b2 --- /dev/null +++ b/packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketRequest.spec.tsx @@ -0,0 +1,154 @@ +import React from 'react'; +import { describe, it, expect } from 'vitest'; +import { useRenderToDom } from '@/hooks/useRenderToDom'; +import { getByTestId, queryByTestId, query } from '@/test-utils/dom'; +import type { WebSocketRequest } from '@opencollection/types/requests/websocket'; +import { WebsocketRequest } from './WebsocketRequest'; + +const wsItem = (data: Record): WebSocketRequest => data as unknown as WebSocketRequest; + +describe('WebsocketRequest', () => { + it('renders the request name, the WS badge and the url', () => { + const root = useRenderToDom( + + ); + + expect(getByTestId(root, 'websocket-request-title').text).toBe('Live Updates'); + expect(getByTestId(root, 'request-method').text).toBe('WS'); + expect(getByTestId(root, 'request-url').text).toContain('wss://echo.websocket.org'); + }); + + it('renders a request that has no websocket block at all', () => { + const root = useRenderToDom( + + ); + + expect(getByTestId(root, 'websocket-request-title').text).toBe('Bare Socket'); + expect(getByTestId(root, 'request-url').text).toContain('{{host}}/ws'); + }); + + it('falls back to a placeholder name and never offers a Try button', () => { + const root = useRenderToDom(); + + expect(getByTestId(root, 'websocket-request-title').text).toBe('Untitled Request'); + expect(queryByTestId(root, 'request-try-button')).toBeNull(); + }); + + it('renders the docs markdown as html', () => { + const root = useRenderToDom( + + ); + + const markdown = query(getByTestId(root, 'websocket-request-description'), '.markdown-documentation'); + expect(query(markdown, 'h1').text).toBe('Live Updates'); + expect(query(markdown, 'p').text).toBe('Streams order updates.'); + }); + + it('falls back to the description when there are no docs', () => { + const root = useRenderToDom( + + ); + + expect(getByTestId(root, 'websocket-request-description').text).toContain('Streams order updates.'); + }); + + it('lists the messages in one section with a count', () => { + const root = useRenderToDom( + + ); + + expect(getByTestId(root, 'websocket-request-section-messages').text).toContain('2 messages'); + expect(getByTestId(root, 'websocket-request-messages-card-0-title').text).toBe('Subscribe'); + expect(getByTestId(root, 'websocket-request-messages-card-1-title').text).toBe('Ping'); + expect(queryByTestId(root, 'websocket-request-config-empty')).toBeNull(); + }); + + it('lists the request headers alongside the ones it inherits', () => { + const root = useRenderToDom( + + ); + + const section = getByTestId(root, 'websocket-request-section-headers'); + expect(section.text).toContain('Accept'); + expect(section.text).toContain('X-Tenant'); + expect(section.text).toContain('1 header inherited'); + }); + + it('shows concrete auth with no inherited badge and masks the secret', () => { + const root = useRenderToDom( + + ); + + const section = getByTestId(root, 'websocket-request-section-auth'); + expect(section.text).toContain('Basic Auth'); + expect(section.text).toContain('reader'); + expect(root.text).not.toContain('s3cret'); + expect(queryByTestId(root, 'websocket-request-auth-inherited')).toBeNull(); + }); + + it('resolves inherited auth up to the collection and says where it came from', () => { + const root = useRenderToDom( + + ); + + expect(getByTestId(root, 'websocket-request-auth-inherited').text).toContain('Inherited from collection'); + expect(getByTestId(root, 'websocket-request-section-auth').text).toContain('Bearer Token'); + }); + + it('shows the tags of a request that has no other configuration', () => { + const root = useRenderToDom( + + ); + + expect(getByTestId(root, 'websocket-request-section-tags').text).toContain('realtime'); + expect(queryByTestId(root, 'websocket-request-config-empty')).not.toBeNull(); + }); + + it('shows an empty state when nothing is configured', () => { + const root = useRenderToDom( + + ); + + expect(getByTestId(root, 'websocket-request-config-empty').text).toContain('No request configuration'); + expect(queryByTestId(root, 'websocket-request-section-messages')).toBeNull(); + expect(queryByTestId(root, 'websocket-request-section-headers')).toBeNull(); + expect(queryByTestId(root, 'websocket-request-section-auth')).toBeNull(); + }); +}); diff --git a/packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketRequest.tsx b/packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketRequest.tsx new file mode 100644 index 00000000..4b8dcbc7 --- /dev/null +++ b/packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketRequest.tsx @@ -0,0 +1,240 @@ +import React, { useMemo } from 'react'; +import type { OpenCollection } from '@opencollection/types'; +import type { Item } from '@opencollection/types/collection/item'; +import type { WebSocketRequest } from '@opencollection/types/requests/websocket'; +import type { Auth } from '@opencollection/types/common/auth'; +import { + getItemName, + getRequestUrl, + getItemDocs, + getItemDescription, + getRequestAuth, + getRequestHeaders, + getWebSocketMessages, + getItemTags, + getInheritedTags +} from '@/utils/schemaHelpers'; +import { + resolveInheritedAuth, + collectInheritedConfig, + enabledHeaderKeys, + headerRows, + inheritedCountLabel +} from '@/utils/request'; +import { inheritedHeaderRows } from '@/components/PropertyTable/inheritedRows'; +import { useMarkdownRenderer } from '@/hooks'; +import { buildBreadcrumbSegments } from '@/utils/common'; +import { AUTH_MODE_LABELS, PROTOCOL_BADGE_LABELS } from '@/constants'; +import { Section } from '@/components/Section/Section'; +import { ContentTypeBadge } from '@/components/ContentTypeBadge/ContentTypeBadge'; +import { InheritedAuthBadge } from '@/components/InheritedAuthBadge/InheritedAuthBadge'; +import { AuthDetails } from '@/components/AuthDetails/AuthDetails'; +import { PropertyTable } from '@/components/PropertyTable/PropertyTable'; +import { Tags } from '@/components/Tags/Tags'; +import { PageWrapper } from '@/components/PageWrapper/PageWrapper'; +import { Heading } from '@/components/Heading/Heading'; +import { ViewMore } from '@/components/ViewMore/ViewMore'; +import { Breadcrumb, type BreadcrumbSegment } from '@/ui/Breadcrumb/Breadcrumb'; +import { EmptyState } from '@/ui/EmptyState/EmptyState'; +import { RequestUrlBar } from '@/components/Request/RequestUrlBar/RequestUrlBar'; +import { MarkdownContent } from '@/components/MarkdownContent/MarkdownContent'; +import { FileIcon } from '@/assets/icons'; +import { WebsocketMessages } from './WebsocketMessages/WebsocketMessages'; +import { StyledWrapper } from './StyledWrapper'; + +const NO_ANCESTRY: Item[] = []; +const NO_OWN_VARS = new Set(); + +const NAV_GROUP = { configuration: 'Configuration' } as const; +const NAV_LEVEL = { section: 1, configItem: 2 } as const; + +interface WebsocketRequestProps { + item: WebSocketRequest; + collection?: OpenCollection | null; + ancestry?: Item[]; + onBreadcrumbClick?: (uuid: string) => void; + testId?: string; +} + +export const WebsocketRequest: React.FC = ({ + item, + ancestry = NO_ANCESTRY, + collection, + onBreadcrumbClick, + testId = 'websocket-request-page' +}) => { + const name = getItemName(item) || 'Untitled Request'; + const url = getRequestUrl(item); + const tags = useMemo(() => getItemTags(item), [item]); + const inheritedTags = useMemo(() => getInheritedTags(ancestry, tags), [ancestry, tags]); + const messages = useMemo(() => getWebSocketMessages(item), [item]); + + const headers = getRequestHeaders(item); + const inheritedHeaders = useMemo( + () => + collectInheritedConfig(collection, ancestry, { + headers: enabledHeaderKeys(headers), + preVars: NO_OWN_VARS, + postVars: NO_OWN_VARS + }).headers, + [collection, ancestry, headers] + ); + const headerTableRows = useMemo( + () => [...headerRows(headers), ...inheritedHeaderRows(inheritedHeaders)], + [headers, inheritedHeaders] + ); + const hasHeaders = headerTableRows.length > 0; + + const ownAuth = getRequestAuth(item) as Auth | undefined; + const resolvedAuth = useMemo(() => resolveInheritedAuth(collection, ancestry, item), [collection, ancestry, item]); + const effectiveAuth = ownAuth === 'inherit' ? resolvedAuth.auth : ownAuth; + const showAuth = ownAuth !== undefined; + const authBadge + = ownAuth === 'inherit' ? ( + resolvedAuth.source ? ( + + ) : ( + + ) + ) : undefined; + + const hasMessages = messages.length > 0; + const hasLeftColumn = hasHeaders || showAuth; + const hasRightColumn = tags.length > 0 || inheritedTags.length > 0; + const hasConfig = hasMessages || hasLeftColumn; + + const md = useMarkdownRenderer(); + + const descHtml = useMemo(() => { + const content = getItemDocs(item) || getItemDescription(item); + return content ? md.render(content) : ''; + }, [item, md]); + + const segments = useMemo( + () => buildBreadcrumbSegments(collection, ancestry), + [collection, ancestry] + ); + + const configEmptyState = ( + } + heading="No request configuration" + subheading="This request has no messages, headers, or authentication configured." + /> + ); + + return ( + + + + + {name} + +
+ +
+ + {descHtml && ( + + + + )} + + {hasMessages && ( +
} + > + +
+ )} + + {hasLeftColumn || hasRightColumn ? ( +
+
+ {!hasConfig && configEmptyState} + + {hasHeaders && ( +
0 ? ( + + ) : null + } + > + +
+ )} + + {showAuth && ( +
+ +
+ )} +
+ + {hasRightColumn && ( +
+
0 ? ( + + ) : undefined + } + > + +
+
+ )} +
+ ) : ( + !hasMessages && configEmptyState + )} +
+
+ ); +}; + +export default WebsocketRequest; diff --git a/packages/bruno-api-docs/src/sampleCollection.ts b/packages/bruno-api-docs/src/sampleCollection.ts index b9ff065e..d58da240 100644 --- a/packages/bruno-api-docs/src/sampleCollection.ts +++ b/packages/bruno-api-docs/src/sampleCollection.ts @@ -144,11 +144,42 @@ items: - name: "Realtime" type: "folder" items: - - name: "Live Updates" - description: "Streams live order updates over a WebSocket connection." - type: "websocket" - tags: ["realtime"] - url: "{{host}}/ws/updates" + - info: + name: "Live Updates" + description: "Streams live order updates over a WebSocket connection." + type: "websocket" + tags: ["realtime"] + websocket: + url: "{{host}}/ws/updates" + headers: + - name: "Accept" + value: "application/json" + - name: "X-Trace-Id" + value: "{{$randomUUID}}" + description: "Correlates the stream with server logs" + message: + - title: "Subscribe" + selected: true + message: + type: "json" + data: '{"action":"subscribe","channel":"orders"}' + - title: "Ping" + message: + type: "text" + data: "ping" + - title: "Order Snapshot" + message: + type: "xml" + data: 'orderspacked' + - title: "Unsubscribe" + message: + type: "json" + data: '{"action":"unsubscribe","channel":"orders"}' + - title: "Heartbeat" + message: + type: "text" + data: "heartbeat" + auth: inherit docs: | # Websockets WebSockets provide a persistent, full-duplex communication channel over a single, long-lived TCP connection. Unlike standard HTTP request-response cycles, they enable real-time, low-latency data transfer. diff --git a/packages/bruno-api-docs/src/utils/schemaHelpers.spec.ts b/packages/bruno-api-docs/src/utils/schemaHelpers.spec.ts index 9845d95c..a152c1c3 100644 --- a/packages/bruno-api-docs/src/utils/schemaHelpers.spec.ts +++ b/packages/bruno-api-docs/src/utils/schemaHelpers.spec.ts @@ -18,6 +18,7 @@ import { getGrpcMethodType, getGrpcMetadata, getGrpcProtoFileName, + getWebSocketMessages, getItemTags, getInheritedTags, type RequestItem @@ -284,6 +285,67 @@ describe('getGrpcMessages', () => { }); }); +describe('getWebSocketMessages', () => { + it('lists a single message as "Message 1" with its format', () => { + expect(getWebSocketMessages(item({ websocket: { message: { type: 'json', data: '{"a":1}' } } }))).toEqual([ + { title: 'Message 1', type: 'json', data: '{"a":1}' } + ]); + }); + + it('keeps the title, format and order of every message in a list', () => { + expect( + getWebSocketMessages( + item({ + websocket: { + message: [ + { title: 'Subscribe', message: { type: 'json', data: '{"channel":"orders"}' } }, + { title: 'Ping', selected: true, message: { type: 'text', data: 'ping' } } + ] + } + }) + ) + ).toEqual([ + { title: 'Subscribe', type: 'json', data: '{"channel":"orders"}' }, + { title: 'Ping', type: 'text', data: 'ping' } + ]); + }); + + it('numbers untitled messages by their position', () => { + const messages = getWebSocketMessages( + item({ + websocket: { + message: [ + { title: 'First', message: { type: 'text', data: 'a' } }, + { title: '', message: { type: 'xml', data: '' } } + ] + } + }) + ); + expect(messages[1].title).toBe('Message 2'); + }); + + it('drops blank messages', () => { + expect( + getWebSocketMessages( + item({ + websocket: { + message: [ + { title: 'Empty', message: { type: 'text', data: ' ' } }, + { title: 'Ping', message: { type: 'text', data: 'ping' } } + ] + } + }) + ) + ).toEqual([{ title: 'Ping', type: 'text', data: 'ping' }]); + }); + + it('returns no messages when the request defines none', () => { + expect(getWebSocketMessages(item({ websocket: { url: 'wss://x' } }))).toEqual([]); + expect(getWebSocketMessages(item({ type: 'websocket', url: 'wss://x' }))).toEqual([]); + expect(getWebSocketMessages(null)).toEqual([]); + }); +}); + describe('getGrpcProtoFileName', () => { it('returns just the file name from a stored path', () => { expect(getGrpcProtoFileName(item({ grpc: { protoFilePath: 'protos/hello.proto' } }))).toBe('hello.proto'); diff --git a/packages/bruno-api-docs/src/utils/schemaHelpers.ts b/packages/bruno-api-docs/src/utils/schemaHelpers.ts index ddc7d190..43ff6fb8 100644 --- a/packages/bruno-api-docs/src/utils/schemaHelpers.ts +++ b/packages/bruno-api-docs/src/utils/schemaHelpers.ts @@ -16,7 +16,7 @@ import type { Item as OpenCollectionItem, Folder, ScriptFile } from '@opencollec import type { HttpRequest, HttpRequestHeader, HttpRequestParam, HttpRequestExample, HttpRequestBody, HttpRequestBodyVariant } from '@opencollection/types/requests/http'; import type { GraphQLRequest } from '@opencollection/types/requests/graphql'; import type { GrpcRequest, GrpcRequestDetails, GrpcMetadata, GrpcMethodType } from '@opencollection/types/requests/grpc'; -import type { WebSocketRequest } from '@opencollection/types/requests/websocket'; +import type { WebSocketRequest, WebSocketMessageType } from '@opencollection/types/requests/websocket'; import type { Script, Scripts, ScriptType } from '@opencollection/types/common/scripts'; import { PROTOCOL_BADGE_LABELS } from '@/constants'; @@ -265,6 +265,27 @@ export const getGrpcMessages = (item: OpenCollectionItem | null | undefined): Gr return []; }; +export interface WebSocketMessageEntry { + title: string; + type: WebSocketMessageType; + data: string; +} + +export const getWebSocketMessages = (item: OpenCollectionItem | null | undefined): WebSocketMessageEntry[] => { + const payload = item && 'websocket' in item ? (item as WebSocketRequest).websocket?.message : undefined; + if (!payload) return []; + + const variants = Array.isArray(payload) ? payload : [{ title: '', message: payload }]; + + return variants + .map((variant, index) => ({ + title: variant.title || `Message ${index + 1}`, + type: variant.message?.type ?? 'text', + data: variant.message?.data ?? '' + })) + .filter((entry) => entry.data.trim().length > 0); +}; + export const getGrpcProtoFilePath = (item: OpenCollectionItem | null | undefined): string | undefined => getGrpcDetails(item).protoFilePath || undefined; From 09ff77b8d1ca7b3dc7e2277880b5b902bbff5ffa Mon Sep 17 00:00:00 2001 From: Adwait Aayush Date: Sun, 11 Oct 2026 18:31:54 +0530 Subject: [PATCH 2/3] refactor:ws page renders through RequestPageLayout --- .../e2e/pages/websocket-request.page.ts | 14 +- .../RequestPageLayout/RequestPageLayout.tsx | 129 +++++----- .../RequestPageLayout/StyledWrapper.ts | 4 + .../src/hooks/useRequestPageData.ts | 6 +- .../pages/WebsocketRequest/StyledWrapper.ts | 74 ------ .../WebsocketMessageCard/StyledWrapper.ts | 16 +- .../WebsocketMessageCard.spec.tsx | 11 + .../WebsocketMessageCard.tsx | 19 +- .../WebsocketRequest.spec.tsx | 63 +++-- .../WebsocketRequest/WebsocketRequest.tsx | 234 +++--------------- .../src/utils/schemaHelpers.spec.ts | 7 +- .../bruno-api-docs/src/utils/schemaHelpers.ts | 16 +- 12 files changed, 210 insertions(+), 383 deletions(-) delete mode 100644 packages/bruno-api-docs/src/pages/WebsocketRequest/StyledWrapper.ts diff --git a/packages/bruno-api-docs/e2e/pages/websocket-request.page.ts b/packages/bruno-api-docs/e2e/pages/websocket-request.page.ts index 34026fc1..682aa416 100644 --- a/packages/bruno-api-docs/e2e/pages/websocket-request.page.ts +++ b/packages/bruno-api-docs/e2e/pages/websocket-request.page.ts @@ -7,18 +7,18 @@ import { GrpcMessagesComponent } from '../components/request/grpc-messages.compo export class WebsocketRequestPage extends BasePage { readonly sidebar = new SidebarComponent(this.page); - readonly breadcrumb = new BreadcrumbComponent(this.page, 'websocket-request-breadcrumb'); + readonly breadcrumb = new BreadcrumbComponent(this.page, 'request-breadcrumb'); readonly urlBar = new RequestUrlBarComponent(this.page); readonly messages = new GrpcMessagesComponent(this.page, 'websocket-request-messages'); readonly root: Locator = this.page.getByTestId('websocket-request-page'); - readonly title: Locator = this.page.getByTestId('websocket-request-title'); - readonly description: Locator = this.page.getByTestId('websocket-request-description'); + readonly title: Locator = this.page.getByTestId('request-title'); + readonly description: Locator = this.page.getByTestId('request-description'); - readonly messagesSection: Locator = this.page.getByTestId('websocket-request-section-messages'); - readonly headersSection: Locator = this.page.getByTestId('websocket-request-section-headers'); - readonly authSection: Locator = this.page.getByTestId('websocket-request-section-auth'); - readonly authInheritedBadge: Locator = this.page.getByTestId('websocket-request-auth-inherited'); + readonly messagesSection: Locator = this.page.getByTestId('request-section-messages'); + readonly headersSection: Locator = this.page.getByTestId('request-section-headers'); + readonly authSection: Locator = this.page.getByTestId('request-section-auth'); + readonly authInheritedBadge: Locator = this.page.getByTestId('request-auth-inherited'); async open(path: string[]): Promise { await this.navigate('/'); diff --git a/packages/bruno-api-docs/src/components/RequestPageLayout/RequestPageLayout.tsx b/packages/bruno-api-docs/src/components/RequestPageLayout/RequestPageLayout.tsx index f3dc9d79..e7757671 100644 --- a/packages/bruno-api-docs/src/components/RequestPageLayout/RequestPageLayout.tsx +++ b/packages/bruno-api-docs/src/components/RequestPageLayout/RequestPageLayout.tsx @@ -32,11 +32,14 @@ interface RequestPageLayoutProps { hasConfigContent: boolean; emptyConfigSubheading: string; snippetBody?: HttpRequestBody | HttpRequestBodyVariant[]; + showCodeSnippet?: boolean; + showExecutionContext?: boolean; + beforeColumns?: React.ReactNode; afterColumns?: React.ReactNode; onTryClick?: () => void; onBreadcrumbClick?: (uuid: string) => void; testId?: string; - children: React.ReactNode; + children?: React.ReactNode; } export const RequestPageLayout: React.FC = ({ @@ -46,6 +49,9 @@ export const RequestPageLayout: React.FC = ({ hasConfigContent, emptyConfigSubheading, snippetBody, + showCodeSnippet = true, + showExecutionContext = true, + beforeColumns, afterColumns, onTryClick, onBreadcrumbClick, @@ -82,6 +88,7 @@ export const RequestPageLayout: React.FC = ({ scriptFlow, hasExecutionContext } = data; + const hasTags = tags.length > 0 || inheritedTags.length > 0; const authBadge = ownAuth === 'inherit' ? ( @@ -116,6 +123,8 @@ export const RequestPageLayout: React.FC = ({ )} + {beforeColumns &&
{beforeColumns}
} +
{hasLeftColumn ? ( @@ -151,7 +160,7 @@ export const RequestPageLayout: React.FC = ({ )} - ) : hasExecutionContext ? null : ( + ) : showExecutionContext && hasExecutionContext ? null : ( } @@ -161,65 +170,71 @@ export const RequestPageLayout: React.FC = ({ )}
-
-
- -
- {(tags.length > 0 || inheritedTags.length > 0) && ( -
0 ? ( - - ) : undefined - } - > - -
- )} -
+ {(showCodeSnippet || hasTags) && ( +
+ {showCodeSnippet && ( +
+ +
+ )} + {hasTags && ( +
0 ? ( + + ) : undefined + } + > + +
+ )} +
+ )}
{afterColumns} -
- {hasExecutionContext ? ( - - ) : ( - } - heading="No execution context" - subheading="This request has no scripts, variables, asserts, or tests configured." - /> - )} -
+ {showExecutionContext && ( +
+ {hasExecutionContext ? ( + + ) : ( + } + heading="No execution context" + subheading="This request has no scripts, variables, asserts, or tests configured." + /> + )} +
+ )} ); diff --git a/packages/bruno-api-docs/src/components/RequestPageLayout/StyledWrapper.ts b/packages/bruno-api-docs/src/components/RequestPageLayout/StyledWrapper.ts index 64f96ec3..7b2b3f1c 100644 --- a/packages/bruno-api-docs/src/components/RequestPageLayout/StyledWrapper.ts +++ b/packages/bruno-api-docs/src/components/RequestPageLayout/StyledWrapper.ts @@ -17,6 +17,10 @@ export const StyledWrapper = styled.div` box-shadow: 0 0 0 1rem var(--content-bg); } + .request-before-columns { + margin-top: 1.5rem; + } + .request-columns { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr); diff --git a/packages/bruno-api-docs/src/hooks/useRequestPageData.ts b/packages/bruno-api-docs/src/hooks/useRequestPageData.ts index aaf48c11..7bf36a81 100644 --- a/packages/bruno-api-docs/src/hooks/useRequestPageData.ts +++ b/packages/bruno-api-docs/src/hooks/useRequestPageData.ts @@ -14,6 +14,7 @@ import { getItemDescription, getItemTags, getInheritedTags, + isWebSocketRequest, type SupportedRequestItem } from '@/utils/schemaHelpers'; import { @@ -45,10 +46,11 @@ export const useRequestPageData = ( const url = getRequestUrl(item); const headers = getRequestHeaders(item); const params = getRequestParams(item); + const hasParamsModel = !isWebSocketRequest(item); const { path: pathParams, query: queryParams } = useMemo( - () => resolvePathAndQueryParams(params, url), - [params, url] + () => (hasParamsModel ? resolvePathAndQueryParams(params, url) : { path: [], query: [] }), + [hasParamsModel, params, url] ); const descHtml = useMemo(() => { diff --git a/packages/bruno-api-docs/src/pages/WebsocketRequest/StyledWrapper.ts b/packages/bruno-api-docs/src/pages/WebsocketRequest/StyledWrapper.ts deleted file mode 100644 index f8b9892d..00000000 --- a/packages/bruno-api-docs/src/pages/WebsocketRequest/StyledWrapper.ts +++ /dev/null @@ -1,74 +0,0 @@ -import styled from '@emotion/styled'; - -export const StyledWrapper = styled.div` - max-width: 100rem; - margin: 0 auto; - color: var(--text-primary); - padding-top: 0.1rem; - padding-bottom: 0.1rem; - --sticky-url-clearance: 4.25rem; - - .websocket-request-title { - margin-top: 0.25rem; - } - - .websocket-request-url-sticky { - position: sticky; - top: 1rem; - z-index: 2; - margin-top: 0.75rem; - background: var(--content-bg); - box-shadow: 0 0 0 1rem var(--content-bg); - } - - .websocket-request-description { - margin-top: 1.5rem; - } - - .websocket-request-empty { - margin-top: 1.5rem; - } - - .websocket-request-messages { - margin-top: 1.25rem; - } - - .websocket-request-messages + .websocket-request-columns { - margin-top: 1.5rem; - } - - .websocket-request-columns { - display: grid; - grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr); - gap: 2.75rem; - align-items: start; - margin-top: 1.25rem; - } - - .websocket-request-col-left { - min-width: 0; - display: flex; - flex-direction: column; - gap: 1.5rem; - } - - .websocket-request-col-right { - min-width: 0; - display: flex; - flex-direction: column; - gap: 1.5rem; - position: sticky; - top: var(--sticky-url-clearance); - align-self: start; - } - - @container docs (max-width: 1024px) { - .websocket-request-columns { - grid-template-columns: 1fr; - gap: 1.75rem; - } - .websocket-request-col-right { - position: static; - } - } -`; diff --git a/packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketMessages/WebsocketMessageCard/StyledWrapper.ts b/packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketMessages/WebsocketMessageCard/StyledWrapper.ts index 5675f60e..c0d4ab95 100644 --- a/packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketMessages/WebsocketMessageCard/StyledWrapper.ts +++ b/packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketMessages/WebsocketMessageCard/StyledWrapper.ts @@ -35,7 +35,7 @@ export const StyledWrapper = styled.div` .websocket-message-toggle:focus-visible { outline: 2px solid var(--primary-color); outline-offset: 2px; - border-radius: 4px; + border-radius: 0.25rem; } .websocket-message-chevron { @@ -65,6 +65,20 @@ export const StyledWrapper = styled.div` .websocket-message-detail-body { border-top: 1px solid var(--border-color); } + .websocket-message-detail-body .code-content-wrapper { + border: none; + border-radius: 0; + } + .websocket-message-empty { + margin: 0; + padding: 0.75rem; + font-family: var(--font-sans); + font-weight: 500; + font-style: italic; + font-size: 0.8125rem; + line-height: 1; + color: var(--text-secondary); + } .websocket-message-detail-body .code-content-numbered { min-height: 2.75rem; } diff --git a/packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketMessages/WebsocketMessageCard/WebsocketMessageCard.spec.tsx b/packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketMessages/WebsocketMessageCard/WebsocketMessageCard.spec.tsx index ce2d0ef9..caae5456 100644 --- a/packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketMessages/WebsocketMessageCard/WebsocketMessageCard.spec.tsx +++ b/packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketMessages/WebsocketMessageCard/WebsocketMessageCard.spec.tsx @@ -49,4 +49,15 @@ describe('WebsocketMessageCard', () => { expect(getByTestId(root, 'websocket-message-card-type').text).toBe('Text'); expect(getByTestId(root, 'websocket-message-card-code').text).toContain(' ping '); }); + + it('says the message is empty instead of showing a blank code block', () => { + const root = useRenderToDom( + + ); + + expect(getByTestId(root, 'websocket-message-card-title').text).toBe('Close'); + expect(getByTestId(root, 'websocket-message-card-type').text).toBe('Text'); + expect(getByTestId(root, 'websocket-message-card-empty').text).toBe('Empty message'); + expect(queryByTestId(root, 'websocket-message-card-code')).toBeNull(); + }); }); diff --git a/packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketMessages/WebsocketMessageCard/WebsocketMessageCard.tsx b/packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketMessages/WebsocketMessageCard/WebsocketMessageCard.tsx index a3530c8c..9deb65c9 100644 --- a/packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketMessages/WebsocketMessageCard/WebsocketMessageCard.tsx +++ b/packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketMessages/WebsocketMessageCard/WebsocketMessageCard.tsx @@ -35,6 +35,7 @@ export const WebsocketMessageCard: React.FC = ({ const [collapsing, setCollapsing] = useState(false); const detailId = useId(); const code = useMemo(() => prettify(type, data), [type, data]); + const isEmpty = data.trim().length === 0; const isOpen = expanded && !collapsing; @@ -83,13 +84,17 @@ export const WebsocketMessageCard: React.FC = ({
{expanded && (
- + {isEmpty ? ( +

Empty message

+ ) : ( + + )}
)}
diff --git a/packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketRequest.spec.tsx b/packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketRequest.spec.tsx index 6d4b11b2..a15df123 100644 --- a/packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketRequest.spec.tsx +++ b/packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketRequest.spec.tsx @@ -15,7 +15,7 @@ describe('WebsocketRequest', () => { /> ); - expect(getByTestId(root, 'websocket-request-title').text).toBe('Live Updates'); + expect(getByTestId(root, 'request-title').text).toBe('Live Updates'); expect(getByTestId(root, 'request-method').text).toBe('WS'); expect(getByTestId(root, 'request-url').text).toContain('wss://echo.websocket.org'); }); @@ -25,14 +25,14 @@ describe('WebsocketRequest', () => { ); - expect(getByTestId(root, 'websocket-request-title').text).toBe('Bare Socket'); + expect(getByTestId(root, 'request-title').text).toBe('Bare Socket'); expect(getByTestId(root, 'request-url').text).toContain('{{host}}/ws'); }); it('falls back to a placeholder name and never offers a Try button', () => { const root = useRenderToDom(); - expect(getByTestId(root, 'websocket-request-title').text).toBe('Untitled Request'); + expect(getByTestId(root, 'request-title').text).toBe('Untitled Request'); expect(queryByTestId(root, 'request-try-button')).toBeNull(); }); @@ -47,7 +47,7 @@ describe('WebsocketRequest', () => { /> ); - const markdown = query(getByTestId(root, 'websocket-request-description'), '.markdown-documentation'); + const markdown = query(getByTestId(root, 'request-description'), '.markdown-documentation'); expect(query(markdown, 'h1').text).toBe('Live Updates'); expect(query(markdown, 'p').text).toBe('Streams order updates.'); }); @@ -59,7 +59,7 @@ describe('WebsocketRequest', () => { /> ); - expect(getByTestId(root, 'websocket-request-description').text).toContain('Streams order updates.'); + expect(getByTestId(root, 'request-description').text).toContain('Streams order updates.'); }); it('lists the messages in one section with a count', () => { @@ -78,10 +78,10 @@ describe('WebsocketRequest', () => { /> ); - expect(getByTestId(root, 'websocket-request-section-messages').text).toContain('2 messages'); + expect(getByTestId(root, 'request-section-messages').text).toContain('2 messages'); expect(getByTestId(root, 'websocket-request-messages-card-0-title').text).toBe('Subscribe'); expect(getByTestId(root, 'websocket-request-messages-card-1-title').text).toBe('Ping'); - expect(queryByTestId(root, 'websocket-request-config-empty')).toBeNull(); + expect(queryByTestId(root, 'request-config-empty')).toBeNull(); }); it('lists the request headers alongside the ones it inherits', () => { @@ -95,7 +95,7 @@ describe('WebsocketRequest', () => { /> ); - const section = getByTestId(root, 'websocket-request-section-headers'); + const section = getByTestId(root, 'request-section-headers'); expect(section.text).toContain('Accept'); expect(section.text).toContain('X-Tenant'); expect(section.text).toContain('1 header inherited'); @@ -111,11 +111,11 @@ describe('WebsocketRequest', () => { /> ); - const section = getByTestId(root, 'websocket-request-section-auth'); + const section = getByTestId(root, 'request-section-auth'); expect(section.text).toContain('Basic Auth'); expect(section.text).toContain('reader'); expect(root.text).not.toContain('s3cret'); - expect(queryByTestId(root, 'websocket-request-auth-inherited')).toBeNull(); + expect(queryByTestId(root, 'request-auth-inherited')).toBeNull(); }); it('resolves inherited auth up to the collection and says where it came from', () => { @@ -126,8 +126,8 @@ describe('WebsocketRequest', () => { /> ); - expect(getByTestId(root, 'websocket-request-auth-inherited').text).toContain('Inherited from collection'); - expect(getByTestId(root, 'websocket-request-section-auth').text).toContain('Bearer Token'); + expect(getByTestId(root, 'request-auth-inherited').text).toContain('Inherited from collection'); + expect(getByTestId(root, 'request-section-auth').text).toContain('Bearer Token'); }); it('shows the tags of a request that has no other configuration', () => { @@ -137,8 +137,8 @@ describe('WebsocketRequest', () => { /> ); - expect(getByTestId(root, 'websocket-request-section-tags').text).toContain('realtime'); - expect(queryByTestId(root, 'websocket-request-config-empty')).not.toBeNull(); + expect(getByTestId(root, 'request-section-tags').text).toContain('realtime'); + expect(queryByTestId(root, 'request-config-empty')).not.toBeNull(); }); it('shows an empty state when nothing is configured', () => { @@ -146,9 +146,36 @@ describe('WebsocketRequest', () => { ); - expect(getByTestId(root, 'websocket-request-config-empty').text).toContain('No request configuration'); - expect(queryByTestId(root, 'websocket-request-section-messages')).toBeNull(); - expect(queryByTestId(root, 'websocket-request-section-headers')).toBeNull(); - expect(queryByTestId(root, 'websocket-request-section-auth')).toBeNull(); + expect(getByTestId(root, 'request-config-empty').text).toContain('No request configuration'); + expect(queryByTestId(root, 'request-section-messages')).toBeNull(); + expect(queryByTestId(root, 'request-section-headers')).toBeNull(); + expect(queryByTestId(root, 'request-section-auth')).toBeNull(); + }); + + it('has no code snippet, execution context, or params from the url query', () => { + const root = useRenderToDom( + + ); + + expect(queryByTestId(root, 'request-section-code-snippet')).toBeNull(); + expect(queryByTestId(root, 'request-section-execution-context')).toBeNull(); + expect(queryByTestId(root, 'request-section-params')).toBeNull(); + }); + + it('still shows the empty state when the request only has scripts', () => { + const root = useRenderToDom( + + ); + + expect(getByTestId(root, 'request-config-empty').text).toContain('No request configuration'); + expect(queryByTestId(root, 'request-section-execution-context')).toBeNull(); }); }); diff --git a/packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketRequest.tsx b/packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketRequest.tsx index 4b8dcbc7..d4febb99 100644 --- a/packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketRequest.tsx +++ b/packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketRequest.tsx @@ -2,51 +2,15 @@ import React, { useMemo } from 'react'; import type { OpenCollection } from '@opencollection/types'; import type { Item } from '@opencollection/types/collection/item'; import type { WebSocketRequest } from '@opencollection/types/requests/websocket'; -import type { Auth } from '@opencollection/types/common/auth'; -import { - getItemName, - getRequestUrl, - getItemDocs, - getItemDescription, - getRequestAuth, - getRequestHeaders, - getWebSocketMessages, - getItemTags, - getInheritedTags -} from '@/utils/schemaHelpers'; -import { - resolveInheritedAuth, - collectInheritedConfig, - enabledHeaderKeys, - headerRows, - inheritedCountLabel -} from '@/utils/request'; -import { inheritedHeaderRows } from '@/components/PropertyTable/inheritedRows'; -import { useMarkdownRenderer } from '@/hooks'; -import { buildBreadcrumbSegments } from '@/utils/common'; -import { AUTH_MODE_LABELS, PROTOCOL_BADGE_LABELS } from '@/constants'; +import { getWebSocketMessages } from '@/utils/schemaHelpers'; +import { PROTOCOL_BADGE_LABELS } from '@/constants'; import { Section } from '@/components/Section/Section'; import { ContentTypeBadge } from '@/components/ContentTypeBadge/ContentTypeBadge'; -import { InheritedAuthBadge } from '@/components/InheritedAuthBadge/InheritedAuthBadge'; -import { AuthDetails } from '@/components/AuthDetails/AuthDetails'; -import { PropertyTable } from '@/components/PropertyTable/PropertyTable'; -import { Tags } from '@/components/Tags/Tags'; -import { PageWrapper } from '@/components/PageWrapper/PageWrapper'; -import { Heading } from '@/components/Heading/Heading'; -import { ViewMore } from '@/components/ViewMore/ViewMore'; -import { Breadcrumb, type BreadcrumbSegment } from '@/ui/Breadcrumb/Breadcrumb'; -import { EmptyState } from '@/ui/EmptyState/EmptyState'; -import { RequestUrlBar } from '@/components/Request/RequestUrlBar/RequestUrlBar'; -import { MarkdownContent } from '@/components/MarkdownContent/MarkdownContent'; -import { FileIcon } from '@/assets/icons'; +import { useRequestPageData } from '@/hooks/useRequestPageData'; +import { RequestPageLayout, NAV_GROUP, NAV_LEVEL } from '@/components/RequestPageLayout/RequestPageLayout'; import { WebsocketMessages } from './WebsocketMessages/WebsocketMessages'; -import { StyledWrapper } from './StyledWrapper'; const NO_ANCESTRY: Item[] = []; -const NO_OWN_VARS = new Set(); - -const NAV_GROUP = { configuration: 'Configuration' } as const; -const NAV_LEVEL = { section: 1, configItem: 2 } as const; interface WebsocketRequestProps { item: WebSocketRequest; @@ -63,177 +27,35 @@ export const WebsocketRequest: React.FC = ({ onBreadcrumbClick, testId = 'websocket-request-page' }) => { - const name = getItemName(item) || 'Untitled Request'; - const url = getRequestUrl(item); - const tags = useMemo(() => getItemTags(item), [item]); - const inheritedTags = useMemo(() => getInheritedTags(ancestry, tags), [ancestry, tags]); + const data = useRequestPageData(collection, ancestry, item); const messages = useMemo(() => getWebSocketMessages(item), [item]); - - const headers = getRequestHeaders(item); - const inheritedHeaders = useMemo( - () => - collectInheritedConfig(collection, ancestry, { - headers: enabledHeaderKeys(headers), - preVars: NO_OWN_VARS, - postVars: NO_OWN_VARS - }).headers, - [collection, ancestry, headers] - ); - const headerTableRows = useMemo( - () => [...headerRows(headers), ...inheritedHeaderRows(inheritedHeaders)], - [headers, inheritedHeaders] - ); - const hasHeaders = headerTableRows.length > 0; - - const ownAuth = getRequestAuth(item) as Auth | undefined; - const resolvedAuth = useMemo(() => resolveInheritedAuth(collection, ancestry, item), [collection, ancestry, item]); - const effectiveAuth = ownAuth === 'inherit' ? resolvedAuth.auth : ownAuth; - const showAuth = ownAuth !== undefined; - const authBadge - = ownAuth === 'inherit' ? ( - resolvedAuth.source ? ( - - ) : ( - - ) - ) : undefined; - const hasMessages = messages.length > 0; - const hasLeftColumn = hasHeaders || showAuth; - const hasRightColumn = tags.length > 0 || inheritedTags.length > 0; - const hasConfig = hasMessages || hasLeftColumn; - const md = useMarkdownRenderer(); - - const descHtml = useMemo(() => { - const content = getItemDocs(item) || getItemDescription(item); - return content ? md.render(content) : ''; - }, [item, md]); - - const segments = useMemo( - () => buildBreadcrumbSegments(collection, ancestry), - [collection, ancestry] - ); - - const configEmptyState = ( - } - heading="No request configuration" - subheading="This request has no messages, headers, or authentication configured." - /> - ); + const messagesSection = hasMessages ? ( +
} + > + +
+ ) : null; return ( - - - - - {name} - -
- -
- - {descHtml && ( - - - - )} - - {hasMessages && ( -
} - > - -
- )} - - {hasLeftColumn || hasRightColumn ? ( -
-
- {!hasConfig && configEmptyState} - - {hasHeaders && ( -
0 ? ( - - ) : null - } - > - -
- )} - - {showAuth && ( -
- -
- )} -
- - {hasRightColumn && ( -
-
0 ? ( - - ) : undefined - } - > - -
-
- )} -
- ) : ( - !hasMessages && configEmptyState - )} -
-
+ ); }; diff --git a/packages/bruno-api-docs/src/utils/schemaHelpers.spec.ts b/packages/bruno-api-docs/src/utils/schemaHelpers.spec.ts index a152c1c3..8dbfeacf 100644 --- a/packages/bruno-api-docs/src/utils/schemaHelpers.spec.ts +++ b/packages/bruno-api-docs/src/utils/schemaHelpers.spec.ts @@ -324,7 +324,7 @@ describe('getWebSocketMessages', () => { expect(messages[1].title).toBe('Message 2'); }); - it('drops blank messages', () => { + it('keeps a message whose payload is blank', () => { expect( getWebSocketMessages( item({ @@ -336,7 +336,10 @@ describe('getWebSocketMessages', () => { } }) ) - ).toEqual([{ title: 'Ping', type: 'text', data: 'ping' }]); + ).toEqual([ + { title: 'Empty', type: 'text', data: ' ' }, + { title: 'Ping', type: 'text', data: 'ping' } + ]); }); it('returns no messages when the request defines none', () => { diff --git a/packages/bruno-api-docs/src/utils/schemaHelpers.ts b/packages/bruno-api-docs/src/utils/schemaHelpers.ts index 43ff6fb8..f49df3e7 100644 --- a/packages/bruno-api-docs/src/utils/schemaHelpers.ts +++ b/packages/bruno-api-docs/src/utils/schemaHelpers.ts @@ -20,9 +20,9 @@ import type { WebSocketRequest, WebSocketMessageType } from '@opencollection/typ import type { Script, Scripts, ScriptType } from '@opencollection/types/common/scripts'; import { PROTOCOL_BADGE_LABELS } from '@/constants'; -export type SupportedRequestItem = HttpRequest | GraphQLRequest | GrpcRequest; +export type SupportedRequestItem = HttpRequest | GraphQLRequest | GrpcRequest | WebSocketRequest; -export type RequestItem = SupportedRequestItem | WebSocketRequest; +export type RequestItem = SupportedRequestItem; /** A request body as stored on an item: a single body, a list of body variants, or none. */ export type RequestBody = HttpRequestBody | HttpRequestBodyVariant[] | undefined; @@ -277,13 +277,11 @@ export const getWebSocketMessages = (item: OpenCollectionItem | null | undefined const variants = Array.isArray(payload) ? payload : [{ title: '', message: payload }]; - return variants - .map((variant, index) => ({ - title: variant.title || `Message ${index + 1}`, - type: variant.message?.type ?? 'text', - data: variant.message?.data ?? '' - })) - .filter((entry) => entry.data.trim().length > 0); + return variants.map((variant, index) => ({ + title: variant.title || `Message ${index + 1}`, + type: variant.message?.type ?? 'text', + data: variant.message?.data ?? '' + })); }; export const getGrpcProtoFilePath = (item: OpenCollectionItem | null | undefined): string | undefined => From 308e868ebd030687da2eccf566feb29a9447ffee Mon Sep 17 00:00:00 2001 From: Adwait Aayush Date: Sun, 11 Oct 2026 19:19:05 +0530 Subject: [PATCH 3/3] fix(docs): ws empty state goes full width, no empty grid under messages --- .../RequestPageLayout/RequestPageLayout.tsx | 140 ++++++++++-------- .../RequestPageLayout/StyledWrapper.ts | 4 + .../WebsocketMessageCard.tsx | 2 +- .../WebsocketRequest.spec.tsx | 20 ++- .../WebsocketRequest/WebsocketRequest.tsx | 2 +- 5 files changed, 99 insertions(+), 69 deletions(-) diff --git a/packages/bruno-api-docs/src/components/RequestPageLayout/RequestPageLayout.tsx b/packages/bruno-api-docs/src/components/RequestPageLayout/RequestPageLayout.tsx index e7757671..60f9bc79 100644 --- a/packages/bruno-api-docs/src/components/RequestPageLayout/RequestPageLayout.tsx +++ b/packages/bruno-api-docs/src/components/RequestPageLayout/RequestPageLayout.tsx @@ -100,6 +100,17 @@ export const RequestPageLayout: React.FC = ({ ) : null; const hasLeftColumn = showAuth || hasParams || hasConfigContent || hasHeaders || hasInheritedHeaders; + const hasRightColumn = showCodeSnippet || hasTags; + const showConfigEmptyState = !hasLeftColumn && !beforeColumns && !(showExecutionContext && hasExecutionContext); + + const configEmptyState = ( + } + heading="No request configuration" + subheading={emptyConfigSubheading} + /> + ); return ( @@ -125,81 +136,80 @@ export const RequestPageLayout: React.FC = ({ {beforeColumns &&
{beforeColumns}
} -
-
- {hasLeftColumn ? ( - <> - {hasParams && ( -
- + {hasLeftColumn || hasRightColumn ? ( +
+
+ {hasLeftColumn ? ( + <> + {hasParams && ( +
+ +
+ )} + + {children} + + {(hasHeaders || hasInheritedHeaders) && ( +
+ ) : null + } + > + +
+ )} + + {showAuth && ( +
+ +
+ )} + + ) : ( + showConfigEmptyState && configEmptyState + )} +
+ + {hasRightColumn && ( +
+ {showCodeSnippet && ( +
+
)} - - {children} - - {(hasHeaders || hasInheritedHeaders) && ( + {hasTags && (
- ) : null + inheritedTags.length > 0 ? ( + + ) : undefined } > - -
- )} - - {showAuth && ( -
- +
)} - - ) : showExecutionContext && hasExecutionContext ? null : ( - } - heading="No request configuration" - subheading={emptyConfigSubheading} - /> +
)}
- - {(showCodeSnippet || hasTags) && ( -
- {showCodeSnippet && ( -
- -
- )} - {hasTags && ( -
0 ? ( - - ) : undefined - } - > - -
- )} -
- )} -
+ ) : ( + showConfigEmptyState &&
{configEmptyState}
+ )} {afterColumns} diff --git a/packages/bruno-api-docs/src/components/RequestPageLayout/StyledWrapper.ts b/packages/bruno-api-docs/src/components/RequestPageLayout/StyledWrapper.ts index 7b2b3f1c..478c5223 100644 --- a/packages/bruno-api-docs/src/components/RequestPageLayout/StyledWrapper.ts +++ b/packages/bruno-api-docs/src/components/RequestPageLayout/StyledWrapper.ts @@ -21,6 +21,10 @@ export const StyledWrapper = styled.div` margin-top: 1.5rem; } + .request-empty-fullwidth { + margin-top: 1.5rem; + } + .request-columns { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr); diff --git a/packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketMessages/WebsocketMessageCard/WebsocketMessageCard.tsx b/packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketMessages/WebsocketMessageCard/WebsocketMessageCard.tsx index 9deb65c9..29e18561 100644 --- a/packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketMessages/WebsocketMessageCard/WebsocketMessageCard.tsx +++ b/packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketMessages/WebsocketMessageCard/WebsocketMessageCard.tsx @@ -34,8 +34,8 @@ export const WebsocketMessageCard: React.FC = ({ }) => { const [collapsing, setCollapsing] = useState(false); const detailId = useId(); - const code = useMemo(() => prettify(type, data), [type, data]); const isEmpty = data.trim().length === 0; + const code = useMemo(() => (isEmpty ? '' : prettify(type, data)), [isEmpty, type, data]); const isOpen = expanded && !collapsing; diff --git a/packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketRequest.spec.tsx b/packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketRequest.spec.tsx index a15df123..be21d62a 100644 --- a/packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketRequest.spec.tsx +++ b/packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketRequest.spec.tsx @@ -138,7 +138,7 @@ describe('WebsocketRequest', () => { ); expect(getByTestId(root, 'request-section-tags').text).toContain('realtime'); - expect(queryByTestId(root, 'request-config-empty')).not.toBeNull(); + expect(query(root, '.request-col-left').querySelector('[data-testid="request-config-empty"]')).not.toBeNull(); }); it('shows an empty state when nothing is configured', () => { @@ -146,7 +146,8 @@ describe('WebsocketRequest', () => { ); - expect(getByTestId(root, 'request-config-empty').text).toContain('No request configuration'); + expect(query(root, '.request-empty-fullwidth').text).toContain('No request configuration'); + expect(root.querySelector('.request-columns')).toBeNull(); expect(queryByTestId(root, 'request-section-messages')).toBeNull(); expect(queryByTestId(root, 'request-section-headers')).toBeNull(); expect(queryByTestId(root, 'request-section-auth')).toBeNull(); @@ -178,4 +179,19 @@ describe('WebsocketRequest', () => { expect(getByTestId(root, 'request-config-empty').text).toContain('No request configuration'); expect(queryByTestId(root, 'request-section-execution-context')).toBeNull(); }); + + it('draws no empty column grid under the messages when nothing else is configured', () => { + const root = useRenderToDom( + + ); + + expect(getByTestId(root, 'request-section-messages').text).toContain('1 message'); + expect(root.querySelector('.request-columns')).toBeNull(); + expect(queryByTestId(root, 'request-config-empty')).toBeNull(); + }); }); diff --git a/packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketRequest.tsx b/packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketRequest.tsx index d4febb99..b98c954e 100644 --- a/packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketRequest.tsx +++ b/packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketRequest.tsx @@ -48,7 +48,7 @@ export const WebsocketRequest: React.FC = ({