Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export class CodeSnippetComponent extends BaseComponent {
readonly iconTrigger: Locator;
readonly modal: Locator;
readonly modalCode: Locator;
readonly modalInterpolate: Locator;

constructor(
page: Page,
Expand All @@ -20,6 +21,7 @@ export class CodeSnippetComponent extends BaseComponent {
this.iconTrigger = this.root.getByTestId(`${base}-trigger`);
this.modal = page.getByTestId(`${base}-modal`);
this.modalCode = this.modal.getByTestId(`${base}-code`);
this.modalInterpolate = this.modal.getByTestId(`${base}-interpolate-input`);
}

variableToken(name: string): Locator {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import type { Page } from '@playwright/test';
import { test, expect } from '../../playwright';
import type { PlaygroundComponent } from '../../components/playground.component';

const DESKTOP = { width: 1280, height: 900 };
const VARS_PLAYGROUND = '/?fixture=vars#/?pg=1&dock=bottom';

test.describe('Playground query bar — code snippet', () => {
test.use({ viewport: DESKTOP });
Expand Down Expand Up @@ -50,3 +53,55 @@ test.describe('Playground query bar — code snippet', () => {
await expect(playground.codeSnippet.modalCode).toContainText('/posts/1/:commentId');
});
});

test.describe('Playground code snippet — Interpolate Variables', () => {
test.use({ viewport: DESKTOP });

const openVarsPlayground = async (page: Page, playground: PlaygroundComponent) => {
await page.goto(VARS_PLAYGROUND);
await playground.runner.waitFor({ state: 'visible' });
await playground.openTreeItem(['Customers', 'Variables Demo']);
await playground.envSwitcher.selectEnvironment('Dev');
};

test('the switch resolves variables without closing the playground', async ({ page, playground }) => {
await openVarsPlayground(page, playground);

const { codeSnippet } = playground;
await codeSnippet.openFromIcon();
// Starts on, as the app's Generate Code does.
await expect(codeSnippet.modalInterpolate).toBeChecked();
await expect(codeSnippet.modalCode).toContainText('https://api.dev.example.com/customers/req-42');

await codeSnippet.modalInterpolate.setChecked(false);
await expect(codeSnippet.modalCode).toContainText('{{host}}');
});

test('an interpolated playground snippet substitutes secrets, as the app does', async ({ page, playground }) => {
await openVarsPlayground(page, playground);

const { codeSnippet } = playground;
await codeSnippet.openFromIcon();
await expect(codeSnippet.modalCode).toContainText('Bearer super-secret-token');

await codeSnippet.modalInterpolate.setChecked(false);
await expect(codeSnippet.modalCode).toContainText('Bearer {{bearer_token}}');
});

test('the docs Show vars toggle moves nothing in the playground snippet', async ({ page, playground }) => {
await openVarsPlayground(page, playground);

const { codeSnippet } = playground;
await codeSnippet.openFromIcon();
await codeSnippet.modalInterpolate.setChecked(false);
await expect(codeSnippet.modalCode).toContainText('{{host}}');

// Flip the app-wide toggle behind the dock: the snippet must not react.
await page.keyboard.press('Escape');
await playground.envSwitcher.toggle();
await codeSnippet.openFromIcon();
await expect(codeSnippet.modalInterpolate).not.toBeChecked();
await expect(codeSnippet.modalCode).toContainText('{{host}}');
await expect(codeSnippet.modalCode).not.toContainText('https://api.dev.example.com');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ interface CodeSnippetTabsProps {
body?: HttpRequestBody | HttpRequestBodyVariant[];
auth?: Auth;
variant?: 'inline' | 'embedded' | 'icon';
interpolation?: 'showVars' | 'switch';
className?: string;
testId?: string;
}
Expand All @@ -34,6 +35,7 @@ export const CodeSnippetTabs: React.FC<CodeSnippetTabsProps> = ({
body,
auth,
variant = 'inline',
interpolation,
className,
testId
}) => {
Expand All @@ -55,7 +57,15 @@ export const CodeSnippetTabs: React.FC<CodeSnippetTabsProps> = ({
}));
}, [method, url, snippetHeaders, body, auth]);

return <SnippetTabs snippets={snippets} variant={variant} className={className} testId={testId} />;
return (
<SnippetTabs
snippets={snippets}
variant={variant}
interpolation={interpolation}
className={className}
testId={testId}
/>
);
};

export default CodeSnippetTabs;
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ const QueryBar: React.FC<QueryBarProps> = ({
body={getHttpBody(item)}
auth={effectiveAuth}
variant="icon"
interpolation="switch"
testId="query-bar-code-snippet"
/>
<CopyButton text={url} label="Copy URL" copiedLabel="Copied" testId="query-bar-copy-url" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,14 @@ describe('SnippetTabs', () => {
expect(code.text).toContain('{{host}}');
});

it('keeps the Interpolate Variables control out of the inline box', () => {
const showVars = useRenderToDom(<SnippetTabs snippets={snippets} />);
expect(queryByTestId(showVars, 'request-code-snippet-interpolate')).toBeNull();

const ownSwitch = useRenderToDom(<SnippetTabs snippets={snippets} interpolation="switch" />);
expect(queryByTestId(ownSwitch, 'request-code-snippet-interpolate')).toBeNull();
});

it('passes the snippet language through to the highlighter', () => {
const root = useRenderToDom(
<SnippetTabs snippets={[{ id: 'json', label: 'JSON', language: 'json', code: '{"a":1}' }]} />
Expand Down
32 changes: 26 additions & 6 deletions packages/bruno-api-docs/src/components/SnippetTabs/SnippetTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { IconCode } from '@tabler/icons';
import cx from '@/utils/cx';
import { Code } from '../Code/Code';
import { CopyButton } from '@/ui/CopyButton/CopyButton';
import { useResolvedVariables } from '@/hooks';
import Checkbox from '@/ui/Checkbox/Checkbox';
import { ShowVarsOverrideProvider, useResolvedVariables } from '@/hooks';
import { SectionLabel } from '../SectionLabel/SectionLabel';
import { Modal } from '@/ui/Modal/Modal';
import { ExpandIcon } from '@/assets/icons';
Expand All @@ -19,26 +20,31 @@ export interface Snippet {
interface SnippetTabsProps {
snippets: Snippet[];
variant?: 'inline' | 'embedded' | 'icon';
interpolation?: 'showVars' | 'switch';
className?: string;
testId?: string;
}

export const SnippetTabs: React.FC<SnippetTabsProps> = ({
snippets,
variant = 'inline',
interpolation = 'showVars',
className,
testId = 'request-code-snippet'
}) => {
const [active, setActive] = useState<string>(snippets[0]?.id ?? '');
const [activeModalId, setActiveModalId] = useState<string>(snippets[0]?.id ?? '');
const [expanded, setExpanded] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
const { showVars, resolve } = useResolvedVariables();
const { showVars, resolve, interpolate } = useResolvedVariables();
const ownSwitch = interpolation === 'switch';
const [shouldInterpolate, setShouldInterpolate] = useState(true);

if (snippets.length === 0) return null;

const openModal = () => {
setActiveModalId(active);
if (!ownSwitch) setShouldInterpolate(showVars);
setExpanded(true);
};

Expand All @@ -50,7 +56,8 @@ export const SnippetTabs: React.FC<SnippetTabsProps> = ({
const renderSnippetBox = (placement: 'inline' | 'modal', activeId: string, setActiveId: (id: string) => void) => {
const activeSnippet = snippets.find((snippet) => snippet.id === activeId) ?? snippets[0];
const code = activeSnippet.code;
const copyText = showVars ? resolve(code) : code;
const isModal = placement === 'modal';
const copyText = isModal ? (shouldInterpolate ? interpolate(code) : code) : resolve(code);
return (
<div className="snippet-box">
<div className="snippet-head">
Expand All @@ -70,6 +77,17 @@ export const SnippetTabs: React.FC<SnippetTabsProps> = ({
))}
</div>
<span className="snippet-head-spacer" />
{isModal && (
<label className="snippet-interpolate">
<Checkbox
checked={shouldInterpolate}
ariaLabel="Interpolate Variables"
testId={`${testId}-interpolate`}
onChange={(event) => setShouldInterpolate(event.target.checked)}
/>
<span>Interpolate Variables</span>
</label>
)}
{placement === 'inline' ? (
<button
ref={triggerRef}
Expand Down Expand Up @@ -123,9 +141,11 @@ export const SnippetTabs: React.FC<SnippetTabsProps> = ({
ariaLabel="Code snippet"
>
{expanded && (
<StyledWrapper className="code-snippet-tabs is-modal" data-testid={`${testId}-modal`}>
{renderSnippetBox('modal', activeModalId, setActiveModalId)}
</StyledWrapper>
<ShowVarsOverrideProvider showVars={shouldInterpolate}>
<StyledWrapper className="code-snippet-tabs is-modal" data-testid={`${testId}-modal`}>
{renderSnippetBox('modal', activeModalId, setActiveModalId)}
</StyledWrapper>
</ShowVarsOverrideProvider>
)}
</Modal>
</StyledWrapper>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,33 @@ export const StyledWrapper = styled.div`
flex: 0 0 auto;
}

.snippet-interpolate {
align-self: center;
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: 0.375rem;
margin-right: 0.5rem;
padding: 0.3rem 0.5rem;
font-family: var(--font-sans);
font-size: 0.75rem;
font-weight: 500;
line-height: 1;
white-space: nowrap;
color: var(--text-secondary);
background-color: var(--oc-bg);
border: 1px solid var(--border-color);
border-radius: var(--oc-radius);
cursor: pointer;
transition:
color 0.15s ease,
background-color 0.15s ease;
}
.snippet-interpolate:hover {
color: var(--text-primary);
background-color: var(--badge-bg);
}

.snippet-trigger {
flex: 0 0 auto;
display: inline-flex;
Expand Down
1 change: 1 addition & 0 deletions packages/bruno-api-docs/src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export {
useResolvedVariables,
VariableResolverProvider,
ItemVariableResolverProvider,
ShowVarsOverrideProvider,
type VariableResolver,
type VariableLookup
} from './useVariableResolver';
Expand Down
37 changes: 34 additions & 3 deletions packages/bruno-api-docs/src/hooks/useVariableResolver.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export interface VariableResolver {
showVars: boolean;
activeEnvName: string | null;
resolve: (raw: string) => string;
interpolate: (raw: string) => string;
isSecret: (name: string) => boolean;
secretRefName: (raw: string) => string | null;
lookup: (name: string) => VariableLookup;
Expand Down Expand Up @@ -94,13 +95,15 @@ const makeResolver = (
activeEnvName: string | null
): VariableResolver => {
const isSecret = (name: string) => model.secretNames.has(name.trim());
const interpolate = (raw: string) => resolveVariables(raw, model.values);
return {
showVars,
activeEnvName,
isSecret,
isFound: (name: string) => Object.prototype.hasOwnProperty.call(model.entries, name),
names: Object.keys(model.entries),
resolve: (raw: string) => (showVars ? resolveVariables(raw, model.values) : raw),
interpolate,
resolve: (raw: string) => (showVars ? interpolate(raw) : raw),
secretRefName: (raw: string) => {
const name = singleReferenceName(raw);
return name && isSecret(name) ? name : null;
Expand Down Expand Up @@ -164,6 +167,7 @@ const PASSTHROUGH_RESOLVER: VariableResolver = {
showVars: false,
activeEnvName: null,
resolve: (raw) => raw,
interpolate: (raw) => raw,
isSecret: () => false,
secretRefName: () => null,
lookup: (name) => ({
Expand Down Expand Up @@ -196,6 +200,23 @@ export const VariableResolverProvider: React.FC<{ children: React.ReactNode }> =
<VariableResolverContext.Provider value={useVariableResolver()}>{children}</VariableResolverContext.Provider>
);

export const ShowVarsOverrideProvider: React.FC<{
showVars: boolean;
children: React.ReactNode;
}> = ({ showVars, children }) => {
const resolver = useResolvedVariables();
const value = useMemo(
() => ({
...resolver,
showVars,
resolve: (raw: string) => (showVars ? resolver.interpolate(raw) : raw)
}),
[resolver, showVars]
);

return <VariableResolverContext.Provider value={value}>{children}</VariableResolverContext.Provider>;
};

export const ItemVariableResolverProvider: React.FC<{
collection: OpenCollection | null;
ancestry: Item[];
Expand Down Expand Up @@ -241,9 +262,19 @@ export const ItemVariableResolverProvider: React.FC<{
[resolver, dispatch, activeEnvName, item, ancestry]
);

const interpolateWithSecrets = useCallback(
(raw: string) => resolveVariables(raw, model.fullValues),
[model]
);

const value = useMemo(
() => ({ ...resolver, canWrite: writable, updateVariable }),
[resolver, writable, updateVariable]
() => ({
...resolver,
canWrite: writable,
updateVariable,
...(writable ? { interpolate: interpolateWithSecrets } : {})
}),
[resolver, writable, updateVariable, interpolateWithSecrets]
);

return <VariableResolverContext.Provider value={value}>{children}</VariableResolverContext.Provider>;
Expand Down
Loading