Skip to content

Commit d61a7da

Browse files
authored
feat: Add code snippets to example docs (#30)
* feat: Add Code Snippet to exaxmple docs * fix:reference and e2e fixes
1 parent cb1f42a commit d61a7da

8 files changed

Lines changed: 182 additions & 11 deletions

File tree

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,24 @@ export class ExamplesComponent extends BaseComponent {
2929
await this.example(name).getByTestId('example-toggle').click();
3030
}
3131

32+
// The snippet dialog is portalled to <body>, so it is scoped to the page, not the card.
33+
readonly snippetModal = this.page.getByRole('dialog', { name: 'Code snippet' });
34+
35+
readonly snippetCode = this.snippetModal.getByTestId('code-snippet-code');
36+
37+
snippetButton(name: string): Locator {
38+
return this.example(name).getByTestId('example-code-snippet-trigger');
39+
}
40+
41+
snippetLanguageTab(language: string): Locator {
42+
return this.snippetModal.getByTestId(`code-snippet-tab-${language}`);
43+
}
44+
45+
async openSnippet(name: string): Promise<void> {
46+
await this.snippetButton(name).click();
47+
await this.snippetModal.waitFor({ state: 'visible' });
48+
}
49+
3250
async selectRequestTab(name: string, tab: string): Promise<void> {
3351
await this.example(name).getByTestId(`example-request-pane-tab-${tab}`).click();
3452
}

packages/bruno-api-docs/e2e/tests/request/request-examples.spec.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,4 +84,69 @@ test.describe('Request page — Examples', () => {
8484
await examples.open(BAD_REQUEST_EXAMPLE);
8585
await expect(examples.responseBody(BAD_REQUEST_EXAMPLE)).toContainText('invalid_request');
8686
});
87+
88+
test.describe('Code snippet', () => {
89+
test('offers a Code Snippet trigger on an expanded example', async ({ requestPage }) => {
90+
const { examples } = requestPage;
91+
await expect(examples.snippetButton(OK_EXAMPLE)).toBeVisible();
92+
await expect(examples.snippetButton(OK_EXAMPLE)).toHaveText('Code Snippet');
93+
});
94+
95+
test('opens a dialog with every supported language', async ({ requestPage }) => {
96+
const { examples } = requestPage;
97+
await examples.openSnippet(OK_EXAMPLE);
98+
await expect(examples.snippetLanguageTab('curl')).toBeVisible();
99+
await expect(examples.snippetLanguageTab('javascript')).toBeVisible();
100+
await expect(examples.snippetLanguageTab('python')).toBeVisible();
101+
});
102+
103+
test('shows the example request, and switches language on demand', async ({ requestPage }) => {
104+
const { examples } = requestPage;
105+
await examples.openSnippet(OK_EXAMPLE);
106+
await expect(examples.snippetCode).toContainText('curl');
107+
await expect(examples.snippetCode).toContainText('per_page=10');
108+
await examples.snippetLanguageTab('python').click();
109+
await expect(examples.snippetCode).toContainText('requests');
110+
await expect(examples.snippetCode).toContainText('per_page=10');
111+
});
112+
113+
test('builds each snippet from its own example, not a shared one', async ({ requestPage }) => {
114+
const { examples } = requestPage;
115+
await examples.open(BAD_REQUEST_EXAMPLE);
116+
await examples.openSnippet(BAD_REQUEST_EXAMPLE);
117+
await expect(examples.snippetCode).toContainText('per_page=999');
118+
await expect(examples.snippetCode).not.toContainText('per_page=10');
119+
});
120+
121+
test('offers a copy action for the snippet', async ({ requestPage }) => {
122+
const { examples } = requestPage;
123+
await examples.openSnippet(OK_EXAMPLE);
124+
await expect(examples.snippetModal.getByTestId('copy-button')).toBeVisible();
125+
});
126+
127+
test('renders only one dialog — the embedded snippet brings no modal of its own', async ({ requestPage, page }) => {
128+
const { examples } = requestPage;
129+
await examples.openSnippet(OK_EXAMPLE);
130+
await expect(page.getByRole('dialog')).toHaveCount(1);
131+
await expect(examples.snippetModal.getByTestId('code-snippet-expand')).toHaveCount(0);
132+
});
133+
134+
test('dismisses on Escape and returns focus to the trigger', async ({ requestPage, page }) => {
135+
const { examples } = requestPage;
136+
await examples.openSnippet(OK_EXAMPLE);
137+
138+
await page.keyboard.press('Escape');
139+
await expect(examples.snippetModal).toBeHidden();
140+
await expect(examples.snippetButton(OK_EXAMPLE)).toBeFocused();
141+
});
142+
143+
test('dismisses on close-button click and returns focus to the trigger', async ({ requestPage }) => {
144+
const { examples } = requestPage;
145+
await examples.openSnippet(OK_EXAMPLE);
146+
147+
await examples.snippetModal.getByRole('button', { name: 'Close' }).click();
148+
await expect(examples.snippetModal).toBeHidden();
149+
await expect(examples.snippetButton(OK_EXAMPLE)).toBeFocused();
150+
});
151+
});
87152
});

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

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useMemo, useState } from 'react';
1+
import React, { useMemo, useRef, useState } from 'react';
22
import type { HttpRequestBody, HttpRequestBodyVariant, HttpRequestHeader } from '@opencollection/types/requests/http';
33
import type { Auth } from '@opencollection/types/common/auth';
44
import { Code } from '../Code/Code';
@@ -15,13 +15,16 @@ import {
1515
type SnippetInput
1616
} from '../../utils/codeSnippets';
1717
import { StyledWrapper } from './StyledWrapper';
18+
import { IconCode } from '@tabler/icons';
19+
import { cx } from '@/utils/cx';
1820

