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 @@ -10,9 +11,14 @@ import QueryBar from './QueryBar/QueryBar';
import RequestPane from './RequestPane/RequestPane';
import ResponsePane from './ResponsePane/ResponsePane';
import { useAppDispatch, useAppSelector } from '@/store/hooks';
import { updatePlaygroundItem, setPlaygroundResponse, selectPlaygroundResponse, applyScriptVariableChanges } from '@/store/slices/playground';
import { getItemName, isPlaygroundUnsupported } from '@/utils/schemaHelpers';
import { getInheritedAuthSummary } from '@/utils/request';
import {
updatePlaygroundItem,
setPlaygroundResponse,
selectPlaygroundResponse,
applyScriptVariableChanges
} from '@/store/slices/playground';
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 +50,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]);

Comment on lines +54 to +73

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sundram-bruno, I see this block been written multiple times in the whole application. Since you have worked on Auth, can we have a new reusable hook which does this, and update the implementation at other places accordingly.

Thanks!

Image

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mainly it's in GrpcRequest, and now in the PlaygroundView. We have the useRequestPageData hook, but that's more than what is needed for this implementation.

Maybe, we can use the said hook inside the useRequestPageData,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@vasharma05-bruno , ok will look into it and see what can be done.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks!

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we let this change go ahead in this PR, or make the change in this PR itself?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let this change go. Currently I dont have the bandwidth to do the change and no need to hold this pr for something where we can change it across the whole repo. I feel we can let it be a separate task for it.

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

Expand Down Expand Up @@ -129,6 +156,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');
});
Comment on lines +71 to +77

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we also add a test for clicking on the trigger button, and then check the modal renders with content inside it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have covered this in the e2e(query-bar-code-snippet.spec.ts) ,clicking cannot be simulated in rendertoStaticMarkup.


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 @@ -106,13 +106,14 @@ export const SnippetTabs: React.FC<SnippetTabsProps> = ({
<button
ref={triggerRef}
type="button"
className="snippet-trigger"
className={variant === 'icon' ? 'snippet-icon-trigger' : 'snippet-trigger'}
aria-haspopup="dialog"
aria-label="Generate Code"
data-testid={`${testId}-trigger`}
onClick={openModal}
>
<IconCode size={16} stroke={1.5} />
Code Snippet
{variant === 'embedded' && 'Code Snippet'}
</button>
)}
<Modal
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