Skip to content

Commit 79eb05a

Browse files
committed
feat:add codesnippet to request url
1 parent dec50bb commit 79eb05a

10 files changed

Lines changed: 202 additions & 9 deletions

File tree

packages/bruno-api-docs/e2e/components/playground.component.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { RequestAuthComponent } from './playground/auth.component';
66
import { MethodSelectorComponent } from './playground/method-selector.component';
77
import { PlaygroundVariableComponent } from './playground/playground-variable.component';
88
import { EnvSwitcherComponent } from './layout/env-switcher.component';
9+
import { CodeSnippetComponent } from './request/code-snippet.component';
910
import type { DockMode } from '../../src/utils/playgroundDock';
1011

1112
export class PlaygroundComponent extends BaseComponent {
@@ -20,7 +21,9 @@ export class PlaygroundComponent extends BaseComponent {
2021
readonly testsEditor = new CodeEditorComponent(this.page, 'tests-editor');
2122
readonly variable = new PlaygroundVariableComponent(this.page);
2223
readonly envSwitcher = new EnvSwitcherComponent(this.page, 'playground-env-switcher');
24+
readonly codeSnippet = new CodeSnippetComponent(this.page, 'query-bar-code-snippet');
2325

26+
readonly urlInput = this.page.getByTestId('query-bar-url');
2427
readonly header = this.page.getByTestId('playground-header');
2528
readonly switcher = this.page.getByTestId('playground-dock-switcher');
2629
readonly content = this.page.getByTestId('playground-content');

packages/bruno-api-docs/e2e/components/request/code-snippet.component.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export class CodeSnippetComponent extends BaseComponent {
55
readonly code: Locator;
66
readonly copyButton: Locator;
77
readonly expandButton: Locator;
8+
readonly iconTrigger: Locator;
89
readonly modal: Locator;
910
readonly modalCode: Locator;
1011

@@ -16,6 +17,7 @@ export class CodeSnippetComponent extends BaseComponent {
1617
this.code = this.root.getByTestId(`${base}-code`);
1718
this.copyButton = this.root.getByTestId(`${base}-code-copy`);
1819
this.expandButton = this.root.getByTestId(`${base}-expand`);
20+
this.iconTrigger = this.root.getByTestId(`${base}-trigger`);
1921
this.modal = page.getByTestId(`${base}-modal`);
2022
this.modalCode = this.modal.getByTestId(`${base}-code`);
2123
}
@@ -46,6 +48,11 @@ export class CodeSnippetComponent extends BaseComponent {
4648
await this.modal.waitFor({ state: 'visible' });
4749
}
4850

51+
async openFromIcon(): Promise<void> {
52+
await this.iconTrigger.click();
53+
await this.modal.waitFor({ state: 'visible' });
54+
}
55+
4956
async selectModalLanguage(language: string): Promise<void> {
5057
await this.modalLanguageTab(language).click();
5158
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { test, expect } from '../../playwright';
2+
3+
const DESKTOP = { width: 1280, height: 900 };
4+
5+
test.describe('Playground query bar — code snippet', () => {
6+
test.use({ viewport: DESKTOP });
7+
8+
test.beforeEach(async ({ playground }) => {
9+
await playground.open('bottom');
10+
});
11+
12+
test('the query bar offers an icon-only snippet control that opens the snippet modal', async ({ playground }) => {
13+
await playground.openRequest('get users');
14+
15+
const { codeSnippet } = playground;
16+
await expect(codeSnippet.iconTrigger).toBeVisible();
17+
await expect(codeSnippet.iconTrigger).toHaveAttribute('aria-label', 'Generate Code');
18+
// Icon only — the code box lives in the modal.
19+
await expect(codeSnippet.code).toHaveCount(0);
20+
21+
await codeSnippet.openFromIcon();
22+
await expect(codeSnippet.modalCode).toContainText('curl');
23+
});
24+
25+
test('switches languages inside the modal', async ({ playground }) => {
26+
await playground.openRequest('get users');
27+
await playground.codeSnippet.openFromIcon();
28+
29+
await playground.codeSnippet.selectModalLanguage('python');
30+
await expect(playground.codeSnippet.modalLanguageTab('python')).toHaveAttribute('aria-selected', 'true');
31+
await expect(playground.codeSnippet.modalCode).toContainText('requests');
32+
});
33+
34+
test('the snippet url substitutes filled path params and keeps unfilled placeholders', async ({
35+
page,
36+
playground
37+
}) => {
38+
await playground.openRequest('Jokes');
39+
await playground.codeSnippet.openFromIcon();
40+
await expect(playground.codeSnippet.modalCode).toContainText('/posts/1');
41+
await page.keyboard.press('Escape');
42+
43+
// A fresh `:commentId` segment is a path param with no value yet.
44+
await playground.urlInput.click();
45+
await page.keyboard.press('End');
46+
await page.keyboard.type('/:commentId');
47+
48+
await playground.codeSnippet.openFromIcon();
49+
// Empty path params keep their placeholder instead of collapsing.
50+
await expect(playground.codeSnippet.modalCode).toContainText('/posts/1/:commentId');
51+
});
52+
});

packages/bruno-api-docs/src/components/CodeSnippetTabs/CodeSnippetTabs.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ interface CodeSnippetTabsProps {
1616
headers?: HttpRequestHeader[];
1717
body?: HttpRequestBody | HttpRequestBodyVariant[];
1818
auth?: Auth;
19-
variant?: 'inline' | 'embedded';
19+
variant?: 'inline' | 'embedded' | 'icon';
2020
className?: string;
2121
testId?: string;
2222
}

packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/PlaygroundView.tsx

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
2-
import type { HttpRequest } from '@opencollection/types/requests/http';
2+
import type { HttpRequest, HttpRequestHeader } from '@opencollection/types/requests/http';
33
import type { OpenCollection as OpenCollectionCollection } from '@opencollection/types';
44
import type { Item } from '@opencollection/types/collection/item';
5+
import type { Auth } from '@opencollection/types/common/auth';
56
import { requestRunner } from '@/runner';
67
import { getAncestorsByUuid } from '@/utils/fileUtils';
78
import { ItemVariableResolverProvider } from '@/hooks';
@@ -11,8 +12,8 @@ import RequestPane from './RequestPane/RequestPane';
1112
import ResponsePane from './ResponsePane/ResponsePane';
1213
import { useAppDispatch, useAppSelector } from '@/store/hooks';
1314
import { updatePlaygroundItem, setPlaygroundResponse, selectPlaygroundResponse } from '@/store/slices/playground';
14-
import { getItemName, isPlaygroundUnsupported } from '@/utils/schemaHelpers';
15-
import { getInheritedAuthSummary } from '@/utils/request';
15+
import { getItemName, isPlaygroundUnsupported, getRequestAuth, getRequestHeaders } from '@/utils/schemaHelpers';
16+
import { getInheritedAuthSummary, resolveInheritedAuth, getInheritedHeaders } from '@/utils/request';
1617
import UnsupportedRequest from '@/components/UnsupportedRequest/UnsupportedRequest';
1718
import { FileNotFoundIcon } from '@/assets/icons';
1819
import { useSplitPane } from '@/hooks/useSplitPane';
@@ -44,6 +45,27 @@ const HttpRequestPlaygroundView: React.FC<PlaygroundViewProps> = ({ item, collec
4445
() => getInheritedAuthSummary(collection, ancestry, editableItem),
4546
[collection, ancestry, editableItem]
4647
);
48+
// Resolve the auth so that the runner and the code snippet show the same effective auth.
49+
const effectiveAuth = useMemo<Auth | undefined>(() => {
50+
const ownAuth = getRequestAuth(editableItem) as Auth | undefined;
51+
return ownAuth === 'inherit' ? resolveInheritedAuth(collection, ancestry, editableItem).auth : ownAuth;
52+
}, [collection, ancestry, editableItem]);
53+
54+
// Applies same rules as runner so that the code snippet shows the same effective headers as the runner.
55+
const effectiveHeaders = useMemo<HttpRequestHeader[]>(() => {
56+
const auth = effectiveAuth && effectiveAuth !== 'inherit' ? effectiveAuth : undefined;
57+
const authWritesAuthorization = Boolean(
58+
(auth?.type === 'bearer' && auth.token) || (auth?.type === 'basic' && auth.username && auth.password)
59+
);
60+
const keep = (header: { name?: string }) =>
61+
!authWritesAuthorization || (header.name || '').toLowerCase() !== 'authorization';
62+
const ownRows = getRequestHeaders(editableItem).filter(keep);
63+
const inheritedRows = getInheritedHeaders(collection, ancestry, editableItem)
64+
.filter(keep)
65+
.map((header) => ({ name: header.name, value: header.value ?? '', disabled: header.disabled }));
66+
return [...ownRows, ...inheritedRows];
67+
}, [collection, ancestry, editableItem, effectiveAuth]);
68+
4769
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
4870
const pendingSaveRef = useRef<{ uuid: string; item: HttpRequest } | null>(null);
4971

@@ -122,6 +144,8 @@ const HttpRequestPlaygroundView: React.FC<PlaygroundViewProps> = ({ item, collec
122144
onSendRequest={handleSendRequest}
123145
isLoading={isLoading}
124146
onItemChange={handleItemChange}
147+
effectiveAuth={effectiveAuth}
148+
effectiveHeaders={effectiveHeaders}
125149
/>
126150

127151
<div
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import React from 'react';
2+
import { describe, it, expect } from 'vitest';
3+
import type { HttpRequest } from '@opencollection/types/requests/http';
4+
import { useRenderToDom } from '@/hooks/useRenderToDom';
5+
import { queryByTestId } from '@/test-utils/dom';
6+
import QueryBar from './QueryBar';
7+
8+
const item: HttpRequest = {
9+
info: { name: 'Get Customer', type: 'http' },
10+
http: {
11+
method: 'get',
12+
url: '{{baseUrl}}/billing/customers/:customerId',
13+
headers: [{ name: 'Accept', value: 'application/json' }],
14+
params: [{ name: 'customerId', value: '42', type: 'path' }]
15+
}
16+
} as HttpRequest;
17+
18+
const queryBar = <QueryBar item={item} onSendRequest={() => {}} isLoading={false} onItemChange={() => {}} />;
19+
20+
describe('Playground QueryBar — code snippet', () => {
21+
it('offers the code-snippet control alongside the copy-url action', () => {
22+
const root = useRenderToDom(queryBar);
23+
24+
expect(queryByTestId(root, 'query-bar-code-snippet-trigger')).not.toBeNull();
25+
expect(queryByTestId(root, 'query-bar-copy-url')).not.toBeNull();
26+
});
27+
});

packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/QueryBar/QueryBar.tsx

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,33 @@
11
import React, { useState, useEffect } from 'react';
2-
import type { HttpRequest } from '@opencollection/types/requests/http';
2+
import type { HttpRequest, HttpRequestParam, HttpRequestHeader } from '@opencollection/types/requests/http';
3+
import type { Auth } from '@opencollection/types/common/auth';
34
import { StyledWrapper } from './StyledWrapper';
45
import HighlightedInput from '@/components/HighlightedInput/HighlightedInput';
56
import { useResolvedVariables } from '@/hooks/useVariableResolver';
6-
import { getHttpMethod, getRequestUrl, getHttpParams } from '@/utils/schemaHelpers';
7-
import { syncPathParams, syncQueryParams } from '@/utils/pathParams';
7+
import { getHttpMethod, getRequestUrl, getHttpParams, getRequestHeaders, getHttpBody } from '@/utils/schemaHelpers';
8+
import { buildRequestUrl, syncPathParams, syncQueryParams } from '@/utils/pathParams';
89
import { HttpMethodSelector } from '@/components/HttpMethodSelector/HttpMethodSelector';
910
import { CopyButton } from '@/ui/CopyButton/CopyButton';
1011
import { SendIcon } from '@/assets/icons';
12+
import { CodeSnippetTabs } from '@/components/CodeSnippetTabs/CodeSnippetTabs';
1113

1214
interface QueryBarProps {
1315
item: HttpRequest;
1416
onSendRequest: () => void;
1517
isLoading: boolean;
1618
onItemChange: (item: HttpRequest) => void;
19+
effectiveAuth?: Auth;
20+
effectiveHeaders?: HttpRequestHeader[];
1721
}
1822

19-
const QueryBar: React.FC<QueryBarProps> = ({ item, onSendRequest, isLoading, onItemChange }) => {
23+
const QueryBar: React.FC<QueryBarProps> = ({
24+
item,
25+
onSendRequest,
26+
isLoading,
27+
onItemChange,
28+
effectiveAuth,
29+
effectiveHeaders
30+
}) => {
2031
const { isFound, names } = useResolvedVariables();
2132
const [url, setUrl] = useState(getRequestUrl(item));
2233
const [method, setMethod] = useState(getHttpMethod(item));
@@ -55,6 +66,11 @@ const QueryBar: React.FC<QueryBarProps> = ({ item, onSendRequest, isLoading, onI
5566
onItemChange(updatedItem);
5667
};
5768

69+
const snippetUrl = buildRequestUrl(
70+
url,
71+
getHttpParams(item).filter((param: HttpRequestParam) => param.type !== 'path' || (param.value ?? '').trim() !== '')
72+
);
73+
5874
return (
5975
<StyledWrapper>
6076
<HttpMethodSelector method={method} onMethodChange={handleMethodChange} testId="method-select" />
@@ -74,6 +90,15 @@ const QueryBar: React.FC<QueryBarProps> = ({ item, onSendRequest, isLoading, onI
7490
/>
7591

7692
<div className="actions">
93+
<CodeSnippetTabs
94+
method={method}
95+
url={snippetUrl}
96+
headers={effectiveHeaders ?? getRequestHeaders(item)}
97+
body={getHttpBody(item)}
98+
auth={effectiveAuth}
99+
variant="icon"
100+
testId="query-bar-code-snippet"
101+
/>
77102
<CopyButton text={url} label="Copy URL" copiedLabel="Copied" testId="query-bar-copy-url" />
78103
<button
79104
type="button"

packages/bruno-api-docs/src/components/SnippetTabs/SnippetTabs.spec.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,25 @@ describe('SnippetTabs', () => {
5757
expect(queryByTestId(root, 'example-code-snippet-expand')).toBeNull();
5858
});
5959

60+
it('collapses to an icon-only trigger when the variant is icon', () => {
61+
const root = useRenderToDom(<SnippetTabs snippets={snippets} variant="icon" testId="query-bar-code-snippet" />);
62+
63+
const trigger = getByTestId(root, 'query-bar-code-snippet-trigger');
64+
expect(trigger.classNames).toContain('snippet-icon-trigger');
65+
// Icon only — no label, no inline code box.
66+
expect(trigger.text.trim()).toBe('');
67+
expect(queryByTestId(root, 'query-bar-code-snippet-code')).toBeNull();
68+
expect(queryByTestId(root, 'query-bar-code-snippet-expand')).toBeNull();
69+
});
70+
71+
it('labels the icon trigger for screen readers and marks it as opening a dialog', () => {
72+
const root = useRenderToDom(<SnippetTabs snippets={snippets} variant="icon" testId="query-bar-code-snippet" />);
73+
74+
const trigger = getByTestId(root, 'query-bar-code-snippet-trigger');
75+
expect(trigger.attributes['aria-label']).toBe('Generate Code');
76+
expect(trigger.attributes['aria-haspopup']).toBe('dialog');
77+
});
78+
6079
it('renders variables in the code as hover tokens', () => {
6180
const root = useRenderToDom(<SnippetTabs snippets={snippets} />);
6281

packages/bruno-api-docs/src/components/SnippetTabs/SnippetTabs.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export interface Snippet {
1818

1919
interface SnippetTabsProps {
2020
snippets: Snippet[];
21-
variant?: 'inline' | 'embedded';
21+
variant?: 'inline' | 'embedded' | 'icon';
2222
className?: string;
2323
testId?: string;
2424
}
@@ -102,6 +102,18 @@ export const SnippetTabs: React.FC<SnippetTabsProps> = ({
102102
<StyledWrapper className={cx('code-snippet-tabs', className)} data-testid={testId}>
103103
{variant === 'inline' ? (
104104
renderSnippetBox('inline', active, setActive)
105+
) : variant === 'icon' ? (
106+
<button
107+
ref={triggerRef}
108+
type="button"
109+
className="snippet-icon-trigger"
110+
aria-haspopup="dialog"
111+
aria-label="Generate Code"
112+
data-testid={`${testId}-trigger`}
113+
onClick={openModal}
114+
>
115+
<IconCode size={16} stroke={1.5} />
116+
</button>
105117
) : (
106118
<button
107119
ref={triggerRef}

packages/bruno-api-docs/src/components/SnippetTabs/StyledWrapper.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,4 +133,28 @@ export const StyledWrapper = styled.div`
133133
.snippet-trigger:focus-visible {
134134
outline: none;
135135
}
136+
137+
.snippet-icon-trigger {
138+
flex: 0 0 auto;
139+
display: inline-flex;
140+
align-items: center;
141+
justify-content: center;
142+
padding: 0.3rem;
143+
color: var(--text-tertiary);
144+
background-color: var(--oc-bg);
145+
border: 1px solid var(--border-color);
146+
border-radius: var(--oc-radius);
147+
cursor: pointer;
148+
transition:
149+
color 0.15s ease,
150+
background-color 0.15s ease;
151+
}
152+
.snippet-icon-trigger:hover {
153+
color: var(--text-secondary);
154+
background-color: var(--badge-bg);
155+
}
156+
.snippet-icon-trigger:focus-visible {
157+
outline: 2px solid var(--primary-color);
158+
outline-offset: 1px;
159+
}
136160
`;

0 commit comments

Comments
 (0)