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..baa2a1e9 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,16 @@ 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' }); + readonly tagFilter = this.root.getByTestId('search-tag-filter'); + readonly tagButton = this.root.getByTestId('search-tag-filter-button'); + readonly tagMenu = this.root.getByTestId('search-tag-filter-menu'); 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/pages/request.page.ts b/packages/bruno-api-docs/e2e/pages/request.page.ts index e28cda63..355112b0 100644 --- a/packages/bruno-api-docs/e2e/pages/request.page.ts +++ b/packages/bruno-api-docs/e2e/pages/request.page.ts @@ -12,6 +12,8 @@ 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 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 6ebb9ef1..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 @@ -102,3 +102,44 @@ 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); + }); + + 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 a9555c73..b7e6b43f 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,129 @@ 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('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 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'); + 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..d4c5ec10 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,8 @@ import { getByTestId, queryByTestId } from '@/test-utils/dom'; 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 0e1cad01..c6bdc465 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,8 @@ export const RequestPageLayout: React.FC = ({ }) => { const { name, + tags, + inheritedTags, url, descHtml, pathParams, @@ -165,6 +168,21 @@ export const RequestPageLayout: React.FC = ({ auth={effectiveAuth} /> + {(tags.length > 0 || inheritedTags.length > 0) && ( +
0 ? ( + + ) : undefined + } + > + +
+ )} 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..f46d140b 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,15 +13,15 @@ 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 { @@ -43,12 +43,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 +52,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 +70,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 +82,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 +118,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 +133,7 @@ export const SearchBar: React.FC = ({ open, onOpenChange, focusN const clearFilters = useCallback(() => { setMethods(new Set()); - setFolder(null); + setSelectedTags(new Set()); refocusInput(); }, [refocusInput]); @@ -151,7 +149,7 @@ 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] ); @@ -184,7 +182,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 +232,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..024204cd 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, @@ -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,29 +24,28 @@ 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 }; }; -/** 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 }, ...(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 }; }; @@ -74,7 +73,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 +82,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 +96,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', () => { @@ -148,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 @@ -183,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' @@ -196,13 +201,55 @@ 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', 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', 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', 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,20 +269,18 @@ 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. */ 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' }), @@ -294,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'); }); @@ -324,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); }); }); @@ -377,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(); }); @@ -410,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 6750eb60..93df79f3 100644 --- a/packages/bruno-api-docs/src/components/Search/searchIndex.ts +++ b/packages/bruno-api-docs/src/components/Search/searchIndex.ts @@ -1,34 +1,17 @@ -/** - * 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'; 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'; 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[]; - /** Ancestor folder slugs, for the folder filter chip. */ - ancestorSlugs: string[]; + tags: string[]; } export interface RequestSearchRecord extends SearchRecordBase { @@ -44,27 +27,35 @@ 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 = ' / '; +// 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(); + 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 + 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); + } 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') { @@ -73,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, @@ -88,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; } @@ -107,24 +96,20 @@ 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 })); +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']; -/** - * 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); } @@ -138,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; @@ -185,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[] => { @@ -207,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 new file mode 100644 index 00000000..595b37ef --- /dev/null +++ b/packages/bruno-api-docs/src/components/Tags/StyledWrapper.ts @@ -0,0 +1,37 @@ +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: 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); + font-family: var(--font-sans); + font-size: 0.875rem; + 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; + 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..2de2be30 --- /dev/null +++ b/packages/bruno-api-docs/src/components/Tags/Tags.spec.tsx @@ -0,0 +1,35 @@ +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', () => { + const root = useRenderToDom(); + expect(queryByTestId(root, 'tags')).toBeNull(); + }); + + it('renders one chip per tag with the derived test id', () => { + 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 new file mode 100644 index 00000000..5fc5291b --- /dev/null +++ b/packages/bruno-api-docs/src/components/Tags/Tags.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { TagIcon } from '@/assets/icons'; +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, inheritedTags = [], className, testId = 'tags' }) => { + if (tags.length === 0 && inheritedTags.length === 0) return null; + + return ( + + {tags.map((tag) => ( + + + {tag} + + ))} + {inheritedTags.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..aaf48c11 100644 --- a/packages/bruno-api-docs/src/hooks/useRequestPageData.ts +++ b/packages/bruno-api-docs/src/hooks/useRequestPageData.ts @@ -12,6 +12,8 @@ import { getRequestAuth, getItemDocs, getItemDescription, + getItemTags, + getInheritedTags, type SupportedRequestItem } from '@/utils/schemaHelpers'; import { @@ -38,6 +40,8 @@ export const useRequestPageData = ( const md = useMarkdownRenderer(); const name = getItemName(item) || 'Untitled Request'; + 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); @@ -102,6 +106,8 @@ export const useRequestPageData = ( return { name, + tags, + inheritedTags, 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.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 b1c242b6..cd1584c2 100644 --- a/packages/bruno-api-docs/src/pages/GrpcRequest/GrpcRequest.tsx +++ b/packages/bruno-api-docs/src/pages/GrpcRequest/GrpcRequest.tsx @@ -14,6 +14,8 @@ import { getGrpcMessages, getGrpcProtoFileName, getGrpcProtoFilePath, + getItemTags, + getInheritedTags, countEnabled } from '@/utils/schemaHelpers'; import { @@ -21,13 +23,15 @@ import { getPreRequestVars, getPostResponseVars, buildScriptChain, - getScriptFlow + getScriptFlow, + inheritedCountLabel } from '@/utils/request'; import { collectAssertions } from '@/utils/assertions'; 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 +73,8 @@ export const GrpcRequest: React.FC = ({ testId = 'grpc-request-page' }) => { const name = getItemName(item) || 'Untitled Request'; + const tags = useMemo(() => getItemTags(item), [item]); + const inheritedTags = useMemo(() => getInheritedTags(ancestry, tags), [ancestry, tags]); const url = getRequestUrl(item); const method = getGrpcMethod(item); @@ -127,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(() => { @@ -180,9 +198,10 @@ export const GrpcRequest: React.FC = ({ )} - {hasLeftColumn ? ( + {hasLeftColumn || hasRightColumn ? (
    + {!hasLeftColumn && configEmptyState} {protoFileName && (
    = ({ )}
    - {snippets.length > 0 && ( + {hasRightColumn && (
    -
    - -
    + {snippets.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 )}
    = ({ 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/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/pages/Request/Request.spec.tsx b/packages/bruno-api-docs/src/pages/Request/Request.spec.tsx index d6f9a9b0..808680a6 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,42 @@ 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 with untagged ancestors', () => { + const root = useRenderToDom( + + {}} /> + + ); + 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 7a484f16..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 @@ -161,6 +163,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 +230,7 @@ items: - info: name: "Order Service" type: "grpc" + tags: ["orders"] grpc: url: "{{grpcUrl}}" method: "/orders.OrderService/GetOrder" @@ -598,6 +602,7 @@ items: name: billing type: folder seq: 4 + tags: ["billing"] docs: | ## Billing @@ -632,6 +637,7 @@ items: name: customers type: folder seq: 1 + tags: ["customers"] request: auth: inherit scripts: @@ -656,6 +662,7 @@ items: name: Get All Customers type: http seq: 1 + tags: ["smoke"] http: method: GET url: '{{baseUrl}}/billing/customers' @@ -1815,6 +1822,7 @@ items: name: Get All Subscriptions type: http seq: 1 + tags: ["billing"] http: method: GET url: '{{baseUrl}}/billing/subscriptions' @@ -2065,6 +2073,7 @@ items: - name: "echo json" type: "http" seq: 2 + tags: ["echo", "smoke"] method: "POST" url: "{{host}}/api/echo/json" headers: @@ -2226,6 +2235,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 +2307,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/Dropdown.tsx b/packages/bruno-api-docs/src/ui/Dropdown/Dropdown.tsx index af720fb7..35b14af9 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/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..f02a642f 100644 --- a/packages/bruno-api-docs/src/utils/schemaHelpers.spec.ts +++ b/packages/bruno-api-docs/src/utils/schemaHelpers.spec.ts @@ -17,6 +17,9 @@ import { getGrpcMethodType, getGrpcMetadata, getGrpcProtoFileName, + getItemTags, + getCollectionTags, + getInheritedTags, type RequestItem } from './schemaHelpers'; @@ -24,6 +27,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 +304,59 @@ 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']); + }); + + 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('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']); + }); + + 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..0635cb53 100644 --- a/packages/bruno-api-docs/src/utils/schemaHelpers.ts +++ b/packages/bruno-api-docs/src/utils/schemaHelpers.ts @@ -85,6 +85,45 @@ export const getItemSeq = (item: OpenCollectionItem | null | undefined): number return undefined; }; +const normalizeTags = (raw: unknown): string[] => { + if (!Array.isArray(raw)) return []; + 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]; +}; + +/** + * 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?: string[] } }).info : undefined; + if (info && Array.isArray(info.tags)) return normalizeTags(info.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?: 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 */