From 0402b8d7d8ab1ba2427dfe1105b2cb071bab2994 Mon Sep 17 00:00:00 2001 From: Sundram Gupta Date: Tue, 25 Aug 2026 18:26:22 +0530 Subject: [PATCH 1/3] feat(docs): tag filter in search and tag display on request pages (BRU-4028) Replaces the search palette's folder dropdown with a multi-select tag filter (a record must carry every selected tag) and shows an item's tags on the request, folder and overview pages. Tags are read from both the info block and legacy root-level fields, and requests inherit their ancestor folders' tags for filtering. Also fixes descender clipping in shared dropdown options and adds the missing vertical gap between stacked right-column sections. --- .../e2e/components/search/search.component.ts | 11 +-- .../e2e/tests/request/request-details.spec.ts | 16 +++++ .../e2e/tests/search/search.spec.ts | 61 +++++++++++++--- .../src/assets/icons/TagIcon.tsx | 9 +++ .../bruno-api-docs/src/assets/icons/index.ts | 1 + .../RequestPageLayout.spec.tsx | 1 + .../RequestPageLayout/RequestPageLayout.tsx | 7 ++ .../RequestPageLayout/StyledWrapper.ts | 3 + .../Search/FolderFilter/FolderFilter.spec.tsx | 29 -------- .../Search/FolderFilter/FolderFilter.tsx | 48 ------------- .../components/Search/SearchBar/SearchBar.tsx | 62 ++++++---------- .../SearchResultItem.spec.tsx | 10 +-- .../Search/TagFilter/TagFilter.spec.tsx | 37 ++++++++++ .../components/Search/TagFilter/TagFilter.tsx | 44 ++++++++++++ .../src/components/Search/searchIndex.spec.ts | 72 +++++++++++++++---- .../src/components/Search/searchIndex.ts | 38 ++++++---- .../src/components/Tags/StyledWrapper.ts | 32 +++++++++ .../src/components/Tags/Tags.spec.tsx | 17 +++++ .../src/components/Tags/Tags.tsx | 27 +++++++ .../src/e2eFixtures/foldersCollection.ts | 11 +-- .../src/hooks/useRequestPageData.ts | 3 + .../src/pages/Folder/Folder.tsx | 10 ++- .../src/pages/GrpcRequest/GrpcRequest.tsx | 18 +++-- .../src/pages/GrpcRequest/StyledWrapper.ts | 3 + .../src/pages/Overview/Overview.tsx | 5 +- .../src/pages/Request/Request.spec.tsx | 21 ++++++ .../bruno-api-docs/src/sampleCollection.ts | 6 ++ .../src/ui/Dropdown/StyledWrapper.ts | 4 +- .../src/utils/schemaHelpers.spec.ts | 40 +++++++++++ .../bruno-api-docs/src/utils/schemaHelpers.ts | 21 ++++++ 30 files changed, 487 insertions(+), 180 deletions(-) create mode 100644 packages/bruno-api-docs/src/assets/icons/TagIcon.tsx delete mode 100644 packages/bruno-api-docs/src/components/Search/FolderFilter/FolderFilter.spec.tsx delete mode 100644 packages/bruno-api-docs/src/components/Search/FolderFilter/FolderFilter.tsx create mode 100644 packages/bruno-api-docs/src/components/Search/TagFilter/TagFilter.spec.tsx create mode 100644 packages/bruno-api-docs/src/components/Search/TagFilter/TagFilter.tsx create mode 100644 packages/bruno-api-docs/src/components/Tags/StyledWrapper.ts create mode 100644 packages/bruno-api-docs/src/components/Tags/Tags.spec.tsx create mode 100644 packages/bruno-api-docs/src/components/Tags/Tags.tsx diff --git a/packages/bruno-api-docs/e2e/components/search/search.component.ts b/packages/bruno-api-docs/e2e/components/search/search.component.ts index c9947778..ebe7575c 100644 --- a/packages/bruno-api-docs/e2e/components/search/search.component.ts +++ b/packages/bruno-api-docs/e2e/components/search/search.component.ts @@ -33,15 +33,18 @@ export class SearchComponent extends BaseComponent { readonly clearButton = this.root.getByRole('button', { name: 'Clear search' }); /** Below-desktop Topbar trigger that reveals the search. */ readonly toggleIcon = this.root.getByRole('button', { name: /^search$/i }); - readonly folderButton = this.root.getByRole('button', { name: 'Folder', exact: true }); - readonly folderMenu = this.root.getByRole('listbox', { name: 'Filter by folder' }); + /** The trigger's label changes with the selection ("Tags" → a tag name → "2 tags"), + * so match the first button inside the filter rather than a fixed label. */ + readonly tagButton = this.root.getByTestId('search-tag-filter').getByRole('button', { name: /.+/ }).first(); + readonly tagFilter = this.root.getByTestId('search-tag-filter'); + readonly tagMenu = this.root.getByRole('listbox', { name: 'Filter by tags' }); methodChip(label: string): Locator { return this.root.getByRole('button', { name: label, exact: true }); } - folderOption(name: string): Locator { - return this.folderMenu.getByRole('button', { name, exact: true }); + tagOption(name: string): Locator { + return this.tagMenu.getByRole('button', { name, exact: true }); } result(text: string): Locator { diff --git a/packages/bruno-api-docs/e2e/tests/request/request-details.spec.ts b/packages/bruno-api-docs/e2e/tests/request/request-details.spec.ts index 6ebb9ef1..32b0ac8c 100644 --- a/packages/bruno-api-docs/e2e/tests/request/request-details.spec.ts +++ b/packages/bruno-api-docs/e2e/tests/request/request-details.spec.ts @@ -39,6 +39,22 @@ test.describe('Request page — Details', () => { await expect(requestPage.description).toContainText('Retrieves customers created within a date range.'); }); + test.describe('Tags section', () => { + test('shows the request tags as chips', async ({ requestPage, page }) => { + await requestPage.open(['echo json']); + const tags = requestPage.section('Tags'); + await expect(tags).toBeVisible(); + await expect(page.getByTestId('request-tags-chip')).toHaveCount(2); + await expect(tags).toContainText('echo'); + await expect(tags).toContainText('smoke'); + }); + + test('renders no tags section for an untagged request', async ({ requestPage }) => { + await requestPage.open(['patch user']); + await expect(requestPage.section('Tags')).toHaveCount(0); + }); + }); + test.describe('Params section', () => { test('lists the query parameters', async ({ requestPage }) => { const params = requestPage.section('Params'); diff --git a/packages/bruno-api-docs/e2e/tests/search/search.spec.ts b/packages/bruno-api-docs/e2e/tests/search/search.spec.ts index a9555c73..d48d8b6a 100644 --- a/packages/bruno-api-docs/e2e/tests/search/search.spec.ts +++ b/packages/bruno-api-docs/e2e/tests/search/search.spec.ts @@ -371,41 +371,80 @@ test.describe('Search palette', () => { expect((opt?.y ?? 0) + (opt?.height ?? 0)).toBeLessThanOrEqual((box?.y ?? 0) + (box?.height ?? 0) + 1); }); - test('folder dropdown closes on an outside click', async ({ page, search }) => { + test('tag dropdown closes on an outside click', async ({ page, search }) => { await page.setViewportSize(DESKTOP); await page.goto(FIXTURE); await search.field.click(); - await search.folderButton.click(); - await expect(search.folderMenu).toBeVisible(); + await search.tagButton.click(); + await expect(search.tagMenu).toBeVisible(); await search.field.click(); // click outside the dropdown - await expect(search.folderMenu).toHaveCount(0); + await expect(search.tagMenu).toHaveCount(0); }); - test('folder filter scopes results to the chosen folder', async ({ page, search }) => { + test('tag filter narrows results to records carrying the tag', async ({ page, search }) => { await page.setViewportSize(DESKTOP); await page.goto(FIXTURE); await search.field.click(); - await search.folderButton.click(); - await search.folderOption('Authentication').click(); + await search.tagButton.click(); + await search.tagOption('auth').click(); await expect(search.panel).toContainText('Login'); + await expect(search.panel).toContainText('Refresh Token'); await expect(search.panel).not.toContainText('Create Booking'); }); - test('the filtered folder appears as its own result, ahead of its contents', async ({ page, search }) => { + test('multiple tags narrow to records carrying all of them', async ({ page, search }) => { await page.setViewportSize(DESKTOP); await page.goto(FIXTURE); await search.field.click(); - await search.folderButton.click(); - await search.folderOption('Bookings').click(); + await search.tagButton.click(); + await search.tagOption('auth').click(); + await search.tagOption('smoke').click(); + + await expect(search.panel).toContainText('Login'); + await expect(search.panel).not.toContainText('Refresh Token'); + await expect(search.panel).not.toContainText('Health Check'); + }); + + test('folder tags are inherited: the folder matches itself and everything beneath it', async ({ page, search }) => { + await page.setViewportSize(DESKTOP); + await page.goto(FIXTURE); + await search.field.click(); + + await search.tagButton.click(); + await search.tagOption('bookings').click(); await expect(search.folderResults).toHaveCount(3); await expect(search.folderResults.nth(0)).toContainText('Bookings'); - await expect(search.results.first()).toContainText('Bookings'); + await expect(search.panel).toContainText('Create Booking'); + await expect(search.panel).not.toContainText('Login'); + }); + + test('tag filter combines with method chips', async ({ page, search }) => { + await page.setViewportSize(DESKTOP); + await page.goto(FIXTURE); + await search.field.click(); + + await search.tagButton.click(); + await search.tagOption('bookings').click(); + await search.field.click(); + await search.methodChip('DEL').click(); + + await expect(search.results).toHaveCount(1); + await expect(search.results.first()).toContainText('Cancel Booking'); + }); + + test('a collection without tags offers no tag filter', async ({ page, search }) => { + await page.setViewportSize(DESKTOP); + await page.goto('/?fixture=vars'); + await search.field.click(); + + await expect(search.filters).toBeVisible(); + await expect(search.tagFilter).toHaveCount(0); }); test('tablet: the toggle reveals a panel that stays within the viewport', async ({ page, search }) => { diff --git a/packages/bruno-api-docs/src/assets/icons/TagIcon.tsx b/packages/bruno-api-docs/src/assets/icons/TagIcon.tsx new file mode 100644 index 00000000..d6839b71 --- /dev/null +++ b/packages/bruno-api-docs/src/assets/icons/TagIcon.tsx @@ -0,0 +1,9 @@ +import React from 'react'; +import { baseIconProps } from './baseIconProps'; + +export const TagIcon: React.FC = () => ( + + + + +); diff --git a/packages/bruno-api-docs/src/assets/icons/index.ts b/packages/bruno-api-docs/src/assets/icons/index.ts index dd123ba7..d3727016 100644 --- a/packages/bruno-api-docs/src/assets/icons/index.ts +++ b/packages/bruno-api-docs/src/assets/icons/index.ts @@ -26,6 +26,7 @@ export * from './DockBottomIcon'; export * from './DockModalIcon'; export * from './SidebarToggleIcon'; export * from './SettingsIcon'; +export * from './TagIcon'; export * from './TrashIcon'; export * from './ExampleIcon'; export * from './DotIcon'; diff --git a/packages/bruno-api-docs/src/components/RequestPageLayout/RequestPageLayout.spec.tsx b/packages/bruno-api-docs/src/components/RequestPageLayout/RequestPageLayout.spec.tsx index 738ccc59..e479e2df 100644 --- a/packages/bruno-api-docs/src/components/RequestPageLayout/RequestPageLayout.spec.tsx +++ b/packages/bruno-api-docs/src/components/RequestPageLayout/RequestPageLayout.spec.tsx @@ -9,6 +9,7 @@ import { getByTestId, queryByTestId } from '@/test-utils/dom'; const makeData = (overrides: Partial = {}): RequestPageData => ({ name: 'Get Users', + tags: [], url: '{{baseUrl}}/users', descHtml: '', pathParams: [], diff --git a/packages/bruno-api-docs/src/components/RequestPageLayout/RequestPageLayout.tsx b/packages/bruno-api-docs/src/components/RequestPageLayout/RequestPageLayout.tsx index 0e1cad01..d5b36541 100644 --- a/packages/bruno-api-docs/src/components/RequestPageLayout/RequestPageLayout.tsx +++ b/packages/bruno-api-docs/src/components/RequestPageLayout/RequestPageLayout.tsx @@ -16,6 +16,7 @@ import { PropertyTable } from '@/components/PropertyTable/PropertyTable'; import { InheritedAuthBadge } from '@/components/InheritedAuthBadge/InheritedAuthBadge'; import { ExecutionContext } from '@/components/ExecutionContext/ExecutionContext'; import { CodeSnippetTabs } from '@/components/CodeSnippetTabs/CodeSnippetTabs'; +import { Tags } from '@/components/Tags/Tags'; import type { HttpRequestBody, HttpRequestBodyVariant } from '@opencollection/types/requests/http'; import type { RequestPageData } from '@/hooks/useRequestPageData'; import { StyledWrapper } from './StyledWrapper'; @@ -52,6 +53,7 @@ export const RequestPageLayout: React.FC = ({ }) => { const { name, + tags, url, descHtml, pathParams, @@ -165,6 +167,11 @@ export const RequestPageLayout: React.FC = ({ auth={effectiveAuth} /> + {tags.length > 0 && ( +
+ +
+ )} diff --git a/packages/bruno-api-docs/src/components/RequestPageLayout/StyledWrapper.ts b/packages/bruno-api-docs/src/components/RequestPageLayout/StyledWrapper.ts index 19bfdfcb..ab324774 100644 --- a/packages/bruno-api-docs/src/components/RequestPageLayout/StyledWrapper.ts +++ b/packages/bruno-api-docs/src/components/RequestPageLayout/StyledWrapper.ts @@ -24,6 +24,9 @@ export const StyledWrapper = styled.div` .request-col-right { min-width: 0; + display: flex; + flex-direction: column; + gap: 1.5rem; position: sticky; top: 1.25rem; align-self: start; diff --git a/packages/bruno-api-docs/src/components/Search/FolderFilter/FolderFilter.spec.tsx b/packages/bruno-api-docs/src/components/Search/FolderFilter/FolderFilter.spec.tsx deleted file mode 100644 index 4ff3a909..00000000 --- a/packages/bruno-api-docs/src/components/Search/FolderFilter/FolderFilter.spec.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import React from 'react'; -import { renderToStaticMarkup } from 'react-dom/server'; -import { describe, it, expect } from 'vitest'; -import { FolderFilter } from './FolderFilter'; -import type { FolderOption } from '../searchIndex'; - -const folders: FolderOption[] = [ - { slug: 'hotels', name: 'Hotels' }, - { slug: 'bookings', name: 'Bookings' } -]; - -describe('FolderFilter', () => { - it('shows the neutral "Folder" label when nothing is selected', () => { - const html = renderToStaticMarkup( {}} />); - expect(html).toContain('Folder'); - expect(html).not.toContain('dropdown-button is-active'); - }); - - it('shows the selected folder name and an active state', () => { - const html = renderToStaticMarkup( {}} />); - expect(html).toContain('Hotels'); - expect(html).toContain('dropdown-button is-active'); - }); - - it('renders nothing when the collection has no folders', () => { - const html = renderToStaticMarkup( {}} />); - expect(html).toBe(''); - }); -}); diff --git a/packages/bruno-api-docs/src/components/Search/FolderFilter/FolderFilter.tsx b/packages/bruno-api-docs/src/components/Search/FolderFilter/FolderFilter.tsx deleted file mode 100644 index 4b34aeb6..00000000 --- a/packages/bruno-api-docs/src/components/Search/FolderFilter/FolderFilter.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import React from 'react'; -import { FolderIcon } from '@/assets/icons'; -import Dropdown from '@/ui/Dropdown/Dropdown'; -import type { FolderOption } from '../searchIndex'; - -interface FolderFilterProps { - folders: FolderOption[]; - /** Slug of the selected folder, or null when no folder filter is active. */ - value: string | null; - onChange: (slug: string | null) => void; - testId?: string; -} - -/** Single-select folder filter for the search palette, built on the shared - * Dropdown. Renders nothing when the collection has no folders. */ -export const FolderFilter: React.FC = ({ - folders, - value, - onChange, - testId = 'search-folder-filter' -}) => { - if (folders.length === 0) return null; - - const selected = folders.find((f) => f.slug === value) || null; - - return ( - - {({ close }) => - folders.map((folder) => ( -
  • - -
  • - ))} -
    - ); -}; - -export default FolderFilter; diff --git a/packages/bruno-api-docs/src/components/Search/SearchBar/SearchBar.tsx b/packages/bruno-api-docs/src/components/Search/SearchBar/SearchBar.tsx index 808a1b7b..31fc2ae1 100644 --- a/packages/bruno-api-docs/src/components/Search/SearchBar/SearchBar.tsx +++ b/packages/bruno-api-docs/src/components/Search/SearchBar/SearchBar.tsx @@ -3,7 +3,7 @@ import { useNavModel } from '@/routing/hooks'; import { useClickOutside, useDocsNavigate } from '@/hooks'; import { buildSearchRecords, - collectTopLevelFolders, + collectTags, collectMethods, createSearchIndex, searchHits, @@ -13,29 +13,21 @@ import { } from '../searchIndex'; import { SearchIcon, CloseIcon } from '@/assets/icons'; import MethodChips from '../MethodChips/MethodChips'; -import FolderFilter from '../FolderFilter/FolderFilter'; +import TagFilter from '../TagFilter/TagFilter'; import SearchResultItem from '../SearchResultItem/SearchResultItem'; import { StyledWrapper } from './StyledWrapper'; const RESULTS_ID = 'search-listbox'; -// A single character can't clear Fuse's `minMatchCharLength`, so treat a query -// shorter than this as "not typing yet": keep the initial prompt rather than -// flashing "no matching requests" after the first keystroke. +// Fuse never matches a single character (minMatchCharLength in searchIndex.ts), +// so a 1-char query would always show "no matches". Treat it as "still typing" +// and keep showing the initial prompt instead. const MIN_QUERY_LENGTH = 2; interface SearchBarProps { - /** Open state, controlled by the shell so it is shared with the Topbar's - * below-desktop search row (one source of truth: icon, row and panel agree). */ open: boolean; onOpenChange: (open: boolean) => void; - /** Changes on each hotkey press to refocus the field even when already open. */ focusNonce?: number; - /** - * Below-desktop layout: the field is revealed as a full-width row, so the - * panel widens to center within the docs area. Driven by the shell's - * docs-area-derived mode rather than a viewport media query. - */ collapsed?: boolean; testId?: string; } @@ -43,12 +35,8 @@ interface SearchBarProps { /** * Header-anchored collection search. Typo-tolerant (Fuse/Bitap) search over * request names and URLs and over folder names, plus palette-local method + - * folder filters. Results render in the palette itself and selecting one + * tag filters. Results render in the palette itself and selecting one * navigates via the slug route, to a request or a folder page. - * - * Expands in place (a combobox whose listbox drops directly below the field) - * rather than opening a centered modal. Open state is controlled so the Topbar - * search icon/row and this panel share one state (no redundant affordances). */ export const SearchBar: React.FC = ({ open, onOpenChange, focusNonce, collapsed = false, testId = 'search' }) => { const docsNavigate = useDocsNavigate(); @@ -56,13 +44,13 @@ export const SearchBar: React.FC = ({ open, onOpenChange, focusN const records = useMemo(() => buildSearchRecords(model.ordered), [model]); const fuse = useMemo(() => createSearchIndex(records), [records]); - const folders = useMemo(() => collectTopLevelFolders(model.ordered), [model]); + const tagOptions = useMemo(() => collectTags(records), [records]); // One chip per method present in the collection (canonical order). const methodOptions = useMemo(() => collectMethods(model.ordered), [model]); const [query, setQueryText] = useState(''); const [methods, setMethods] = useState>(() => new Set()); - const [folder, setFolder] = useState(null); + const [selectedTags, setSelectedTags] = useState>(() => new Set()); // -1 = no keyboard selection yet, so no row shows the active highlight until // the user actually arrow-keys (mouse filtering shouldn't pre-highlight row 0). const [activeIdx, setActiveIdx] = useState(-1); @@ -74,7 +62,7 @@ export const SearchBar: React.FC = ({ open, onOpenChange, focusN const optionId = (i: number) => `${RESULTS_ID}-opt-${i}`; const hasQuery = query.trim().length >= MIN_QUERY_LENGTH; - const hasFilter = methods.size > 0 || folder !== null; + const hasFilter = methods.size > 0 || selectedTags.size > 0; const results = useMemo(() => { const base: SearchHit[] = hasQuery @@ -86,14 +74,11 @@ export const SearchBar: React.FC = ({ open, onOpenChange, focusN // A folder carries no method, so any active method chip excludes them all. const passesMethod = methods.size === 0 || (r.type === 'request' && !!r.method && methods.has(r.method.toUpperCase())); - // The filtered folder matches itself, not only the items beneath it. - const passesFolder = folder === null || r.ancestorSlugs.includes(folder) || r.slug === folder; - return passesMethod && passesFolder; + const passesTags = selectedTags.size === 0 || [...selectedTags].every((tag) => r.tags.includes(tag)); + return passesMethod && passesTags; }); - // `searchHits` already groups folders first; the filter-only list is raw nav - // order, so it needs the same grouping to rank consistently. return hasQuery ? filtered : orderFoldersFirst(filtered); - }, [query, methods, folder, records, fuse, hasQuery, hasFilter]); + }, [query, methods, selectedTags, records, fuse, hasQuery, hasFilter]); useEffect(() => setActiveIdx(-1), [results]); @@ -125,9 +110,14 @@ export const SearchBar: React.FC = ({ open, onOpenChange, focusN [refocusInput] ); - const setFolderFilter = useCallback( - (slug: string | null) => { - setFolder(slug); + const toggleTag = useCallback( + (tag: string) => { + setSelectedTags((prev) => { + const next = new Set(prev); + if (next.has(tag)) next.delete(tag); + else next.add(tag); + return next; + }); refocusInput(); }, [refocusInput] @@ -135,7 +125,7 @@ export const SearchBar: React.FC = ({ open, onOpenChange, focusN const clearFilters = useCallback(() => { setMethods(new Set()); - setFolder(null); + setSelectedTags(new Set()); refocusInput(); }, [refocusInput]); @@ -151,14 +141,11 @@ export const SearchBar: React.FC = ({ open, onOpenChange, focusN (rec: SearchRecord) => { docsNavigate(rec.slug); resetAndClose(); - inputRef.current?.blur(); // navigating away, so drop focus from the palette + inputRef.current?.blur(); }, [docsNavigate, resetAndClose] ); - // Move the highlight and keep it in view. All option rows are already - // rendered (activeIdx only toggles a class), so we can scroll imperatively - // here rather than from an effect watching activeIdx. const moveActive = (delta: number) => { const next = Math.min(Math.max(activeIdx + delta, 0), results.length - 1); setActiveIdx(next); @@ -184,7 +171,6 @@ export const SearchBar: React.FC = ({ open, onOpenChange, focusN return; } if (e.key === 'Enter') { - // Enter selects the highlighted row, or the first result if none navigated. const hit = activeIdx >= 0 ? results[activeIdx] : results[0]; if (hit) { e.preventDefault(); @@ -235,7 +221,7 @@ export const SearchBar: React.FC = ({ open, onOpenChange, focusN <>
    - + {hasFilter && ( + + ))} + + ); +}; + +export default TagFilter; diff --git a/packages/bruno-api-docs/src/components/Search/searchIndex.spec.ts b/packages/bruno-api-docs/src/components/Search/searchIndex.spec.ts index 0853bdf7..05a1146b 100644 --- a/packages/bruno-api-docs/src/components/Search/searchIndex.spec.ts +++ b/packages/bruno-api-docs/src/components/Search/searchIndex.spec.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest'; import { buildSearchRecords, - collectTopLevelFolders, + collectTags, collectMethods, createSearchIndex, formatBreadcrumb, @@ -74,7 +74,7 @@ describe('buildSearchRecords', () => { expect(recs[0]).not.toHaveProperty('description'); }); - it('carries the ancestor chain as names and slugs', () => { + it('carries the ancestor chain as names', () => { const entry = requestEntry({ uuid: 'u1', ancestors: [ @@ -83,7 +83,6 @@ describe('buildSearchRecords', () => { ] }); expect(requestRecords([entry])[0].ancestorNames).toEqual(['Billing', 'Lookups']); - expect(requestRecords([entry])[0].ancestorSlugs).toEqual(['billing', 'billing/lookups']); }); it('emits a folder record for a folder at any depth', () => { @@ -98,7 +97,7 @@ describe('buildSearchRecords', () => { const recs = folderRecords([top, nested]); expect(recs.map((r) => r.id)).toEqual(['f1', 'f2']); expect(recs[1].slug).toBe('hotels/rooms'); - expect(recs[1].ancestorSlugs).toEqual(['hotels']); + expect(recs[1].ancestorNames).toEqual(['Hotels']); }); it('gives a folder its own breadcrumb, so same-named folders stay distinguishable', () => { @@ -196,13 +195,60 @@ describe('formatBreadcrumb', () => { }); }); -describe('collectTopLevelFolders', () => { - it('returns only depth-0 folders', () => { - const top: NavEntry = { slug: 'hotels', type: 'folder', name: 'Hotels', item: {} as never, ancestors: [], depth: 0 }; - const nested: NavEntry = { slug: 'hotels/x', type: 'folder', name: 'X', item: {} as never, ancestors: [], depth: 1 }; - expect(collectTopLevelFolders([top, nested, requestEntry({ uuid: 'u1' })])).toEqual([ - { slug: 'hotels', name: 'Hotels' } - ]); +describe('tags on search records', () => { + it('carries the item tags on request and folder records', () => { + const folder = folderEntry({ uuid: 'f1' }); + (folder.item as { info: { tags?: string[] } }).info.tags = ['catalog']; + const request = requestEntry({ uuid: 'u1', ancestors: [] }); + (request.item as { info: { tags?: string[] } }).info.tags = ['auth', 'smoke']; + expect(folderRecords([folder])[0].tags).toEqual(['catalog']); + expect(requestRecords([request])[0].tags).toEqual(['auth', 'smoke']); + }); + + it('merges ancestor folder tags, deduped', () => { + const folder = folderEntry({ uuid: 'f1', slug: 'hotels' }); + (folder.item as { info: { tags?: string[] } }).info.tags = ['catalog', 'smoke']; + const request = requestEntry({ uuid: 'u1' }); + (request.item as { info: { tags?: string[] } }).info.tags = ['smoke']; + expect(requestRecords([folder, request])[0].tags).toEqual(['smoke', 'catalog']); + }); + + it('inherits through the whole ancestor chain, folders included', () => { + const top = folderEntry({ uuid: 'f1', slug: 'billing', name: 'Billing' }); + (top.item as { info: { tags?: string[] } }).info.tags = ['billing']; + const nested = folderEntry({ + uuid: 'f2', + slug: 'billing/lookups', + name: 'Lookups', + ancestors: [{ name: 'Billing', slug: 'billing' }], + depth: 1 + }); + const request = requestEntry({ + uuid: 'u1', + ancestors: [ + { name: 'Billing', slug: 'billing' }, + { name: 'Lookups', slug: 'billing/lookups' } + ] + }); + const records = buildSearchRecords([top, nested, request]); + expect(records.find((r) => r.id === 'f2')!.tags).toEqual(['billing']); + expect(records.find((r) => r.id === 'u1')!.tags).toEqual(['billing']); + }); + + it('yields empty tags for untagged items', () => { + expect(requestRecords([requestEntry({ uuid: 'u1' })])[0].tags).toEqual([]); + expect(folderRecords([folderEntry({ uuid: 'f1' })])[0].tags).toEqual([]); + }); +}); + +describe('collectTags', () => { + it('returns distinct tags sorted alphabetically', () => { + const records = [rec({ tags: ['smoke', 'auth'] }), folderRec({ tags: ['auth', 'bookings'] })]; + expect(collectTags(records)).toEqual(['auth', 'bookings', 'smoke']); + }); + + it('returns an empty list for an untagged collection', () => { + expect(collectTags([rec({}), folderRec({})])).toEqual([]); }); }); @@ -222,11 +268,11 @@ describe('collectMethods', () => { }); const rec = (over: Partial): RequestSearchRecord => ({ - type: 'request', id: 'id', slug: 's', name: '', method: 'GET', ancestorNames: [], ancestorSlugs: [], url: '', ...over + type: 'request', id: 'id', slug: 's', name: '', method: 'GET', ancestorNames: [], tags: [], url: '', ...over }); const folderRec = (over: Partial): FolderSearchRecord => ({ - type: 'folder', id: 'fid', slug: 'f', name: '', ancestorNames: [], ancestorSlugs: [], requestCount: 0, ...over + type: 'folder', id: 'fid', slug: 'f', name: '', ancestorNames: [], tags: [], requestCount: 0, ...over }); /** Substrings the reported ranges actually cover, for match-locality assertions. */ diff --git a/packages/bruno-api-docs/src/components/Search/searchIndex.ts b/packages/bruno-api-docs/src/components/Search/searchIndex.ts index 6750eb60..03c0cdc2 100644 --- a/packages/bruno-api-docs/src/components/Search/searchIndex.ts +++ b/packages/bruno-api-docs/src/components/Search/searchIndex.ts @@ -15,7 +15,7 @@ import Fuse from 'fuse.js'; import type { IFuseOptions, FuseResultMatch } from 'fuse.js'; import type { Folder } from '@opencollection/types/collection/item'; import type { NavEntry } from '@/routing/types'; -import { getRequestUrl } from '@/utils/schemaHelpers'; +import { getRequestUrl, getItemTags } from '@/utils/schemaHelpers'; import { getItemUuid } from '@/utils/itemUtils'; import { countFolderRequests } from '@/utils/folder'; @@ -27,8 +27,8 @@ interface SearchRecordBase { name: string; /** Ancestor folder names, outermost first; joined for display, never searched. */ ancestorNames: string[]; - /** Ancestor folder slugs, for the folder filter chip. */ - ancestorSlugs: string[]; + /** Own tags plus tags inherited from ancestor folders (deduped), for the tag filter. */ + tags: string[]; } export interface RequestSearchRecord extends SearchRecordBase { @@ -44,27 +44,32 @@ export interface FolderSearchRecord extends SearchRecordBase { export type SearchRecord = RequestSearchRecord | FolderSearchRecord; -/** A folder offered in the palette's folder filter dropdown. */ -export interface FolderOption { - slug: string; - name: string; -} - const BREADCRUMB_SEPARATOR = ' / '; /** Build the searchable records (requests + folders) from the nav model. */ export const buildSearchRecords = (entries: NavEntry[]): SearchRecord[] => { + const folderTagsBySlug = new Map(); + for (const entry of entries) { + if (entry.type !== 'folder' || !entry.item) continue; + const folderTags = getItemTags(entry.item); + if (folderTags.length > 0) folderTagsBySlug.set(entry.slug, folderTags); + } + const records: SearchRecord[] = []; for (const entry of entries) { if (!entry.item) continue; const id = getItemUuid(entry.item); if (!id) continue; // unhydrated, cannot key to the sidebar; skip + const tags = new Set(getItemTags(entry.item)); + for (const ancestor of entry.ancestors) { + for (const tag of folderTagsBySlug.get(ancestor.slug) ?? []) tags.add(tag); + } const common = { id, slug: entry.slug, name: entry.name, ancestorNames: entry.ancestors.map((a) => a.name), - ancestorSlugs: entry.ancestors.map((a) => a.slug) + tags: [...tags] }; if (entry.type === 'folder') { @@ -107,11 +112,14 @@ export const formatBreadcrumb = (ancestorNames: string[]): BreadcrumbText => { return { full, display: ends.join(BREADCRUMB_SEPARATOR) }; }; -/** Top-level folders, for the folder filter dropdown. */ -export const collectTopLevelFolders = (entries: NavEntry[]): FolderOption[] => - entries - .filter((e) => e.type === 'folder' && e.depth === 0) - .map((e) => ({ slug: e.slug, name: e.name })); +/** Distinct tags across the rendered records, alphabetical, for the tag filter. */ +export const collectTags = (records: SearchRecord[]): string[] => { + const seen = new Set(); + for (const record of records) { + for (const tag of record.tags) seen.add(tag); + } + return [...seen].sort((a, b) => a.localeCompare(b)); +}; /** Canonical display order for method filters; anything not listed sorts last. */ const METHOD_DISPLAY_ORDER = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS', 'TRACE', 'CONNECT']; diff --git a/packages/bruno-api-docs/src/components/Tags/StyledWrapper.ts b/packages/bruno-api-docs/src/components/Tags/StyledWrapper.ts new file mode 100644 index 00000000..d55f5e19 --- /dev/null +++ b/packages/bruno-api-docs/src/components/Tags/StyledWrapper.ts @@ -0,0 +1,32 @@ +import styled from '@emotion/styled'; + +export const StyledWrapper = styled.div` + display: flex; + flex-wrap: wrap; + gap: 0.625rem; + + .tag-chip { + display: inline-flex; + align-items: center; + gap: 4px; + height: 28px; + padding: 0 4px; + box-sizing: border-box; + border: 1px solid var(--oc-border-border0); + border-radius: var(--oc-radius); + font-family: var(--font-sans); + font-size: 0.875rem; + color: var(--oc-colors-text-subtext2); + } + + .tag-chip svg { + width: 1rem; + height: 1rem; + flex-shrink: 0; + color: var(--oc-colors-text-subtext0); + } + + .tag-chip-label { + line-height: 1; + } +`; diff --git a/packages/bruno-api-docs/src/components/Tags/Tags.spec.tsx b/packages/bruno-api-docs/src/components/Tags/Tags.spec.tsx new file mode 100644 index 00000000..4d9bbca7 --- /dev/null +++ b/packages/bruno-api-docs/src/components/Tags/Tags.spec.tsx @@ -0,0 +1,17 @@ +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, it, expect } from 'vitest'; +import { Tags } from './Tags'; + +describe('Tags', () => { + it('renders nothing for an empty tag list', () => { + expect(renderToStaticMarkup()).toBe(''); + }); + + it('renders one chip per tag with the derived test id', () => { + const html = renderToStaticMarkup(); + expect(html).toContain('data-testid="request-tags"'); + expect(html).toContain('auth'); + expect(html).toContain('smoke'); + expect(html.match(/data-testid="request-tags-chip"/g)).toHaveLength(2); + }); +}); diff --git a/packages/bruno-api-docs/src/components/Tags/Tags.tsx b/packages/bruno-api-docs/src/components/Tags/Tags.tsx new file mode 100644 index 00000000..848f04c5 --- /dev/null +++ b/packages/bruno-api-docs/src/components/Tags/Tags.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { TagIcon } from '@/assets/icons'; +import { StyledWrapper } from './StyledWrapper'; + +interface TagsProps { + tags: string[]; + className?: string; + testId?: string; +} + +/** Chip list for an item's tags. Renders nothing when there are none. */ +export const Tags: React.FC = ({ tags, className, testId = 'tags' }) => { + if (tags.length === 0) return null; + + return ( + + {tags.map((tag) => ( + + + {tag} + + ))} + + ); +}; + +export default Tags; diff --git a/packages/bruno-api-docs/src/e2eFixtures/foldersCollection.ts b/packages/bruno-api-docs/src/e2eFixtures/foldersCollection.ts index d105bedc..69cd7b04 100644 --- a/packages/bruno-api-docs/src/e2eFixtures/foldersCollection.ts +++ b/packages/bruno-api-docs/src/e2eFixtures/foldersCollection.ts @@ -15,8 +15,8 @@ export const foldersFixtureCollection = { type: 'folder', seq: 1, items: [ - { name: 'Login', type: 'http', seq: 1, method: 'POST', url: '{{host}}/auth/login' }, - { name: 'Refresh Token', type: 'http', seq: 2, method: 'POST', url: '{{host}}/auth/refresh' }, + { name: 'Login', type: 'http', seq: 1, method: 'POST', url: '{{host}}/auth/login', tags: ['auth', 'smoke'] }, + { name: 'Refresh Token', type: 'http', seq: 2, method: 'POST', url: '{{host}}/auth/refresh', tags: ['auth'] }, { name: 'Logout', type: 'http', seq: 3, method: 'POST', url: '{{host}}/auth/logout' }, { name: 'Get Current User', type: 'http', seq: 4, method: 'GET', url: '{{host}}/auth/me' } ] @@ -51,6 +51,7 @@ export const foldersFixtureCollection = { name: 'Bookings', type: 'folder', seq: 3, + tags: ['bookings'], items: [ { name: 'List Bookings', type: 'http', seq: 1, method: 'GET', url: '{{host}}/bookings' }, { name: 'Get Booking', type: 'http', seq: 2, method: 'GET', url: '{{host}}/bookings/:id' }, @@ -59,9 +60,9 @@ export const foldersFixtureCollection = { type: 'folder', seq: 3, items: [ - { name: 'Create Booking', type: 'http', seq: 1, method: 'POST', url: '{{host}}/bookings' }, + { name: 'Create Booking', type: 'http', seq: 1, method: 'POST', url: '{{host}}/bookings', tags: ['write'] }, { name: 'Confirm Booking', type: 'http', seq: 2, method: 'PATCH', url: '{{host}}/bookings/:id/confirm' }, - { name: 'Cancel Booking', type: 'http', seq: 3, method: 'DELETE', url: '{{host}}/bookings/:id' } + { name: 'Cancel Booking', type: 'http', seq: 3, method: 'DELETE', url: '{{host}}/bookings/:id', tags: ['write'] } ] }, { @@ -118,7 +119,7 @@ export const foldersFixtureCollection = { } ] }, - { name: 'Health Check', type: 'http', seq: 5, method: 'GET', url: '{{host}}/ping' }, + { name: 'Health Check', type: 'http', seq: 5, method: 'GET', url: '{{host}}/ping', tags: ['smoke'] }, { name: 'Setup Script', type: 'script', diff --git a/packages/bruno-api-docs/src/hooks/useRequestPageData.ts b/packages/bruno-api-docs/src/hooks/useRequestPageData.ts index 283b33a7..e348a9fb 100644 --- a/packages/bruno-api-docs/src/hooks/useRequestPageData.ts +++ b/packages/bruno-api-docs/src/hooks/useRequestPageData.ts @@ -12,6 +12,7 @@ import { getRequestAuth, getItemDocs, getItemDescription, + getItemTags, type SupportedRequestItem } from '@/utils/schemaHelpers'; import { @@ -38,6 +39,7 @@ export const useRequestPageData = ( const md = useMarkdownRenderer(); const name = getItemName(item) || 'Untitled Request'; + const tags = getItemTags(item); const url = getRequestUrl(item); const headers = getRequestHeaders(item); const params = getRequestParams(item); @@ -102,6 +104,7 @@ export const useRequestPageData = ( return { name, + tags, url, descHtml, pathParams, diff --git a/packages/bruno-api-docs/src/pages/Folder/Folder.tsx b/packages/bruno-api-docs/src/pages/Folder/Folder.tsx index cf1f981c..c498b69f 100644 --- a/packages/bruno-api-docs/src/pages/Folder/Folder.tsx +++ b/packages/bruno-api-docs/src/pages/Folder/Folder.tsx @@ -3,7 +3,7 @@ import type { OpenCollection } from '@opencollection/types'; import type { Item, Folder as FolderItem } from '@opencollection/types/collection/item'; import { useMarkdownRenderer } from '@/hooks'; import { AUTH_MODE_LABELS } from '@/constants'; -import { getItemName, getItemDocs, getItemDescription } from '@/utils/schemaHelpers'; +import { getItemName, getItemDocs, getItemDescription, getItemTags } from '@/utils/schemaHelpers'; import { buildBreadcrumbSegments } from '@/utils/common'; import { getFolderConfig, hasFolderConfig, countFolderRequests, requestCountLabel } from '@/utils/folder'; import { PageWrapper } from '../../components/PageWrapper/PageWrapper'; @@ -13,6 +13,7 @@ import { Breadcrumb, type BreadcrumbSegment } from '@/ui/Breadcrumb/Breadcrumb'; import { ViewMore } from '../../components/ViewMore/ViewMore'; import { EmptyState } from '@/ui/EmptyState/EmptyState'; import { FolderConfiguration } from '../../components/FolderConfiguration/FolderConfiguration'; +import { Tags } from '@/components/Tags/Tags'; import { FolderIcon } from '@/assets/icons'; import { StyledWrapper } from './StyledWrapper'; @@ -27,6 +28,7 @@ export const Folder: React.FC = ({ item, ancestry = [], collection, const md = useMarkdownRenderer(); const name = getItemName(item) || 'Untitled Folder'; + const tags = getItemTags(item); const requestCount = useMemo(() => countFolderRequests(item), [item]); const config = useMemo(() => getFolderConfig(collection, ancestry, item), [collection, ancestry, item]); const showConfig = useMemo(() => hasFolderConfig(config), [config]); @@ -58,6 +60,12 @@ export const Folder: React.FC = ({ item, ancestry = [], collection,
    + {tags.length > 0 && ( +
    + +
    + )} + {docsHtml && (
    diff --git a/packages/bruno-api-docs/src/pages/GrpcRequest/GrpcRequest.tsx b/packages/bruno-api-docs/src/pages/GrpcRequest/GrpcRequest.tsx index b1c242b6..3effcefa 100644 --- a/packages/bruno-api-docs/src/pages/GrpcRequest/GrpcRequest.tsx +++ b/packages/bruno-api-docs/src/pages/GrpcRequest/GrpcRequest.tsx @@ -14,6 +14,7 @@ import { getGrpcMessages, getGrpcProtoFileName, getGrpcProtoFilePath, + getItemTags, countEnabled } from '@/utils/schemaHelpers'; import { @@ -28,6 +29,7 @@ import { collectTests, collectRawTestScripts } from '@/utils/fileUtils'; import { ExecutionContext } from '@/components/ExecutionContext/ExecutionContext'; import { generateGrpcurlCommand, generateGrpcJavaScriptCode, grpcMethodPath } from '@/utils/grpcSnippets'; import { SnippetTabs, type Snippet } from '@/components/SnippetTabs/SnippetTabs'; +import { Tags } from '@/components/Tags/Tags'; import { useMarkdownRenderer, useResolvedVariables } from '@/hooks'; import { singleReferenceName } from '@/utils/variableResolution'; import { buildBreadcrumbSegments } from '@/utils/common'; @@ -69,6 +71,7 @@ export const GrpcRequest: React.FC = ({ testId = 'grpc-request-page' }) => { const name = getItemName(item) || 'Untitled Request'; + const tags = getItemTags(item); const url = getRequestUrl(item); const method = getGrpcMethod(item); @@ -268,11 +271,18 @@ export const GrpcRequest: React.FC = ({ )} - {snippets.length > 0 && ( + {(snippets.length > 0 || tags.length > 0) && (
    -
    - -
    + {snippets.length > 0 && ( +
    + +
    + )} + {tags.length > 0 && ( +
    + +
    + )}
    )} diff --git a/packages/bruno-api-docs/src/pages/GrpcRequest/StyledWrapper.ts b/packages/bruno-api-docs/src/pages/GrpcRequest/StyledWrapper.ts index c2372aed..959b39d6 100644 --- a/packages/bruno-api-docs/src/pages/GrpcRequest/StyledWrapper.ts +++ b/packages/bruno-api-docs/src/pages/GrpcRequest/StyledWrapper.ts @@ -33,6 +33,9 @@ export const StyledWrapper = styled.div` .grpc-request-col-right { min-width: 0; + display: flex; + flex-direction: column; + gap: 1.5rem; position: sticky; top: 1.25rem; align-self: start; diff --git a/packages/bruno-api-docs/src/pages/Overview/Overview.tsx b/packages/bruno-api-docs/src/pages/Overview/Overview.tsx index 2e78c4d3..351ae60c 100644 --- a/packages/bruno-api-docs/src/pages/Overview/Overview.tsx +++ b/packages/bruno-api-docs/src/pages/Overview/Overview.tsx @@ -3,7 +3,7 @@ import type { OpenCollection } from '@opencollection/types'; import type { StructuredText } from '@opencollection/types/common/description'; import { useMarkdownRenderer } from '@/hooks'; import { getCollectionStats, hasCollectionConfiguration } from '@/utils/collectionOverview'; -import { scriptsArrayToObject } from '@/utils/schemaHelpers'; +import { scriptsArrayToObject, getCollectionTags } from '@/utils/schemaHelpers'; import { getCollectionVariables } from '@/utils/request'; import { AUTH_MODE_LABELS } from '@/constants'; import { CollectionStats } from '../../components/CollectionStats/CollectionStats'; @@ -13,6 +13,7 @@ import { PageWrapper } from '../../components/PageWrapper/PageWrapper'; import { Heading } from '../../components/Heading/Heading'; import { Section } from '../../components/Section/Section'; import { ViewMore } from '../../components/ViewMore/ViewMore'; +import { Tags } from '@/components/Tags/Tags'; import { BookIcon } from '@/assets/icons'; import { StyledWrapper } from './StyledWrapper'; @@ -42,6 +43,7 @@ export const Overview: React.FC = ({ collection, testId = 'overvi const { preVars, postVars } = useMemo(() => getCollectionVariables(collection), [collection]); const version = collection.info?.version; const name = collection.info?.name || 'Untitled Collection'; + const tags = getCollectionTags(collection); const docsHtml = useMemo(() => { const content = getDocsContent(collection.docs); @@ -68,6 +70,7 @@ export const Overview: React.FC = ({ collection, testId = 'overvi
    {`Version : ${version}`}
    ) : null} {name} + {tags.length > 0 && } diff --git a/packages/bruno-api-docs/src/pages/Request/Request.spec.tsx b/packages/bruno-api-docs/src/pages/Request/Request.spec.tsx index d6f9a9b0..041ec075 100644 --- a/packages/bruno-api-docs/src/pages/Request/Request.spec.tsx +++ b/packages/bruno-api-docs/src/pages/Request/Request.spec.tsx @@ -52,6 +52,27 @@ const item: HttpRequest = { }; describe('Request page', () => { + it('shows a tags section when the request has tags', () => { + const tagged = { ...item, info: { ...item.info, tags: ['auth', 'smoke'] } } as unknown as HttpRequest; + const root = useRenderToDom( + + {}} /> + + ); + const section = getByTestId(root, 'request-section-tags'); + expect(section.text).toContain('auth'); + expect(section.text).toContain('smoke'); + }); + + it('renders no tags section for an untagged request', () => { + const root = useRenderToDom( + + {}} /> + + ); + expect(queryByTestId(root, 'request-section-tags')).toBeNull(); + }); + it('renders the breadcrumb, heading, url bar, description and all populated sections', () => { const root = useRenderToDom( diff --git a/packages/bruno-api-docs/src/sampleCollection.ts b/packages/bruno-api-docs/src/sampleCollection.ts index 7a484f16..1b10b78c 100644 --- a/packages/bruno-api-docs/src/sampleCollection.ts +++ b/packages/bruno-api-docs/src/sampleCollection.ts @@ -161,6 +161,7 @@ items: name: "GraphQL Details" type: "graphql" description: "Fetches a country's name, capital, and emoji by its ISO country code." + tags: ["catalog"] graphql: method: "POST" url: "https://api.example.com/graphql" @@ -227,6 +228,7 @@ items: - info: name: "Order Service" type: "grpc" + tags: ["orders"] grpc: url: "{{grpcUrl}}" method: "/orders.OrderService/GetOrder" @@ -598,6 +600,7 @@ items: name: billing type: folder seq: 4 + tags: ["billing"] docs: | ## Billing @@ -2065,6 +2068,7 @@ items: - name: "echo json" type: "http" seq: 2 + tags: ["echo", "smoke"] method: "POST" url: "{{host}}/api/echo/json" headers: @@ -2226,6 +2230,7 @@ items: - name: "get users" type: "http" seq: 1 + tags: ["users", "smoke"] method: "GET" url: "{{host}}/api/users?page=1&limit=10" headers: @@ -2297,6 +2302,7 @@ items: - name: "update user" type: "http" seq: 4 + tags: ["users"] method: "PUT" url: "{{host}}/api/users/1" headers: diff --git a/packages/bruno-api-docs/src/ui/Dropdown/StyledWrapper.ts b/packages/bruno-api-docs/src/ui/Dropdown/StyledWrapper.ts index a22e3829..db9f679c 100644 --- a/packages/bruno-api-docs/src/ui/Dropdown/StyledWrapper.ts +++ b/packages/bruno-api-docs/src/ui/Dropdown/StyledWrapper.ts @@ -64,13 +64,13 @@ export const StyledWrapper = styled.div` gap: 8px; width: 100%; height: 27px; - padding: 7px 8px; + padding: 0 8px; box-sizing: border-box; cursor: pointer; font-family: var(--font-sans); font-size: 12px; font-weight: 400; - line-height: 1; + line-height: 1.4; text-align: left; color: var(--oc-text); background: transparent; diff --git a/packages/bruno-api-docs/src/utils/schemaHelpers.spec.ts b/packages/bruno-api-docs/src/utils/schemaHelpers.spec.ts index 61de85c9..956876b4 100644 --- a/packages/bruno-api-docs/src/utils/schemaHelpers.spec.ts +++ b/packages/bruno-api-docs/src/utils/schemaHelpers.spec.ts @@ -17,6 +17,8 @@ import { getGrpcMethodType, getGrpcMetadata, getGrpcProtoFileName, + getItemTags, + getCollectionTags, type RequestItem } from './schemaHelpers'; @@ -24,6 +26,8 @@ const item = (data: Record): OpenCollectionItem => data as unkn const requestItem = (data: Record): RequestItem => data as unknown as RequestItem; +const collection = (data: Record) => data as unknown as Parameters[0]; + describe('getItemDescription', () => { it('reads a plain string description from the info block', () => { expect(getItemDescription({ info: { description: 'Short summary.' } } as any)).toBe('Short summary.'); @@ -299,3 +303,39 @@ describe('getGrpcProtoFileName', () => { expect(getGrpcProtoFileName(item({ type: 'grpc' }))).toBeUndefined(); }); }); + +describe('getItemTags', () => { + it('reads tags from the info block', () => { + expect(getItemTags(item({ info: { name: 'Login', tags: ['auth', 'smoke'] } }))).toEqual(['auth', 'smoke']); + }); + + it('falls back to root-level tags for old-schema items', () => { + expect(getItemTags(item({ name: 'Login', tags: ['auth'] }))).toEqual(['auth']); + }); + + it('prefers info tags over root tags when both exist', () => { + expect(getItemTags(item({ info: { tags: ['new'] }, tags: ['old'] }))).toEqual(['new']); + }); + + it('returns an empty array for missing or malformed tags', () => { + expect(getItemTags(undefined)).toEqual([]); + expect(getItemTags(null)).toEqual([]); + expect(getItemTags(item({}))).toEqual([]); + expect(getItemTags(item({ info: { tags: 'auth' } }))).toEqual([]); + }); + + it('drops non-string and blank entries', () => { + expect(getItemTags(item({ info: { tags: ['auth', '', ' ', 7, null] } }))).toEqual(['auth']); + }); +}); + +describe('getCollectionTags', () => { + it('reads tags from the collection info block', () => { + expect(getCollectionTags(collection({ info: { name: 'Hotel Booking', tags: ['public'] } }))).toEqual(['public']); + }); + + it('returns an empty array when absent', () => { + expect(getCollectionTags(null)).toEqual([]); + expect(getCollectionTags(collection({ info: { name: 'Hotel Booking' } }))).toEqual([]); + }); +}); diff --git a/packages/bruno-api-docs/src/utils/schemaHelpers.ts b/packages/bruno-api-docs/src/utils/schemaHelpers.ts index 806efca7..1643760f 100644 --- a/packages/bruno-api-docs/src/utils/schemaHelpers.ts +++ b/packages/bruno-api-docs/src/utils/schemaHelpers.ts @@ -85,6 +85,27 @@ export const getItemSeq = (item: OpenCollectionItem | null | undefined): number return undefined; }; +const normalizeTags = (raw: unknown): string[] => { + if (!Array.isArray(raw)) return []; + return raw.filter((tag): tag is string => typeof tag === 'string' && tag.trim().length > 0); +}; + +/** + * Get the tags of an item (from info block or root for backwards compatibility). + * Tags are protocol-agnostic: any request type, and folders, may carry them. + */ +export const getItemTags = (item: OpenCollectionItem | null | undefined): string[] => { + if (!item) return []; + const info = 'info' in item ? (item as { info?: { tags?: unknown } }).info : undefined; + if (info && Array.isArray(info.tags)) return normalizeTags(info.tags); + if ('tags' in item) return normalizeTags((item as { tags?: unknown }).tags); + return []; +}; + +/** Get the collection-level tags from the info block. */ +export const getCollectionTags = (collection: OpenCollection | null | undefined): string[] => + normalizeTags((collection as { info?: { tags?: unknown } } | null | undefined)?.info?.tags); + /** * Check if an item is a folder */ From 5903a09c7d26ae84208763af81cca0774f23f1ac Mon Sep 17 00:00:00 2001 From: Sundram Gupta Date: Wed, 26 Aug 2026 01:59:00 +0530 Subject: [PATCH 2/3] fix(docs): address review on tag normalization, a11y and e2e locators Trim and dedupe tags at the source so hand-edited files cannot produce duplicate chips or whitespace-distinct filter entries. Mark the tag filter listbox as multi-select for assistive technology, style the overview tag row, and replace the order-dependent tag trigger locator with a derived test id. Tag page tests get their own describe and POM locator, and the search suite now covers tag-filtered typed queries and the Clear all reset. --- .../e2e/components/search/search.component.ts | 4 +-- .../bruno-api-docs/e2e/pages/request.page.ts | 1 + .../e2e/tests/request/request-details.spec.ts | 32 +++++++++--------- .../e2e/tests/search/search.spec.ts | 33 +++++++++++++++++++ .../components/Search/SearchBar/SearchBar.tsx | 13 ++++++++ .../SearchResultItem.spec.tsx | 6 ++++ .../components/Search/TagFilter/TagFilter.tsx | 2 +- .../src/components/Search/searchIndex.spec.ts | 27 +++++++-------- .../src/pages/Overview/StyledWrapper.ts | 3 ++ .../src/ui/Dropdown/Dropdown.tsx | 19 +++++++++-- .../src/utils/schemaHelpers.spec.ts | 5 +++ .../bruno-api-docs/src/utils/schemaHelpers.ts | 8 ++++- 12 files changed, 114 insertions(+), 39 deletions(-) diff --git a/packages/bruno-api-docs/e2e/components/search/search.component.ts b/packages/bruno-api-docs/e2e/components/search/search.component.ts index ebe7575c..049a6839 100644 --- a/packages/bruno-api-docs/e2e/components/search/search.component.ts +++ b/packages/bruno-api-docs/e2e/components/search/search.component.ts @@ -33,10 +33,8 @@ export class SearchComponent extends BaseComponent { readonly clearButton = this.root.getByRole('button', { name: 'Clear search' }); /** Below-desktop Topbar trigger that reveals the search. */ readonly toggleIcon = this.root.getByRole('button', { name: /^search$/i }); - /** The trigger's label changes with the selection ("Tags" → a tag name → "2 tags"), - * so match the first button inside the filter rather than a fixed label. */ - readonly tagButton = this.root.getByTestId('search-tag-filter').getByRole('button', { name: /.+/ }).first(); readonly tagFilter = this.root.getByTestId('search-tag-filter'); + readonly tagButton = this.root.getByTestId('search-tag-filter-button'); readonly tagMenu = this.root.getByRole('listbox', { name: 'Filter by tags' }); methodChip(label: string): Locator { diff --git a/packages/bruno-api-docs/e2e/pages/request.page.ts b/packages/bruno-api-docs/e2e/pages/request.page.ts index e28cda63..5f42b980 100644 --- a/packages/bruno-api-docs/e2e/pages/request.page.ts +++ b/packages/bruno-api-docs/e2e/pages/request.page.ts @@ -12,6 +12,7 @@ export class RequestPage extends BasePage { readonly root = this.page.getByTestId('request-page'); readonly title = this.page.getByTestId('request-title'); readonly description = this.page.getByTestId('request-description'); + readonly tagChips = this.page.getByTestId('request-tags-chip'); readonly sidebar = new SidebarComponent(this.page); readonly breadcrumb = new BreadcrumbComponent(this.page, 'request-breadcrumb'); diff --git a/packages/bruno-api-docs/e2e/tests/request/request-details.spec.ts b/packages/bruno-api-docs/e2e/tests/request/request-details.spec.ts index 32b0ac8c..20832bf1 100644 --- a/packages/bruno-api-docs/e2e/tests/request/request-details.spec.ts +++ b/packages/bruno-api-docs/e2e/tests/request/request-details.spec.ts @@ -39,22 +39,6 @@ test.describe('Request page — Details', () => { await expect(requestPage.description).toContainText('Retrieves customers created within a date range.'); }); - test.describe('Tags section', () => { - test('shows the request tags as chips', async ({ requestPage, page }) => { - await requestPage.open(['echo json']); - const tags = requestPage.section('Tags'); - await expect(tags).toBeVisible(); - await expect(page.getByTestId('request-tags-chip')).toHaveCount(2); - await expect(tags).toContainText('echo'); - await expect(tags).toContainText('smoke'); - }); - - test('renders no tags section for an untagged request', async ({ requestPage }) => { - await requestPage.open(['patch user']); - await expect(requestPage.section('Tags')).toHaveCount(0); - }); - }); - test.describe('Params section', () => { test('lists the query parameters', async ({ requestPage }) => { const params = requestPage.section('Params'); @@ -118,3 +102,19 @@ test.describe('Request page — Details', () => { }); }); }); + +test.describe('Request page — Tags section', () => { + test('shows the request tags as chips', async ({ requestPage }) => { + await requestPage.open(['echo json']); + const tags = requestPage.section('Tags'); + await expect(tags).toBeVisible(); + await expect(requestPage.tagChips).toHaveCount(2); + await expect(tags).toContainText('echo'); + await expect(tags).toContainText('smoke'); + }); + + test('renders no tags section for an untagged request', async ({ requestPage }) => { + await requestPage.open(['patch user']); + await expect(requestPage.section('Tags')).toHaveCount(0); + }); +}); diff --git a/packages/bruno-api-docs/e2e/tests/search/search.spec.ts b/packages/bruno-api-docs/e2e/tests/search/search.spec.ts index d48d8b6a..4ff3c1ea 100644 --- a/packages/bruno-api-docs/e2e/tests/search/search.spec.ts +++ b/packages/bruno-api-docs/e2e/tests/search/search.spec.ts @@ -438,6 +438,39 @@ test.describe('Search palette', () => { await expect(search.results.first()).toContainText('Cancel Booking'); }); + test('tag filter narrows typed-query results too', async ({ page, search }) => { + await page.setViewportSize(DESKTOP); + await page.goto(FIXTURE); + await search.field.click(); + await search.field.fill('payment'); + + // The Payments requests match the query and inherit "bookings" from their + // ancestor folder; Login matches neither. + await search.tagButton.click(); + await search.tagOption('bookings').click(); + + await expect(search.panel).toContainText('Charge Payment'); + await expect(search.panel).toContainText('Refund Payment'); + await expect(search.panel).not.toContainText('Login'); + }); + + test('"Clear all" resets the method and tag filters together', async ({ page, search }) => { + await page.setViewportSize(DESKTOP); + await page.goto(FIXTURE); + await search.field.click(); + + await search.tagButton.click(); + await search.tagOption('auth').click(); + await search.field.click(); // close the menu + await search.methodChip('POST').click(); + + await search.root.getByRole('button', { name: 'Clear all' }).click(); + + // With no query and no filters the palette returns to its initial prompt. + await expect(search.panel).toContainText('Search the collection'); + await expect(search.tagButton).toHaveText(/Tags/); + }); + test('a collection without tags offers no tag filter', async ({ page, search }) => { await page.setViewportSize(DESKTOP); await page.goto('/?fixture=vars'); diff --git a/packages/bruno-api-docs/src/components/Search/SearchBar/SearchBar.tsx b/packages/bruno-api-docs/src/components/Search/SearchBar/SearchBar.tsx index 31fc2ae1..f46d140b 100644 --- a/packages/bruno-api-docs/src/components/Search/SearchBar/SearchBar.tsx +++ b/packages/bruno-api-docs/src/components/Search/SearchBar/SearchBar.tsx @@ -25,9 +25,17 @@ const RESULTS_ID = 'search-listbox'; const MIN_QUERY_LENGTH = 2; interface SearchBarProps { + /** Open state, controlled by the shell so it is shared with the Topbar's + * below-desktop search row (one source of truth: icon, row and panel agree). */ open: boolean; onOpenChange: (open: boolean) => void; + /** Changes on each hotkey press to refocus the field even when already open. */ focusNonce?: number; + /** + * Below-desktop layout: the field is revealed as a full-width row, so the + * panel widens to center within the docs area. Driven by the shell's + * docs-area-derived mode rather than a viewport media query. + */ collapsed?: boolean; testId?: string; } @@ -146,6 +154,9 @@ export const SearchBar: React.FC = ({ open, onOpenChange, focusN [docsNavigate, resetAndClose] ); + // Move the highlight and keep it in view. All option rows are already + // rendered (activeIdx only toggles a class), so we can scroll imperatively + // here rather than from an effect watching activeIdx. const moveActive = (delta: number) => { const next = Math.min(Math.max(activeIdx + delta, 0), results.length - 1); setActiveIdx(next); @@ -270,6 +281,8 @@ export const SearchBar: React.FC = ({ open, onOpenChange, focusN role="option" aria-selected={i === activeIdx} onMouseMove={(e) => { + // Only real pointer movement (not scroll-induced mousemove + // under a parked cursor) should steal the highlight. if (e.movementX !== 0 || e.movementY !== 0) setActiveIdx(i); }} > diff --git a/packages/bruno-api-docs/src/components/Search/SearchResultItem/SearchResultItem.spec.tsx b/packages/bruno-api-docs/src/components/Search/SearchResultItem/SearchResultItem.spec.tsx index a15b4806..4014829a 100644 --- a/packages/bruno-api-docs/src/components/Search/SearchResultItem/SearchResultItem.spec.tsx +++ b/packages/bruno-api-docs/src/components/Search/SearchResultItem/SearchResultItem.spec.tsx @@ -4,6 +4,8 @@ import { describe, it, expect } from 'vitest'; import { SearchResultItem } from './SearchResultItem'; import type { RequestSearchRecord, FolderSearchRecord } from '../searchIndex'; +// Match "" wrapped in a bold element, regardless of its attributes, so the +// assertion keys off the tag (the highlight contract) and not a styling class. const boldElement = /]*)?>([^<]*)<\/b>/g; const boldedText = (html: string): string[] => [...html.matchAll(boldElement)].map((m) => m[1]); @@ -43,6 +45,7 @@ describe('SearchResultItem', () => { }); it('wraps the matched ranges of a field in a bold element', () => { + // "Hotels" sits at indices 8-13 of "Get All Hotels". const html = renderToStaticMarkup( {}} /> ); @@ -55,6 +58,8 @@ describe('SearchResultItem', () => { }); it('shows a deep breadcrumb elided, naming the node with the whole chain', () => { + // The hidden folders are unreachable by pointer for keyboard and AT users, + // so the label has to carry them. const deep = { ...record, ancestorNames: ['Hotels', 'Auth', 'Auth 2', 'Legacy', 'v3'] }; const html = renderToStaticMarkup( {}} />); expect(html).toContain('Hotels / … / v3'); @@ -119,6 +124,7 @@ describe('SearchResultItem - folder variant', () => { }); it('bolds the matched range of the folder name', () => { + // "Auth" sits at indices 6-9 of "Basic Auth". const html = renderToStaticMarkup( {}} /> ); diff --git a/packages/bruno-api-docs/src/components/Search/TagFilter/TagFilter.tsx b/packages/bruno-api-docs/src/components/Search/TagFilter/TagFilter.tsx index 68e56975..7e6928d3 100644 --- a/packages/bruno-api-docs/src/components/Search/TagFilter/TagFilter.tsx +++ b/packages/bruno-api-docs/src/components/Search/TagFilter/TagFilter.tsx @@ -23,7 +23,7 @@ export const TagFilter: React.FC = ({ tags, selected, onToggle, if (tags.length === 0) return null; return ( - 0} menuLabel="Filter by tags" testId={testId}> + 0} menuLabel="Filter by tags" multiselect testId={testId}> {() => tags.map((tag) => (
  • diff --git a/packages/bruno-api-docs/src/components/Search/searchIndex.spec.ts b/packages/bruno-api-docs/src/components/Search/searchIndex.spec.ts index 05a1146b..faae7335 100644 --- a/packages/bruno-api-docs/src/components/Search/searchIndex.spec.ts +++ b/packages/bruno-api-docs/src/components/Search/searchIndex.spec.ts @@ -13,8 +13,8 @@ import { } from './searchIndex'; import type { NavEntry } from '@/routing/types'; -const requestEntry = (over: Partial & { uuid: string }): NavEntry => { - const { uuid, ...rest } = over; +const requestEntry = (over: Partial & { uuid: string; tags?: string[] }): NavEntry => { + const { uuid, tags, ...rest } = over; return { slug: 'hotels/get-all', type: 'request', @@ -24,7 +24,7 @@ const requestEntry = (over: Partial & { uuid: string }): NavEntry => { depth: 1, item: { uuid, - info: { name: 'Get All Hotels', type: 'http', description: 'List hotels' }, + info: { name: 'Get All Hotels', type: 'http', description: 'List hotels', tags }, http: { method: 'GET', url: '{{baseUrl}}/api/v1/hotels', params: [{ name: 'page', value: '1' }] } } as never, ...rest @@ -38,15 +38,15 @@ const childItem = (name: string, type: 'http' | 'folder' | 'script', items?: unk ...(items ? { items } : {}) }); -const folderEntry = (over: Partial & { uuid: string; items?: unknown[] }): NavEntry => { - const { uuid, items, ...rest } = over; +const folderEntry = (over: Partial & { uuid: string; items?: unknown[]; tags?: string[] }): NavEntry => { + const { uuid, items, tags, ...rest } = over; return { slug: 'hotels', type: 'folder', name: 'Hotels', ancestors: [], depth: 0, - item: { uuid, info: { name: 'Hotels', type: 'folder' }, items: items ?? [] } as never, + item: { uuid, info: { name: 'Hotels', type: 'folder', tags }, items: items ?? [] } as never, ...rest }; }; @@ -197,25 +197,20 @@ describe('formatBreadcrumb', () => { describe('tags on search records', () => { it('carries the item tags on request and folder records', () => { - const folder = folderEntry({ uuid: 'f1' }); - (folder.item as { info: { tags?: string[] } }).info.tags = ['catalog']; - const request = requestEntry({ uuid: 'u1', ancestors: [] }); - (request.item as { info: { tags?: string[] } }).info.tags = ['auth', 'smoke']; + const folder = folderEntry({ uuid: 'f1', tags: ['catalog'] }); + const request = requestEntry({ uuid: 'u1', ancestors: [], tags: ['auth', 'smoke'] }); expect(folderRecords([folder])[0].tags).toEqual(['catalog']); expect(requestRecords([request])[0].tags).toEqual(['auth', 'smoke']); }); it('merges ancestor folder tags, deduped', () => { - const folder = folderEntry({ uuid: 'f1', slug: 'hotels' }); - (folder.item as { info: { tags?: string[] } }).info.tags = ['catalog', 'smoke']; - const request = requestEntry({ uuid: 'u1' }); - (request.item as { info: { tags?: string[] } }).info.tags = ['smoke']; + const folder = folderEntry({ uuid: 'f1', slug: 'hotels', tags: ['catalog', 'smoke'] }); + const request = requestEntry({ uuid: 'u1', tags: ['smoke'] }); expect(requestRecords([folder, request])[0].tags).toEqual(['smoke', 'catalog']); }); it('inherits through the whole ancestor chain, folders included', () => { - const top = folderEntry({ uuid: 'f1', slug: 'billing', name: 'Billing' }); - (top.item as { info: { tags?: string[] } }).info.tags = ['billing']; + const top = folderEntry({ uuid: 'f1', slug: 'billing', name: 'Billing', tags: ['billing'] }); const nested = folderEntry({ uuid: 'f2', slug: 'billing/lookups', diff --git a/packages/bruno-api-docs/src/pages/Overview/StyledWrapper.ts b/packages/bruno-api-docs/src/pages/Overview/StyledWrapper.ts index cbdb8ce4..17931a13 100644 --- a/packages/bruno-api-docs/src/pages/Overview/StyledWrapper.ts +++ b/packages/bruno-api-docs/src/pages/Overview/StyledWrapper.ts @@ -17,6 +17,9 @@ export const StyledWrapper = styled.div` margin-bottom: 0.3rem; font-weight: 600; } + .overview-tags { + margin-top: 0.75rem; + } .overview-stats-row { margin-top: 1.25rem; } diff --git a/packages/bruno-api-docs/src/ui/Dropdown/Dropdown.tsx b/packages/bruno-api-docs/src/ui/Dropdown/Dropdown.tsx index af720fb7..f152b6ff 100644 --- a/packages/bruno-api-docs/src/ui/Dropdown/Dropdown.tsx +++ b/packages/bruno-api-docs/src/ui/Dropdown/Dropdown.tsx @@ -10,6 +10,7 @@ interface DropdownProps { active?: boolean; /** Accessible name for the listbox menu. */ menuLabel: string; + multiselect?: boolean; /** Menu content; receives `close` to dismiss after a selection. */ children: (api: { close: () => void }) => React.ReactNode; testId?: string; @@ -21,7 +22,14 @@ interface DropdownProps { * render-prop and call `close` after a selection. Options should use the * `dropdown-option` / `dropdown-label` classes for consistent styling. */ -export const Dropdown: React.FC = ({ label, active = false, menuLabel, children, testId }) => { +export const Dropdown: React.FC = ({ + label, + active = false, + menuLabel, + multiselect = false, + children, + testId +}) => { const [open, setOpen] = useState(false); const wrapperRef = useRef(null); const menuId = useId(); @@ -48,6 +56,7 @@ export const Dropdown: React.FC = ({ label, active = false, menuL {open && ( -
      +
        {children({ close })}
      )} diff --git a/packages/bruno-api-docs/src/utils/schemaHelpers.spec.ts b/packages/bruno-api-docs/src/utils/schemaHelpers.spec.ts index 956876b4..05768e63 100644 --- a/packages/bruno-api-docs/src/utils/schemaHelpers.spec.ts +++ b/packages/bruno-api-docs/src/utils/schemaHelpers.spec.ts @@ -327,6 +327,11 @@ describe('getItemTags', () => { it('drops non-string and blank entries', () => { expect(getItemTags(item({ info: { tags: ['auth', '', ' ', 7, null] } }))).toEqual(['auth']); }); + + it('trims whitespace and dedupes, so " auth" and "auth" are one tag', () => { + expect(getItemTags(item({ info: { tags: [' auth', 'auth', 'auth '] } }))).toEqual(['auth']); + expect(getItemTags(item({ info: { tags: [' smoke ', 'auth'] } }))).toEqual(['smoke', 'auth']); + }); }); describe('getCollectionTags', () => { diff --git a/packages/bruno-api-docs/src/utils/schemaHelpers.ts b/packages/bruno-api-docs/src/utils/schemaHelpers.ts index 1643760f..6a11b1cc 100644 --- a/packages/bruno-api-docs/src/utils/schemaHelpers.ts +++ b/packages/bruno-api-docs/src/utils/schemaHelpers.ts @@ -87,7 +87,13 @@ export const getItemSeq = (item: OpenCollectionItem | null | undefined): number const normalizeTags = (raw: unknown): string[] => { if (!Array.isArray(raw)) return []; - return raw.filter((tag): tag is string => typeof tag === 'string' && tag.trim().length > 0); + const seen = new Set(); + for (const tag of raw) { + if (typeof tag !== 'string') continue; + const trimmed = tag.trim(); + if (trimmed) seen.add(trimmed); + } + return [...seen]; }; /** From f6a3f1f16ca86961929213cc1add444ff99e9a7f Mon Sep 17 00:00:00 2001 From: Sundram Gupta Date: Wed, 26 Aug 2026 18:41:43 +0530 Subject: [PATCH 3/3] feat(docs): show inherited tags on request pages and address review Request pages now display tags inherited from ancestor folders as muted chips with an inherited-count badge, matching how inherited auth and headers are presented. Tag casts follow the string[] contract the Bruno app saves, the tag menu and trigger expose derived test ids, the tag filter listbox is announced as multi-select, and the new specs render through useRenderToDom. Also fixes two surfaced bugs: graphql requests were absent from search records entirely (own nav page type was never indexed), and the gRPC page swallowed tags when the request had no other configuration. The testbench now covers chained inheritance, mixed own/inherited chips, own-over-inherited dedupe, tagged websockets and collection-level tags. --- .../e2e/components/search/search.component.ts | 2 +- .../bruno-api-docs/e2e/pages/request.page.ts | 1 + .../e2e/tests/request/request-details.spec.ts | 25 +++++++ .../e2e/tests/search/search.spec.ts | 16 +++++ .../RequestPageLayout.spec.tsx | 1 + .../RequestPageLayout/RequestPageLayout.tsx | 17 ++++- .../Search/TagFilter/TagFilter.spec.tsx | 30 ++++---- .../src/components/Search/searchIndex.spec.ts | 27 +++---- .../src/components/Search/searchIndex.ts | 72 ++----------------- .../src/components/Tags/StyledWrapper.ts | 11 ++- .../src/components/Tags/Tags.spec.tsx | 32 +++++++-- .../src/components/Tags/Tags.tsx | 11 ++- .../src/hooks/useRequestPageData.ts | 5 +- .../pages/GrpcRequest/GrpcRequest.spec.tsx | 12 ++++ .../src/pages/GrpcRequest/GrpcRequest.tsx | 47 ++++++++---- .../src/pages/Request/Request.spec.tsx | 17 ++++- .../bruno-api-docs/src/sampleCollection.ts | 5 ++ .../src/ui/Dropdown/Dropdown.tsx | 1 + .../src/utils/schemaHelpers.spec.ts | 16 +++++ .../bruno-api-docs/src/utils/schemaHelpers.ts | 18 ++++- 20 files changed, 233 insertions(+), 133 deletions(-) diff --git a/packages/bruno-api-docs/e2e/components/search/search.component.ts b/packages/bruno-api-docs/e2e/components/search/search.component.ts index 049a6839..baa2a1e9 100644 --- a/packages/bruno-api-docs/e2e/components/search/search.component.ts +++ b/packages/bruno-api-docs/e2e/components/search/search.component.ts @@ -35,7 +35,7 @@ export class SearchComponent extends BaseComponent { readonly toggleIcon = this.root.getByRole('button', { name: /^search$/i }); readonly tagFilter = this.root.getByTestId('search-tag-filter'); readonly tagButton = this.root.getByTestId('search-tag-filter-button'); - readonly tagMenu = this.root.getByRole('listbox', { name: 'Filter by tags' }); + readonly tagMenu = this.root.getByTestId('search-tag-filter-menu'); methodChip(label: string): Locator { return this.root.getByRole('button', { name: label, exact: true }); diff --git a/packages/bruno-api-docs/e2e/pages/request.page.ts b/packages/bruno-api-docs/e2e/pages/request.page.ts index 5f42b980..355112b0 100644 --- a/packages/bruno-api-docs/e2e/pages/request.page.ts +++ b/packages/bruno-api-docs/e2e/pages/request.page.ts @@ -13,6 +13,7 @@ export class RequestPage extends BasePage { readonly title = this.page.getByTestId('request-title'); readonly description = this.page.getByTestId('request-description'); readonly tagChips = this.page.getByTestId('request-tags-chip'); + readonly inheritedTagChips = this.page.getByTestId('request-tags-inherited-chip'); readonly sidebar = new SidebarComponent(this.page); readonly breadcrumb = new BreadcrumbComponent(this.page, 'request-breadcrumb'); diff --git a/packages/bruno-api-docs/e2e/tests/request/request-details.spec.ts b/packages/bruno-api-docs/e2e/tests/request/request-details.spec.ts index 20832bf1..0ec571ea 100644 --- a/packages/bruno-api-docs/e2e/tests/request/request-details.spec.ts +++ b/packages/bruno-api-docs/e2e/tests/request/request-details.spec.ts @@ -117,4 +117,29 @@ test.describe('Request page — Tags section', () => { await requestPage.open(['patch user']); await expect(requestPage.section('Tags')).toHaveCount(0); }); + + test('shows tags inherited from the whole folder chain with a count badge', async ({ requestPage }) => { + await requestPage.open(['billing', 'customers', 'Get Customers - Filter by Status']); + const tags = requestPage.section('Tags'); + await expect(tags).toBeVisible(); + await expect(tags).toContainText('2 tags inherited'); + await expect(requestPage.inheritedTagChips).toHaveCount(2); + await expect(requestPage.inheritedTagChips.first()).toContainText('billing'); + await expect(requestPage.inheritedTagChips.last()).toContainText('customers'); + }); + + test('shows own and inherited tags side by side', async ({ requestPage }) => { + await requestPage.open(['billing', 'customers', 'Get All Customers']); + await expect(requestPage.tagChips).toHaveCount(1); + await expect(requestPage.tagChips.first()).toContainText('smoke'); + await expect(requestPage.inheritedTagChips).toHaveCount(2); + }); + + test('a tag owned and inherited shows once, as own', async ({ requestPage }) => { + await requestPage.open(['billing', 'subscriptions', 'Get All Subscriptions']); + await expect(requestPage.tagChips).toHaveCount(1); + await expect(requestPage.tagChips.first()).toContainText('billing'); + await expect(requestPage.inheritedTagChips).toHaveCount(0); + await expect(requestPage.section('Tags')).not.toContainText('inherited'); + }); }); diff --git a/packages/bruno-api-docs/e2e/tests/search/search.spec.ts b/packages/bruno-api-docs/e2e/tests/search/search.spec.ts index 4ff3c1ea..b7e6b43f 100644 --- a/packages/bruno-api-docs/e2e/tests/search/search.spec.ts +++ b/packages/bruno-api-docs/e2e/tests/search/search.spec.ts @@ -471,6 +471,22 @@ test.describe('Search palette', () => { await expect(search.tagButton).toHaveText(/Tags/); }); + test('a graphql request is searchable and filterable by its tag', async ({ page, search }) => { + await page.setViewportSize(DESKTOP); + await page.goto('/'); // the testbench holds the tagged GraphQL request + await search.field.click(); + await search.field.fill('graphql details'); + + await expect(search.panel).toContainText('GraphQL Details'); + + await search.field.clear(); + await search.tagButton.click(); + await search.tagOption('catalog').click(); + + await expect(search.panel).toContainText('GraphQL Details'); + await expect(search.panel).not.toContainText('Order Service'); + }); + test('a collection without tags offers no tag filter', async ({ page, search }) => { await page.setViewportSize(DESKTOP); await page.goto('/?fixture=vars'); diff --git a/packages/bruno-api-docs/src/components/RequestPageLayout/RequestPageLayout.spec.tsx b/packages/bruno-api-docs/src/components/RequestPageLayout/RequestPageLayout.spec.tsx index e479e2df..d4c5ec10 100644 --- a/packages/bruno-api-docs/src/components/RequestPageLayout/RequestPageLayout.spec.tsx +++ b/packages/bruno-api-docs/src/components/RequestPageLayout/RequestPageLayout.spec.tsx @@ -10,6 +10,7 @@ const makeData = (overrides: Partial = {}): RequestPageData => ({ name: 'Get Users', tags: [], + inheritedTags: [], url: '{{baseUrl}}/users', descHtml: '', pathParams: [], diff --git a/packages/bruno-api-docs/src/components/RequestPageLayout/RequestPageLayout.tsx b/packages/bruno-api-docs/src/components/RequestPageLayout/RequestPageLayout.tsx index d5b36541..c6bdc465 100644 --- a/packages/bruno-api-docs/src/components/RequestPageLayout/RequestPageLayout.tsx +++ b/packages/bruno-api-docs/src/components/RequestPageLayout/RequestPageLayout.tsx @@ -54,6 +54,7 @@ export const RequestPageLayout: React.FC = ({ const { name, tags, + inheritedTags, url, descHtml, pathParams, @@ -167,9 +168,19 @@ export const RequestPageLayout: React.FC = ({ auth={effectiveAuth} />
  • - {tags.length > 0 && ( -
    - + {(tags.length > 0 || inheritedTags.length > 0) && ( +
    0 ? ( + + ) : undefined + } + > +
    )} diff --git a/packages/bruno-api-docs/src/components/Search/TagFilter/TagFilter.spec.tsx b/packages/bruno-api-docs/src/components/Search/TagFilter/TagFilter.spec.tsx index c2fe312e..417d8bfa 100644 --- a/packages/bruno-api-docs/src/components/Search/TagFilter/TagFilter.spec.tsx +++ b/packages/bruno-api-docs/src/components/Search/TagFilter/TagFilter.spec.tsx @@ -1,37 +1,35 @@ import React from 'react'; -import { renderToStaticMarkup } from 'react-dom/server'; import { describe, it, expect } from 'vitest'; +import { useRenderToDom } from '@/hooks/useRenderToDom'; +import { getByTestId, queryByTestId } from '@/test-utils/dom'; import TagFilter from './TagFilter'; describe('TagFilter', () => { it('renders nothing when the collection has no tags', () => { - const html = renderToStaticMarkup( - {}} /> - ); - expect(html).toBe(''); + const root = useRenderToDom( {}} />); + expect(queryByTestId(root, 'search-tag-filter')).toBeNull(); }); it('shows the neutral label when nothing is selected', () => { - const html = renderToStaticMarkup( - {}} /> - ); - expect(html).toContain('Tags'); - expect(html).toContain('data-testid="search-tag-filter"'); - expect(html).toContain('class="dropdown-button"'); + const root = useRenderToDom( {}} />); + const button = getByTestId(root, 'search-tag-filter-button'); + expect(button.text).toContain('Tags'); + expect(button.classNames).not.toContain('is-active'); }); it('shows the tag name when exactly one is selected', () => { - const html = renderToStaticMarkup( + const root = useRenderToDom( {}} /> ); - expect(html).toContain('auth'); - expect(html).toContain('class="dropdown-button is-active"'); + const button = getByTestId(root, 'search-tag-filter-button'); + expect(button.text).toContain('auth'); + expect(button.classNames).toContain('is-active'); }); it('shows a count when several are selected', () => { - const html = renderToStaticMarkup( + const root = useRenderToDom( {}} /> ); - expect(html).toContain('2 tags'); + expect(getByTestId(root, 'search-tag-filter-button').text).toContain('2 tags'); }); }); diff --git a/packages/bruno-api-docs/src/components/Search/searchIndex.spec.ts b/packages/bruno-api-docs/src/components/Search/searchIndex.spec.ts index faae7335..024204cd 100644 --- a/packages/bruno-api-docs/src/components/Search/searchIndex.spec.ts +++ b/packages/bruno-api-docs/src/components/Search/searchIndex.spec.ts @@ -31,7 +31,6 @@ const requestEntry = (over: Partial & { uuid: string; tags?: string[] }; }; -/** A child item of a folder, as the collection schema shapes it. */ const childItem = (name: string, type: 'http' | 'folder' | 'script', items?: unknown[]) => ({ uuid: `${type}-${name}`, info: { name, type }, @@ -147,6 +146,15 @@ describe('buildSearchRecords', () => { expect(folderRecords([entry])[0].requestCount).toBe(1); }); + it('emits a request record for a graphql entry, with its badge as the method', () => { + const entry = requestEntry({ uuid: 'g1', type: 'graphql', name: 'Country Lookup', method: 'GQL', tags: ['catalog'] }); + const recs = requestRecords([entry]); + expect(recs).toHaveLength(1); + expect(recs[0].method).toBe('GQL'); + expect(recs[0].tags).toEqual(['catalog']); + expect(collectMethods([entry])).toEqual(['GQL']); + }); + it('excludes built-in pages from records', () => { const overview: NavEntry = { slug: '', type: 'overview', name: 'Overview', item: null, ancestors: [], depth: -1 @@ -182,12 +190,10 @@ describe('formatBreadcrumb', () => { }); it('counts folders, not separators, so a name holding " / " cannot mis-segment', () => { - // Three folders, the middle one carrying a separator inside its own name. expect(formatBreadcrumb(['Billing', 'A / B', 'Payments'])).toEqual({ full: 'Billing / A / B / Payments', display: 'Billing / A / B / Payments' }); - // Four folders elide from the ends, however many separators the names hold. expect(formatBreadcrumb(['Billing', 'A / B', 'Payments', 'v3'])).toEqual({ full: 'Billing / A / B / Payments / v3', display: 'Billing / … / v3' @@ -270,13 +276,11 @@ const folderRec = (over: Partial): FolderSearchRecord => ({ type: 'folder', id: 'fid', slug: 'f', name: '', ancestorNames: [], tags: [], requestCount: 0, ...over }); -/** Substrings the reported ranges actually cover, for match-locality assertions. */ const matchedText = (text: string, ranges?: Array<[number, number]>): string[] => (ranges ?? []).map(([start, end]) => text.slice(start, end + 1)); const ids = (hits: ReturnType): string[] => hits.map((h) => h.record.id); -// A small, representative billing collection reused across the matching tests. const BILLING: RequestSearchRecord[] = [ rec({ id: 'payments', name: 'Get All Payments', ancestorNames: ['Billing'], url: '{{baseUrl}}/billing/payments' }), rec({ id: 'invoices', name: 'Get All Invoices', ancestorNames: ['Billing'], url: '{{baseUrl}}/billing/invoices' }), @@ -335,8 +339,8 @@ describe('searchHits - exact matches per field', () => { describe('searchHits - typo tolerance', () => { it('tolerates a one-character error', () => { const fuse = createSearchIndex(BILLING); - expect(ids(searchHits(fuse, 'paymnt'))).toContain('payments'); // dropped letter - expect(ids(searchHits(fuse, 'invoises'))).toContain('invoices'); // substitution + expect(ids(searchHits(fuse, 'paymnt'))).toContain('payments'); + expect(ids(searchHits(fuse, 'invoises'))).toContain('invoices'); expect(ids(searchHits(fuse, 'custmers'))).toContain('customers'); }); @@ -365,9 +369,7 @@ describe('searchHits - match locality (no cross-word stitching)', () => { const hit = searchHits(fuse, 'billing').find((h) => h.record.id === 'payments')!; const { url } = BILLING.find((r) => r.id === 'payments')!; const subs = matchedText(url, hit.matches.url); - // The matched span is the word "billing" itself... expect(subs).toContain('billing'); - // ...never the "b" of "{{baseUrl}}" at index 2. expect(hit.matches.url?.map(([start]) => start)).not.toContain(2); }); }); @@ -418,7 +420,6 @@ describe('searchHits - reported matches for highlighting', () => { it('reports ranges only for the fields that actually matched', () => { const fuse = createSearchIndex([rec({ id: 'x', name: 'Get All Payments', url: '{{baseUrl}}/billing/invoices' })]); const hit = searchHits(fuse, 'payments')[0]; - // "payments" is in the name but not in the url. expect(hit.matches.name).toBeTruthy(); expect(hit.matches.url).toBeUndefined(); }); @@ -451,18 +452,12 @@ describe('searchHits - transposition typos (adjacent letter swap)', () => { it('swap variants do not introduce unrelated records (near-exact gate)', () => { const fuse = createSearchIndex(BILLING); - // Scrambling "cursor" must not back-door "currencies" in via a variant. expect(ids(searchHits(fuse, 'cursor'))).not.toContain('currencies'); expect(searchHits(fuse, 'zzzzz')).toEqual([]); }); }); describe('searchHits - abbreviations are intentionally out of scope', () => { - // Bitap only matches contiguous approximate spans, never a gapped subsequence - // like a consonant-skeleton abbreviation. Supporting those would need a much - // looser threshold that reopens the prefix-bleed false positives above, so it - // is deliberately left unsupported. These guard that boundary: if the matcher - // ever starts accepting abbreviations, precision has almost certainly slipped. it('does not match a consonant-skeleton abbreviation', () => { const hotels = createSearchIndex([rec({ id: 'h', name: 'Get All Hotels', url: '{{baseUrl}}/api/v1/hotels' })]); expect(ids(searchHits(hotels, 'htl'))).not.toContain('h'); // htl -> hotel diff --git a/packages/bruno-api-docs/src/components/Search/searchIndex.ts b/packages/bruno-api-docs/src/components/Search/searchIndex.ts index 03c0cdc2..93df79f3 100644 --- a/packages/bruno-api-docs/src/components/Search/searchIndex.ts +++ b/packages/bruno-api-docs/src/components/Search/searchIndex.ts @@ -1,16 +1,3 @@ -/** - * Search index for the collection palette. - * - * Turns the routing NavModel's ordered entries into flat, scoreable records: - * one per request node and one per folder node, at any depth. Each record's - * `id` is the item UUID, the exact identifier the sidebar keys on. - * - * Requests match on name and url; folders match on name alone, since a folder - * is not an endpoint and has no url to match or display. - * - * Pure + React-free so it can be unit tested and memoized by the caller. - */ - import Fuse from 'fuse.js'; import type { IFuseOptions, FuseResultMatch } from 'fuse.js'; import type { Folder } from '@opencollection/types/collection/item'; @@ -20,14 +7,10 @@ import { getItemUuid } from '@/utils/itemUtils'; import { countFolderRequests } from '@/utils/folder'; interface SearchRecordBase { - /** Item UUID (the sidebar key). */ id: string; - /** Route target slug. */ slug: string; name: string; - /** Ancestor folder names, outermost first; joined for display, never searched. */ ancestorNames: string[]; - /** Own tags plus tags inherited from ancestor folders (deduped), for the tag filter. */ tags: string[]; } @@ -46,6 +29,9 @@ export type SearchRecord = RequestSearchRecord | FolderSearchRecord; const BREADCRUMB_SEPARATOR = ' / '; +// GraphQL requests route to their own page type but are requests for search. +const isRequestEntry = (entry: NavEntry): boolean => entry.type === 'request' || entry.type === 'graphql'; + /** Build the searchable records (requests + folders) from the nav model. */ export const buildSearchRecords = (entries: NavEntry[]): SearchRecord[] => { const folderTagsBySlug = new Map(); @@ -59,7 +45,7 @@ export const buildSearchRecords = (entries: NavEntry[]): SearchRecord[] => { for (const entry of entries) { if (!entry.item) continue; const id = getItemUuid(entry.item); - if (!id) continue; // unhydrated, cannot key to the sidebar; skip + if (!id) continue; const tags = new Set(getItemTags(entry.item)); for (const ancestor of entry.ancestors) { for (const tag of folderTagsBySlug.get(ancestor.slug) ?? []) tags.add(tag); @@ -78,7 +64,7 @@ export const buildSearchRecords = (entries: NavEntry[]): SearchRecord[] => { ...common, requestCount: countFolderRequests(entry.item as Folder) }); - } else if (entry.type === 'request') { + } else if (isRequestEntry(entry)) { records.push({ type: 'request', ...common, @@ -93,9 +79,7 @@ export const buildSearchRecords = (entries: NavEntry[]): SearchRecord[] => { const MAX_BREADCRUMB_SEGMENTS = 3; export interface BreadcrumbText { - /** Every ancestor, for the accessible name and the tooltip. */ full: string; - /** What the row paints; equals `full` until the chain is too long. */ display: string; } @@ -112,7 +96,6 @@ export const formatBreadcrumb = (ancestorNames: string[]): BreadcrumbText => { return { full, display: ends.join(BREADCRUMB_SEPARATOR) }; }; -/** Distinct tags across the rendered records, alphabetical, for the tag filter. */ export const collectTags = (records: SearchRecord[]): string[] => { const seen = new Set(); for (const record of records) { @@ -121,18 +104,12 @@ export const collectTags = (records: SearchRecord[]): string[] => { return [...seen].sort((a, b) => a.localeCompare(b)); }; -/** Canonical display order for method filters; anything not listed sorts last. */ const METHOD_DISPLAY_ORDER = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS', 'TRACE', 'CONNECT']; -/** - * Distinct request methods present in the collection, uppercased, in canonical - * order (custom methods last, alphabetical). Drives the method filters so any - * method actually in use (PATCH/HEAD/OPTIONS/custom) is offered, not a fixed list. - */ export const collectMethods = (entries: NavEntry[]): string[] => { const seen = new Set(); for (const e of entries) { - if (e.type !== 'request') continue; + if (!isRequestEntry(e)) continue; const m = e.method?.toUpperCase(); if (m) seen.add(m); } @@ -146,13 +123,10 @@ export const collectMethods = (entries: NavEntry[]): string[] => { }); }; -/** Searchable + highlightable fields, in weight order (name dominates). */ type SearchField = 'name' | 'url'; -/** Matched character ranges per field, as inclusive [start, end] pairs. */ export type FieldMatches = Partial>>; -/** A ranked result plus the ranges that matched, so the row can bold them. */ export interface SearchHit { record: SearchRecord; matches: FieldMatches; @@ -193,16 +167,6 @@ const collectMatches = (matches: readonly FuseResultMatch[] | undefined): FieldM return byField; }; -/** - * Adjacent-character swaps of `query`, e.g. "hotles" → ["ohtles", "htoles", - * "holtes", "hotels", "hotlse"]. Bitap scores a transposition as two edits, so - * a swap-typo on a short word ("hotles" → "hotels") busts the threshold and is - * missed. Searching these variants restores the correction as a near-exact hit - * without loosening the threshold (which would reopen prefix-bleed matches). - * Swaps of equal neighbours are skipped (they reproduce the original). Long - * queries are not expanded: they are far more likely a pasted string than a - * mistyped word, and expansion is one Fuse pass per character position. - */ const MAX_SWAP_QUERY_LENGTH = 24; const adjacentSwaps = (query: string): string[] => { @@ -215,37 +179,13 @@ const adjacentSwaps = (query: string): string[] => { return variants; }; -/** - * A swap variant must come back near-exact to count: a genuine de-typo lands at - * ~0 (the corrected word is really there), whereas a variant that only fuzzily - * grazes an unrelated record scores higher and is noise. This gate only applies - * to records the original query did NOT already surface. - */ const TRANSPOSITION_MAX_SCORE = 0.1; -/** - * Folders form a block above requests, matching how the sidebar orders a level. - * The grouping is unconditional, so a fuzzy folder hit outranks an exact request - * hit; score only decides the order within a block. - */ const groupRank = (record: SearchRecord): number => (record.type === 'folder' ? 0 : 1); -/** - * Apply that same grouping to an already-ordered list, for the filter-only - * results the palette builds without running a query. The sort is stable, so - * each group keeps its incoming (nav) order. - */ export const orderFoldersFirst = (hits: SearchHit[]): SearchHit[] => [...hits].sort((a, b) => groupRank(a.record) - groupRank(b.record)); -/** - * Rank records against a query (text only; filters are applied separately by - * the caller). Empty query → [] (the palette shows its initial empty state, - * not the whole collection). The original query runs at the normal threshold; - * adjacent-swap variants back-fill transposition typos the threshold misses. - * Each record is kept at its best (lowest) score, so a variant that corrects a - * typo also improves the row's rank and highlight. - */ export const searchHits = (fuse: Fuse, query: string): SearchHit[] => { const q = query.trim(); if (!q) return []; diff --git a/packages/bruno-api-docs/src/components/Tags/StyledWrapper.ts b/packages/bruno-api-docs/src/components/Tags/StyledWrapper.ts index d55f5e19..595b37ef 100644 --- a/packages/bruno-api-docs/src/components/Tags/StyledWrapper.ts +++ b/packages/bruno-api-docs/src/components/Tags/StyledWrapper.ts @@ -8,9 +8,9 @@ export const StyledWrapper = styled.div` .tag-chip { display: inline-flex; align-items: center; - gap: 4px; - height: 28px; - padding: 0 4px; + gap: 0.25rem; + height: 1.75rem; + padding: 0 0.25rem; box-sizing: border-box; border: 1px solid var(--oc-border-border0); border-radius: var(--oc-radius); @@ -19,6 +19,11 @@ export const StyledWrapper = styled.div` color: var(--oc-colors-text-subtext2); } + .tag-chip.is-inherited { + border-style: dashed; + color: var(--oc-colors-text-subtext0); + } + .tag-chip svg { width: 1rem; height: 1rem; diff --git a/packages/bruno-api-docs/src/components/Tags/Tags.spec.tsx b/packages/bruno-api-docs/src/components/Tags/Tags.spec.tsx index 4d9bbca7..2de2be30 100644 --- a/packages/bruno-api-docs/src/components/Tags/Tags.spec.tsx +++ b/packages/bruno-api-docs/src/components/Tags/Tags.spec.tsx @@ -1,17 +1,35 @@ -import { renderToStaticMarkup } from 'react-dom/server'; +import React from 'react'; import { describe, it, expect } from 'vitest'; +import { useRenderToDom } from '@/hooks/useRenderToDom'; +import { queryByTestId } from '@/test-utils/dom'; import { Tags } from './Tags'; describe('Tags', () => { it('renders nothing for an empty tag list', () => { - expect(renderToStaticMarkup()).toBe(''); + const root = useRenderToDom(); + expect(queryByTestId(root, 'tags')).toBeNull(); }); it('renders one chip per tag with the derived test id', () => { - const html = renderToStaticMarkup(); - expect(html).toContain('data-testid="request-tags"'); - expect(html).toContain('auth'); - expect(html).toContain('smoke'); - expect(html.match(/data-testid="request-tags-chip"/g)).toHaveLength(2); + const root = useRenderToDom(); + const chips = root.querySelectorAll('[data-testid="request-tags-chip"]'); + expect(chips).toHaveLength(2); + expect(chips[0].text).toContain('auth'); + expect(chips[1].text).toContain('smoke'); + }); + + it('renders inherited tags as muted chips after the own tags', () => { + const root = useRenderToDom(); + expect(root.querySelectorAll('[data-testid="request-tags-chip"]')).toHaveLength(1); + const inherited = root.querySelectorAll('[data-testid="request-tags-inherited-chip"]'); + expect(inherited).toHaveLength(1); + expect(inherited[0].classNames).toContain('is-inherited'); + expect(inherited[0].text).toContain('billing'); + }); + + it('renders inherited-only tags without any own chips', () => { + const root = useRenderToDom(); + expect(root.querySelectorAll('[data-testid="request-tags-inherited-chip"]')).toHaveLength(1); + expect(queryByTestId(root, 'request-tags-chip')).toBeNull(); }); }); diff --git a/packages/bruno-api-docs/src/components/Tags/Tags.tsx b/packages/bruno-api-docs/src/components/Tags/Tags.tsx index 848f04c5..5fc5291b 100644 --- a/packages/bruno-api-docs/src/components/Tags/Tags.tsx +++ b/packages/bruno-api-docs/src/components/Tags/Tags.tsx @@ -4,13 +4,14 @@ import { StyledWrapper } from './StyledWrapper'; interface TagsProps { tags: string[]; + inheritedTags?: string[]; className?: string; testId?: string; } /** Chip list for an item's tags. Renders nothing when there are none. */ -export const Tags: React.FC = ({ tags, className, testId = 'tags' }) => { - if (tags.length === 0) return null; +export const Tags: React.FC = ({ tags, inheritedTags = [], className, testId = 'tags' }) => { + if (tags.length === 0 && inheritedTags.length === 0) return null; return ( @@ -20,6 +21,12 @@ export const Tags: React.FC = ({ tags, className, testId = 'tags' }) {tag} ))} + {inheritedTags.map((tag) => ( + + + {tag} + + ))} ); }; diff --git a/packages/bruno-api-docs/src/hooks/useRequestPageData.ts b/packages/bruno-api-docs/src/hooks/useRequestPageData.ts index e348a9fb..aaf48c11 100644 --- a/packages/bruno-api-docs/src/hooks/useRequestPageData.ts +++ b/packages/bruno-api-docs/src/hooks/useRequestPageData.ts @@ -13,6 +13,7 @@ import { getItemDocs, getItemDescription, getItemTags, + getInheritedTags, type SupportedRequestItem } from '@/utils/schemaHelpers'; import { @@ -39,7 +40,8 @@ export const useRequestPageData = ( const md = useMarkdownRenderer(); const name = getItemName(item) || 'Untitled Request'; - const tags = getItemTags(item); + const tags = useMemo(() => getItemTags(item), [item]); + const inheritedTags = useMemo(() => getInheritedTags(ancestry, tags), [ancestry, tags]); const url = getRequestUrl(item); const headers = getRequestHeaders(item); const params = getRequestParams(item); @@ -105,6 +107,7 @@ export const useRequestPageData = ( return { name, tags, + inheritedTags, url, descHtml, pathParams, diff --git a/packages/bruno-api-docs/src/pages/GrpcRequest/GrpcRequest.spec.tsx b/packages/bruno-api-docs/src/pages/GrpcRequest/GrpcRequest.spec.tsx index 1ae73b38..72554ac7 100644 --- a/packages/bruno-api-docs/src/pages/GrpcRequest/GrpcRequest.spec.tsx +++ b/packages/bruno-api-docs/src/pages/GrpcRequest/GrpcRequest.spec.tsx @@ -29,6 +29,18 @@ describe('GrpcRequest', () => { expect(getByTestId(root, 'request-url').text).toContain('{{grpcUrl}}'); }); + it('shows the tags of a request that has no other configuration', () => { + const root = useRenderToDom( + + ); + + const section = getByTestId(root, 'grpc-request-section-tags'); + expect(section.text).toContain('orders'); + expect(queryByTestId(root, 'grpc-request-config-empty')).not.toBeNull(); + }); + it('falls back to a placeholder name and never offers a Try button', () => { const root = useRenderToDom(); diff --git a/packages/bruno-api-docs/src/pages/GrpcRequest/GrpcRequest.tsx b/packages/bruno-api-docs/src/pages/GrpcRequest/GrpcRequest.tsx index 3effcefa..cd1584c2 100644 --- a/packages/bruno-api-docs/src/pages/GrpcRequest/GrpcRequest.tsx +++ b/packages/bruno-api-docs/src/pages/GrpcRequest/GrpcRequest.tsx @@ -15,6 +15,7 @@ import { getGrpcProtoFileName, getGrpcProtoFilePath, getItemTags, + getInheritedTags, countEnabled } from '@/utils/schemaHelpers'; import { @@ -22,7 +23,8 @@ import { getPreRequestVars, getPostResponseVars, buildScriptChain, - getScriptFlow + getScriptFlow, + inheritedCountLabel } from '@/utils/request'; import { collectAssertions } from '@/utils/assertions'; import { collectTests, collectRawTestScripts } from '@/utils/fileUtils'; @@ -71,7 +73,8 @@ export const GrpcRequest: React.FC = ({ testId = 'grpc-request-page' }) => { const name = getItemName(item) || 'Untitled Request'; - const tags = getItemTags(item); + const tags = useMemo(() => getItemTags(item), [item]); + const inheritedTags = useMemo(() => getInheritedTags(ancestry, tags), [ancestry, tags]); const url = getRequestUrl(item); const method = getGrpcMethod(item); @@ -130,6 +133,18 @@ export const GrpcRequest: React.FC = ({ return built; }, [url, resolvedUrl, method, methodType, protoFilePath, metadata, messages, effectiveAuth]); + const hasRightColumn = snippets.length > 0 || tags.length > 0 || inheritedTags.length > 0; + + const configEmptyState = ( + } + heading="No request configuration" + subheading="This request has no method, messages, metadata, or authentication configured." + /> + ); + const md = useMarkdownRenderer(); const descHtml = useMemo(() => { @@ -183,9 +198,10 @@ export const GrpcRequest: React.FC = ({ )} - {hasLeftColumn ? ( + {hasLeftColumn || hasRightColumn ? (
    + {!hasLeftColumn && configEmptyState} {protoFileName && (
    = ({ )}
    - {(snippets.length > 0 || tags.length > 0) && ( + {hasRightColumn && (
    {snippets.length > 0 && (
    )} - {tags.length > 0 && ( -
    - + {(tags.length > 0 || inheritedTags.length > 0) && ( +
    0 ? ( + + ) : undefined + } + > +
    )}
    )}
    ) : ( - } - heading="No request configuration" - subheading="This request has no method, messages, metadata, or authentication configured." - /> + configEmptyState )}
    { expect(section.text).toContain('smoke'); }); - it('renders no tags section for an untagged request', () => { + it('renders no tags section for an untagged request with untagged ancestors', () => { const root = useRenderToDom( {}} /> @@ -73,6 +73,21 @@ describe('Request page', () => { expect(queryByTestId(root, 'request-section-tags')).toBeNull(); }); + it('shows folder tags as inherited with a count badge', () => { + const taggedAncestry = [ + { uuid: 'folder-1', info: { name: 'Authentication', type: 'folder', tags: ['billing'] } } as unknown as Item + ]; + const root = useRenderToDom( + + {}} /> + + ); + const section = getByTestId(root, 'request-section-tags'); + expect(section.text).toContain('billing'); + expect(section.text).toContain('1 tag inherited'); + expect(queryByTestId(root, 'request-tags-inherited-chip')).not.toBeNull(); + }); + it('renders the breadcrumb, heading, url bar, description and all populated sections', () => { const root = useRenderToDom( diff --git a/packages/bruno-api-docs/src/sampleCollection.ts b/packages/bruno-api-docs/src/sampleCollection.ts index 1b10b78c..828b8cc5 100644 --- a/packages/bruno-api-docs/src/sampleCollection.ts +++ b/packages/bruno-api-docs/src/sampleCollection.ts @@ -4,6 +4,7 @@ info: name: "Bruno Testbench" summary: "A comprehensive API collection for testing OpenCollection features" version: "1.0.0" + tags: ["testbench"] config: environments: - name: "Local" @@ -136,6 +137,7 @@ items: - name: "Live Updates" description: "Streams live order updates over a WebSocket connection." type: "websocket" + tags: ["realtime"] url: "{{host}}/ws/updates" docs: | # Websockets @@ -635,6 +637,7 @@ items: name: customers type: folder seq: 1 + tags: ["customers"] request: auth: inherit scripts: @@ -659,6 +662,7 @@ items: name: Get All Customers type: http seq: 1 + tags: ["smoke"] http: method: GET url: '{{baseUrl}}/billing/customers' @@ -1818,6 +1822,7 @@ items: name: Get All Subscriptions type: http seq: 1 + tags: ["billing"] http: method: GET url: '{{baseUrl}}/billing/subscriptions' diff --git a/packages/bruno-api-docs/src/ui/Dropdown/Dropdown.tsx b/packages/bruno-api-docs/src/ui/Dropdown/Dropdown.tsx index f152b6ff..35b14af9 100644 --- a/packages/bruno-api-docs/src/ui/Dropdown/Dropdown.tsx +++ b/packages/bruno-api-docs/src/ui/Dropdown/Dropdown.tsx @@ -72,6 +72,7 @@ export const Dropdown: React.FC = ({
      { }); }); +describe('getInheritedTags', () => { + it('collects ancestor folder tags the item does not carry itself', () => { + const ancestry = [ + item({ info: { name: 'billing', type: 'folder', tags: ['billing'] } }), + item({ info: { name: 'customers', type: 'folder', tags: ['customers', 'smoke'] } }) + ]; + expect(getInheritedTags(ancestry, ['smoke'])).toEqual(['billing', 'customers']); + }); + + it('returns an empty list for untagged ancestors', () => { + expect(getInheritedTags([item({ info: { name: 'f', type: 'folder' } })], ['auth'])).toEqual([]); + expect(getInheritedTags([], [])).toEqual([]); + }); +}); + describe('getCollectionTags', () => { it('reads tags from the collection info block', () => { expect(getCollectionTags(collection({ info: { name: 'Hotel Booking', tags: ['public'] } }))).toEqual(['public']); diff --git a/packages/bruno-api-docs/src/utils/schemaHelpers.ts b/packages/bruno-api-docs/src/utils/schemaHelpers.ts index 6a11b1cc..0635cb53 100644 --- a/packages/bruno-api-docs/src/utils/schemaHelpers.ts +++ b/packages/bruno-api-docs/src/utils/schemaHelpers.ts @@ -102,15 +102,27 @@ const normalizeTags = (raw: unknown): string[] => { */ export const getItemTags = (item: OpenCollectionItem | null | undefined): string[] => { if (!item) return []; - const info = 'info' in item ? (item as { info?: { tags?: unknown } }).info : undefined; + const info = 'info' in item ? (item as { info?: { tags?: string[] } }).info : undefined; if (info && Array.isArray(info.tags)) return normalizeTags(info.tags); - if ('tags' in item) return normalizeTags((item as { tags?: unknown }).tags); + if ('tags' in item) return normalizeTags((item as { tags?: string[] }).tags); return []; }; /** Get the collection-level tags from the info block. */ export const getCollectionTags = (collection: OpenCollection | null | undefined): string[] => - normalizeTags((collection as { info?: { tags?: unknown } } | null | undefined)?.info?.tags); + normalizeTags((collection as { info?: { tags?: string[] } } | null | undefined)?.info?.tags); + +/** Tags carried by ancestor folders that the item does not carry itself. */ +export const getInheritedTags = (ancestry: OpenCollectionItem[], ownTags: string[]): string[] => { + const own = new Set(ownTags); + const seen = new Set(); + for (const ancestor of ancestry) { + for (const tag of getItemTags(ancestor)) { + if (!own.has(tag)) seen.add(tag); + } + } + return [...seen]; +}; /** * Check if an item is a folder