= ({ open, onOpenChange, focusN
<>
-
+
{hasFilter && (
+ {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
= ({ label, active = false, menuL
{open && (
-