1921
interface CodeSnippetTabsProps {
2022
method: string;
2123
url: string;
2224
headers?: HttpRequestHeader[];
2325
body?: HttpRequestBody | HttpRequestBodyVariant[];
2426
auth?: Auth;
27+
variant?: 'inline' | 'embedded';
2528
className?: string;
2629
testId?: string;
2730
}
@@ -32,10 +35,11 @@ const LANGUAGES = [
3235
{ id: 'python', label: 'Python', language: 'python', generate: generatePythonCode }
3336
] as const;
3437

35-
export const CodeSnippetTabs: React.FC<CodeSnippetTabsProps> = ({ method, url, headers, body, auth, className, testId = 'request-code-snippet' }) => {
38+
export const CodeSnippetTabs: React.FC<CodeSnippetTabsProps> = ({ method, url, headers, body, auth, variant = 'inline', className, testId = 'request-code-snippet' }) => {
3639
const [active, setActive] = useState<string>(LANGUAGES[0].id);
3740
const [modalActive, setModalActive] = useState<string>(LANGUAGES[0].id);
3841
const [expanded, setExpanded] = useState(false);
42+
const triggerRef = useRef<HTMLButtonElement>(null);
3943
const { showVars, resolve } = useResolvedVariables();
4044

4145
const snippetHeaders: SnippetHeader[] = useMemo(
@@ -59,8 +63,13 @@ export const CodeSnippetTabs: React.FC<CodeSnippetTabsProps> = ({ method, url, h
5963
setExpanded(true);
6064
};
6165

66+
const closeModal = () => {
67+
setExpanded(false);
68+
triggerRef.current?.focus();
69+
};
70+
6271
const renderSnippetBox = (
63-
variant: 'inline' | 'modal',
72+
placement: 'inline' | 'modal',
6473
activeId: string,
6574
setActiveId: (id: string) => void
6675
) => {
@@ -86,8 +95,9 @@ export const CodeSnippetTabs: React.FC<CodeSnippetTabsProps> = ({ method, url, h
8695
))}
8796
</div>
8897
<span className="snippet-head-spacer" />
89-
{variant === 'inline' ? (
98+
{placement === 'inline' ? (
9099
<button
100+
ref={triggerRef}
91101
type="button"
92102
className="code-snippet-expand"
93103
aria-label="Expand code snippet"
@@ -100,17 +110,33 @@ export const CodeSnippetTabs: React.FC<CodeSnippetTabsProps> = ({ method, url, h
100110
<CopyButton text={copyText} label="Copy code" className="snippet-copy" />
101111
)}
102112
</div>
103-
<Code code={snippet} language={activeLang.language} showLineNumbers showCopy={variant === 'inline'} variableAware copyText={copyText} testId="code-snippet-code" />
113+
<Code code={snippet} language={activeLang.language} showLineNumbers showCopy={placement === 'inline'} variableAware copyText={copyText} testId="code-snippet-code" />
104114
</div>
105115
);
106116
};
107117

108118
return (
109-
<StyledWrapper className={['code-snippet-tabs', className].filter(Boolean).join(' ')} data-testid={testId}>
110-
{renderSnippetBox('inline', active, setActive)}
111-
<Modal open={expanded} onClose={() => setExpanded(false)} title={<SectionLabel>Code snippet</SectionLabel>} ariaLabel="Code snippet">
119+
<StyledWrapper className={cx('code-snippet-tabs', className)} data-testid={testId}>
120+
{variant === 'inline' ? (
121+
renderSnippetBox('inline', active, setActive)
122+
) : (
123+
<button
124+
ref={triggerRef}
125+
type="button"
126+
className="snippet-trigger"
127+
aria-haspopup="dialog"
128+
data-testid={`${testId}-trigger`}
129+
onClick={openModal}
130+
>
131+
<IconCode size={16} stroke={1.5} />
132+
Code Snippet
133+
</button>
134+
)}
135+
<Modal open={expanded} onClose={closeModal} title={<SectionLabel>Code snippet</SectionLabel>} ariaLabel="Code snippet">
112136
{expanded && (
113-
<StyledWrapper className="code-snippet-tabs" data-testid="code-snippet-modal">{renderSnippetBox('modal', modalActive, setModalActive)}</StyledWrapper>
137+
<StyledWrapper className={cx('code-snippet-tabs', className)} data-testid="code-snippet-modal">
138+
{renderSnippetBox('modal', modalActive, setModalActive)}
139+
</StyledWrapper>
114140
)}
115141
</Modal>
116142
</StyledWrapper>

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

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,4 +89,33 @@ export const StyledWrapper = styled.div`
8989
align-self: center;
9090
flex: 0 0 auto;
9191
}
92+
93+
.snippet-trigger {
94+
flex: 0 0 auto;
95+
display: inline-flex;
96+
align-items: center;
97+
gap: 0.375rem;
98+
padding: 0.3rem 0.5rem;
99+
font-family: var(--font-sans);
100+
font-size: 0.6875rem;
101+
font-weight: 500;
102+
line-height: 1;
103+
white-space: nowrap;
104+
color: var(--text-tertiary);
105+
background-color: var(--oc-bg);
106+
border: 1px solid var(--border-color);
107+
border-radius: var(--oc-radius);
108+
cursor: pointer;
109+
transition:
110+
color 0.15s ease,
111+
background-color 0.15s ease;
112+
}
113+
.snippet-trigger:hover {
114+
color: var(--text-secondary);
115+
background-color: var(--badge-bg);
116+
}
117+
118+
.snippet-trigger:focus-visible {
119+
outline: none;
120+
}
92121
`;

packages/bruno-api-docs/src/components/Examples/ExampleCard/ExampleCard.spec.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,4 +237,21 @@ describe('ExampleCard', () => {
237237
expect(html).toContain('orgId');
238238
expect(html).toContain('alice');
239239
});
240+
241+
describe('code snippet', () => {
242+
it('offers a labelled Code Snippet trigger when expanded, with the dialog still closed', () => {
243+
const html = renderToStaticMarkup(
244+
<ExampleCard example={example} method="post" url="{{baseUrl}}/auth/login" defaultExpanded />
245+
);
246+
expect(html).toContain('data-testid="example-code-snippet-trigger"');
247+
expect(html).toContain('Code Snippet');
248+
expect(html).toContain('aria-haspopup="dialog"');
249+
expect(html).not.toContain('curl');
250+
});
251+
252+
it('does not render the trigger while the card is collapsed', () => {
253+
const html = renderToStaticMarkup(<ExampleCard example={example} method="post" url="/x" />);
254+
expect(html).not.toContain('data-testid="example-code-snippet-trigger"');
255+
});
256+
});
240257
});

packages/bruno-api-docs/src/components/Examples/ExampleCard/ExampleCard.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,14 @@ import { getBodyView, getDescription, headerRows } from '../../../utils/request'
1515
import { computeBodySize, formatBytes, responseBodyLanguage, responseBodyContentType, statusCodePhrase } from '../../../utils/exampleResponse';
1616
import { statusToneColor } from '../../../utils/common';
1717
import { StyledWrapper } from './StyledWrapper';
18+
import type { Auth } from '@opencollection/types/common/auth';
19+
import { CodeSnippetTabs } from '../../CodeSnippetTabs/CodeSnippetTabs';
1820

1921
interface ExampleCardProps {
2022
example: HttpRequestExample;
2123
method: string;
2224
url: string;
25+
auth?: Auth;
2326
defaultExpanded?: boolean;
2427
active?: boolean;
2528
testId?: string;
@@ -163,7 +166,7 @@ const Pane: React.FC<{
163166
);
164167
};
165168

166-
export const ExampleCard: React.FC<ExampleCardProps> = ({ example, method, url, defaultExpanded, active, testId = 'example-card' }) => {
169+
export const ExampleCard: React.FC<ExampleCardProps> = ({ example, method, url, auth, defaultExpanded, active, testId = 'example-card' }) => {
167170
const [expanded, setExpanded] = useState(Boolean(defaultExpanded));
168171
const [mounted, setMounted] = useState(Boolean(defaultExpanded));
169172
const detailId = useId();
@@ -324,6 +327,15 @@ export const ExampleCard: React.FC<ExampleCardProps> = ({ example, method, url,
324327
<TruncatedText className="example-url-text" text={displayUrl}>
325328
<VariableText value={displayUrl} />
326329
</TruncatedText>
330+
<CodeSnippetTabs
331+
variant="embedded"
332+
method={displayMethod}
333+
url={displayUrl}
334+
headers={request.headers}
335+
body={request.body}
336+
auth={auth}
337+
testId="example-code-snippet"
338+
/>
327339
<CopyButton text={displayUrl} />
328340
</div>
329341

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,19 @@ import React from 'react';
22
import type { HttpRequestExample } from '@opencollection/types/requests/http';
33
import { ExampleCard } from './ExampleCard/ExampleCard';
44
import { StyledWrapper } from './StyledWrapper';
5+
import type { Auth } from '@opencollection/types/common/auth';
56

67
interface ExamplesProps {
78
examples?: HttpRequestExample[];
89
method: string;
910
url: string;
11+
auth?: Auth;
1012
highlightedIndex?: number;
1113
className?: string;
1214
testId?: string;
1315
}
1416

15-
export const Examples: React.FC<ExamplesProps> = ({ examples, method, url, highlightedIndex, className, testId = 'request-examples' }) => {
17+
export const Examples: React.FC<ExamplesProps> = ({ examples, method, url, auth, highlightedIndex, className, testId = 'request-examples' }) => {
1618
if (!examples || examples.length === 0) return null;
1719

1820
// A highlight that no longer resolves (out of range) falls back to the
@@ -28,6 +30,7 @@ export const Examples: React.FC<ExamplesProps> = ({ examples, method, url, highl
2830
example={example}
2931
method={method}
3032
url={url}
33+
auth={auth}
3134
defaultExpanded={validHighlight ? highlightedIndex === index : index === 0}
3235
active={validHighlight && highlightedIndex === index}
3336
/>

packages/bruno-api-docs/src/pages/Request/Request.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,7 @@ const RequestContent: React.FC<RequestContentProps> = ({
256256
examples={examples}
257257
method={method}
258258
url={url}
259+
auth={effectiveAuth}
259260
highlightedIndex={highlightedExampleIndex}
260261
/>
261262
</Section>

0 commit comments

Comments
 (0)