Skip to content
Open
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 @@ -6,6 +6,7 @@ import { RequestAuthComponent } from './playground/auth.component';
import { MethodSelectorComponent } from './playground/method-selector.component';
import { PlaygroundVariableComponent } from './playground/playground-variable.component';
import { EnvSwitcherComponent } from './layout/env-switcher.component';
import { CodeSnippetComponent } from './request/code-snippet.component';
import type { DockMode } from '../../src/utils/playgroundDock';

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

readonly urlInput = this.page.getByTestId('query-bar-url');
readonly header = this.page.getByTestId('playground-header');
readonly switcher = this.page.getByTestId('playground-dock-switcher');
readonly content = this.page.getByTestId('playground-content');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export class CodeSnippetComponent extends BaseComponent {
readonly code: Locator;
readonly copyButton: Locator;
readonly expandButton: Locator;
readonly iconTrigger: Locator;
readonly modal: Locator;
readonly modalCode: Locator;

Expand All @@ -16,6 +17,7 @@ export class CodeSnippetComponent extends BaseComponent {
this.code = this.root.getByTestId(`${base}-code`);
this.copyButton = this.root.getByTestId(`${base}-code-copy`);
this.expandButton = this.root.getByTestId(`${base}-expand`);
this.iconTrigger = this.root.getByTestId(`${base}-trigger`);
this.modal = page.getByTestId(`${base}-modal`);
this.modalCode = this.modal.getByTestId(`${base}-code`);
}
Expand Down Expand Up @@ -46,6 +48,11 @@ export class CodeSnippetComponent extends BaseComponent {
await this.modal.waitFor({ state: 'visible' });
}

async openFromIcon(): Promise<void> {
await this.iconTrigger.click();
await this.modal.waitFor({ state: 'visible' });
}

async selectModalLanguage(language: string): Promise<void> {
await this.modalLanguageTab(language).click();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { test, expect } from '../../playwright';

const DESKTOP = { width: 1280, height: 900 };

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

test.beforeEach(async ({ playground }) => {
await playground.open('bottom');
});

test('the query bar offers an icon-only snippet control that opens the snippet modal', async ({ playground }) => {
await playground.openRequest('get users');

const { codeSnippet } = playground;
await expect(codeSnippet.iconTrigger).toBeVisible();
await expect(codeSnippet.iconTrigger).toHaveAttribute('aria-label', 'Generate Code');
// Icon only — the code box lives in the modal.
await expect(codeSnippet.code).toHaveCount(0);

await codeSnippet.openFromIcon();
await expect(codeSnippet.modalCode).toContainText('curl');
});

test('switches languages inside the modal', async ({ playground }) => {
await playground.openRequest('get users');
await playground.codeSnippet.openFromIcon();

await playground.codeSnippet.selectModalLanguage('python');
await expect(playground.codeSnippet.modalLanguageTab('python')).toHaveAttribute('aria-selected', 'true');
await expect(playground.codeSnippet.modalCode).toContainText('requests');
});

test('the snippet url substitutes filled path params and keeps unfilled placeholders', async ({
page,
playground
}) => {
await playground.openRequest('Jokes');
await playground.codeSnippet.openFromIcon();
await expect(playground.codeSnippet.modalCode).toContainText('/posts/1');
await page.keyboard.press('Escape');

// A fresh `:commentId` segment is a path param with no value yet.
await playground.urlInput.click();
await page.keyboard.press('End');
await page.keyboard.type('/:commentId');

await playground.codeSnippet.openFromIcon();
// Empty path params keep their placeholder instead of collapsing.
await expect(playground.codeSnippet.modalCode).toContainText('/posts/1/:commentId');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ interface CodeSnippetTabsProps {
headers?: HttpRequestHeader[];
body?: HttpRequestBody | HttpRequestBodyVariant[];
auth?: Auth;
variant?: 'inline' | 'embedded';
variant?: 'inline' | 'embedded' | 'icon';
className?: string;
testId?: string;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import type { HttpRequest } from '@opencollection/types/requests/http';
import type { HttpRequest, HttpRequestHeader } from '@opencollection/types/requests/http';
import type { OpenCollection as OpenCollectionCollection } from '@opencollection/types';
import type { Item } from '@opencollection/types/collection/item';
import type { Auth } from '@opencollection/types/common/auth';
import { requestRunner } from '@/runner';
import { getAncestorsByUuid } from '@/utils/fileUtils';
import { ItemVariableResolverProvider } from '@/hooks';
Expand All @@ -11,8 +12,8 @@ import RequestPane from './RequestPane/RequestPane';
import ResponsePane from './ResponsePane/ResponsePane';
import { useAppDispatch, useAppSelector } from '@/store/hooks';
import { updatePlaygroundItem, setPlaygroundResponse, selectPlaygroundResponse } from '@/store/slices/playground';
import { getItemName, isPlaygroundUnsupported } from '@/utils/schemaHelpers';
import { getInheritedAuthSummary } from '@/utils/request';
import { getItemName, isPlaygroundUnsupported, getRequestAuth, getRequestHeaders } from '@/utils/schemaHelpers';
import { getInheritedAuthSummary, resolveInheritedAuth, getInheritedHeaders } from '@/utils/request';
import UnsupportedRequest from '@/components/UnsupportedRequest/UnsupportedRequest';
import { FileNotFoundIcon } from '@/assets/icons';
import { useSplitPane } from '@/hooks/useSplitPane';
Expand Down Expand Up @@ -44,6 +45,27 @@ const HttpRequestPlaygroundView: React.FC<PlaygroundViewProps> = ({ item, collec
() => getInheritedAuthSummary(collection, ancestry, editableItem),
[collection, ancestry, editableItem]
);
// Resolve the auth so that the runner and the code snippet show the same effective auth.
const effectiveAuth = useMemo<Auth | undefined>(() => {
const ownAuth = getRequestAuth(editableItem) as Auth | undefined;
return ownAuth === 'inherit' ? resolveInheritedAuth(collection, ancestry, editableItem).auth : ownAuth;
}, [collection, ancestry, editableItem]);

// Applies same rules as runner so that the code snippet shows the same effective headers as the runner.
const effectiveHeaders = useMemo<HttpRequestHeader[]>(() => {
const auth = effectiveAuth && effectiveAuth !== 'inherit' ? effectiveAuth : undefined;
const authWritesAuthorization = Boolean(
(auth?.type === 'bearer' && auth.token) || (auth?.type === 'basic' && auth.username && auth.password)
);
const keep = (header: { name?: string }) =>
!authWritesAuthorization || (header.name || '').toLowerCase() !== 'authorization';
const ownRows = getRequestHeaders(editableItem).filter(keep);
const inheritedRows = getInheritedHeaders(collection, ancestry, editableItem)
.filter(keep)
.map((header) => ({ name: header.name, value: header.value ?? '', disabled: header.disabled }));
return [...ownRows, ...inheritedRows];
}, [collection, ancestry, editableItem, effectiveAuth]);

const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const pendingSaveRef = useRef<{ uuid: string; item: HttpRequest } | null>(null);

Expand Down Expand Up @@ -122,6 +144,8 @@ const HttpRequestPlaygroundView: React.FC<PlaygroundViewProps> = ({ item, collec
onSendRequest={handleSendRequest}
isLoading={isLoading}
onItemChange={handleItemChange}
effectiveAuth={effectiveAuth}
effectiveHeaders={effectiveHeaders}
/>

<div
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import React from 'react';
import { describe, it, expect } from 'vitest';
import type { HttpRequest } from '@opencollection/types/requests/http';
import { useRenderToDom } from '@/hooks/useRenderToDom';
import { queryByTestId } from '@/test-utils/dom';
import QueryBar from './QueryBar';

const item: HttpRequest = {
info: { name: 'Get Customer', type: 'http' },
http: {
method: 'get',
url: '{{baseUrl}}/billing/customers/:customerId',
headers: [{ name: 'Accept', value: 'application/json' }],
params: [{ name: 'customerId', value: '42', type: 'path' }]
}
} as HttpRequest;

const queryBar = <QueryBar item={item} onSendRequest={() => {}} isLoading={false} onItemChange={() => {}} />;

describe('Playground QueryBar — code snippet', () => {
it('offers the code-snippet control alongside the copy-url action', () => {
const root = useRenderToDom(queryBar);

expect(queryByTestId(root, 'query-bar-code-snippet-trigger')).not.toBeNull();
expect(queryByTestId(root, 'query-bar-copy-url')).not.toBeNull();
});
});
Original file line number Diff line number Diff line change
@@ -1,22 +1,33 @@
import React, { useState, useEffect } from 'react';
import type { HttpRequest } from '@opencollection/types/requests/http';
import type { HttpRequest, HttpRequestParam, HttpRequestHeader } from '@opencollection/types/requests/http';
import type { Auth } from '@opencollection/types/common/auth';
import { StyledWrapper } from './StyledWrapper';
import HighlightedInput from '@/components/HighlightedInput/HighlightedInput';
import { useResolvedVariables } from '@/hooks/useVariableResolver';
import { getHttpMethod, getRequestUrl, getHttpParams } from '@/utils/schemaHelpers';
import { syncPathParams, syncQueryParams } from '@/utils/pathParams';
import { getHttpMethod, getRequestUrl, getHttpParams, getRequestHeaders, getHttpBody } from '@/utils/schemaHelpers';
import { buildRequestUrl, syncPathParams, syncQueryParams } from '@/utils/pathParams';
import { HttpMethodSelector } from '@/components/HttpMethodSelector/HttpMethodSelector';
import { CopyButton } from '@/ui/CopyButton/CopyButton';
import { SendIcon } from '@/assets/icons';
import { CodeSnippetTabs } from '@/components/CodeSnippetTabs/CodeSnippetTabs';

interface QueryBarProps {
item: HttpRequest;
onSendRequest: () => void;
isLoading: boolean;
onItemChange: (item: HttpRequest) => void;
effectiveAuth?: Auth;
effectiveHeaders?: HttpRequestHeader[];
}

const QueryBar: React.FC<QueryBarProps> = ({ item, onSendRequest, isLoading, onItemChange }) => {
const QueryBar: React.FC<QueryBarProps> = ({
item,
onSendRequest,
isLoading,
onItemChange,
effectiveAuth,
effectiveHeaders
}) => {
const { isFound, names } = useResolvedVariables();
const [url, setUrl] = useState(getRequestUrl(item));
const [method, setMethod] = useState(getHttpMethod(item));
Expand Down Expand Up @@ -55,6 +66,11 @@ const QueryBar: React.FC<QueryBarProps> = ({ item, onSendRequest, isLoading, onI
onItemChange(updatedItem);
};

const snippetUrl = buildRequestUrl(
url,
getHttpParams(item).filter((param: HttpRequestParam) => param.type !== 'path' || (param.value ?? '').trim() !== '')
);

return (
<StyledWrapper>
<HttpMethodSelector method={method} onMethodChange={handleMethodChange} testId="method-select" />
Expand All @@ -74,6 +90,15 @@ const QueryBar: React.FC<QueryBarProps> = ({ item, onSendRequest, isLoading, onI
/>

<div className="actions">
<CodeSnippetTabs
method={method}
url={snippetUrl}
headers={effectiveHeaders ?? getRequestHeaders(item)}
body={getHttpBody(item)}
auth={effectiveAuth}
variant="icon"
testId="query-bar-code-snippet"
/>
<CopyButton text={url} label="Copy URL" copiedLabel="Copied" testId="query-bar-copy-url" />
<button
type="button"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,25 @@ describe('SnippetTabs', () => {
expect(queryByTestId(root, 'example-code-snippet-expand')).toBeNull();
});

it('collapses to an icon-only trigger when the variant is icon', () => {
const root = useRenderToDom(<SnippetTabs snippets={snippets} variant="icon" testId="query-bar-code-snippet" />);

const trigger = getByTestId(root, 'query-bar-code-snippet-trigger');
expect(trigger.classNames).toContain('snippet-icon-trigger');
// Icon only — no label, no inline code box.
expect(trigger.text.trim()).toBe('');
expect(queryByTestId(root, 'query-bar-code-snippet-code')).toBeNull();
expect(queryByTestId(root, 'query-bar-code-snippet-expand')).toBeNull();
});

it('labels the icon trigger for screen readers and marks it as opening a dialog', () => {
const root = useRenderToDom(<SnippetTabs snippets={snippets} variant="icon" testId="query-bar-code-snippet" />);

const trigger = getByTestId(root, 'query-bar-code-snippet-trigger');
expect(trigger.attributes['aria-label']).toBe('Generate Code');
expect(trigger.attributes['aria-haspopup']).toBe('dialog');
});

it('renders variables in the code as hover tokens', () => {
const root = useRenderToDom(<SnippetTabs snippets={snippets} />);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export interface Snippet {

interface SnippetTabsProps {
snippets: Snippet[];
variant?: 'inline' | 'embedded';
variant?: 'inline' | 'embedded' | 'icon';
className?: string;
testId?: string;
}
Expand Down Expand Up @@ -102,6 +102,18 @@ export const SnippetTabs: React.FC<SnippetTabsProps> = ({
<StyledWrapper className={cx('code-snippet-tabs', className)} data-testid={testId}>
{variant === 'inline' ? (
renderSnippetBox('inline', active, setActive)
) : variant === 'icon' ? (
<button
ref={triggerRef}
type="button"
className="snippet-icon-trigger"
aria-haspopup="dialog"
aria-label="Generate Code"
data-testid={`${testId}-trigger`}
onClick={openModal}
>
<IconCode size={16} stroke={1.5} />
</button>
) : (
<button
ref={triggerRef}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,4 +133,28 @@ export const StyledWrapper = styled.div`
.snippet-trigger:focus-visible {
outline: none;
}

.snippet-icon-trigger {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.3rem;
color: var(--text-tertiary);
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-icon-trigger:hover {
color: var(--text-secondary);
background-color: var(--badge-bg);
}
.snippet-icon-trigger:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: 1px;
}
`;
Loading