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
1 change: 1 addition & 0 deletions packages/elements/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
"httpsnippet": "^1.24.0",
"jotai": "^0.12.4",
"lodash": "^4.17.19",
"nanoid": "^3.1.20",
"prop-types": "^15.7.2",
"react-router-dom": "^5.2.0",
"react-router-hash-link": "^2.1.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ export const httpOperation: IHttpOperation = {
type: 'string',
enum: ['a', 'b', 'c'],
},
someFile: {
type: 'string',
format: 'binary',
},
},
required: ['name', 'completed'],
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { Flex, Icon, Input, Text } from '@stoplight/mosaic';
import { nanoid } from 'nanoid';
import * as React from 'react';

import { ParameterSpec } from './parameter-utils';

interface FileUploadParamterEditorProps {
parameter: ParameterSpec;
value?: File;
onChange: (parameterValue: File | undefined) => void;
}

export const FileUploadParamterEditor: React.FC<FileUploadParamterEditorProps> = ({ parameter, value, onChange }) => {
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.currentTarget.files?.[0];
if (file === undefined) return;

onChange(file);
};

const clearFile = () => {
onChange(undefined);
};

const fileUploadInputId = React.useRef(`file-upload-${nanoid()}`);

return (
<Flex align="center" key={parameter.name}>
<Input appearance="minimal" readOnly value={parameter.name} />
<Text mx={3}>:</Text>
<Flex flexGrow alignItems="center">
<Input
style={{ paddingLeft: 15 }}
aria-label={parameter.name}
appearance="minimal"
flexGrow
placeholder="pick a file"
type="text"
required
value={value?.name ?? ''}
disabled
/>
{value && (
<button className="mr-3 p-2" aria-label="Remove file" onClick={clearFile}>
<Icon icon="times" />
</button>
)}
<div>
<label role="button" htmlFor={fileUploadInputId.current}>
Upload
</label>
<input onChange={handleFileChange} type="file" hidden id={fileUploadInputId.current} />
</div>
</Flex>
</Flex>
);
};
48 changes: 37 additions & 11 deletions packages/elements/src/components/TryIt/FormDataBody.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
import { safeStringify } from '@stoplight/json';
import { Panel } from '@stoplight/mosaic';
import { Dictionary, IMediaTypeContent } from '@stoplight/types';
import { IMediaTypeContent } from '@stoplight/types';
import { omit } from 'lodash';
import * as React from 'react';

import { FileUploadParamterEditor } from './FileUploadParameterEditors';
import { parameterSupportsFileUpload } from './parameter-utils';
import { ParameterEditor } from './ParameterEditor';
import { BodyParameterValues } from './request-body-utils';

interface FormDataBodyProps {
specification: IMediaTypeContent;
values: Dictionary<string, string>;
onChangeValues: (newValues: Dictionary<string, string>) => void;
values: BodyParameterValues;
onChangeValues: (newValues: BodyParameterValues) => void;
}

export const FormDataBody: React.FC<FormDataBodyProps> = ({ specification, values, onChangeValues }) => {
Expand All @@ -29,14 +33,36 @@ export const FormDataBody: React.FC<FormDataBodyProps> = ({ specification, value
<Panel defaultIsOpen>
<Panel.Titlebar>Body</Panel.Titlebar>
<Panel.Content className="sl-overflow-y-auto OperationParametersContent">
{Object.entries(parameters).map(([name, schema]) => (
<ParameterEditor
key={name}
parameter={{ name, schema, examples: schema?.examples }}
value={values[name]}
onChange={e => onChangeValues({ ...values, [name]: e.currentTarget.value })}
/>
))}
{Object.entries(parameters)
.map(([name, schema]) => ({ name, schema, examples: schema?.examples }))
.map(parameter => {
const supportsFileUpload = parameterSupportsFileUpload(parameter);
const value = values[parameter.name];

if (supportsFileUpload) {
return (
<FileUploadParamterEditor
key={parameter.name}
parameter={parameter}
value={value instanceof File ? value : undefined}
onChange={newValue =>
newValue
? onChangeValues({ ...values, [parameter.name]: newValue })
: onChangeValues(omit(values, parameter.name))
}
/>
);
}

return (
<ParameterEditor
key={parameter.name}
parameter={parameter}
value={typeof value === 'string' ? value : undefined}
onChange={e => onChangeValues({ ...values, [parameter.name]: e.currentTarget.value })}
/>
);
})}
</Panel.Content>
</Panel>
);
Expand Down
4 changes: 2 additions & 2 deletions packages/elements/src/components/TryIt/ParameterEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {

interface ParameterProps {
parameter: ParameterSpec;
value: string;
value?: string;
onChange: (e: React.FormEvent<HTMLSelectElement> | React.ChangeEvent<HTMLInputElement>) => void;
}

Expand Down Expand Up @@ -41,7 +41,7 @@ export const ParameterEditor: React.FC<ParameterProps> = ({ parameter, value, on
placeholder={getPlaceholderForParameter(parameter)}
type={parameter.schema?.type === 'number' ? 'number' : 'text'}
required
value={value ?? ''}
value={value}
onChange={onChange}
/>
{examples && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,39 @@ describe('TryIt', () => {
expect(body.get('someEnum')).toBe('a');
});
});

describe('File Upload', () => {
it('displays the name of the imported file in the string input', () => {
render(<TryItWithPersistence httpOperation={multipartFormdataOperation} />);

userEvent.upload(screen.getByLabelText('Upload'), new File(['something'], 'some-file'));

expect(screen.getByLabelText('someFile')).toHaveValue('some-file');
});

it('allows to remove file after importing it', () => {
render(<TryItWithPersistence httpOperation={multipartFormdataOperation} />);

userEvent.upload(screen.getByLabelText('Upload'), new File(['something'], 'some-file'));

userEvent.click(screen.getByLabelText('Remove file'));

expect(screen.getByLabelText('someFile')).not.toHaveValue();
});

it('allows to upload file in multipart request', async () => {
render(<TryItWithPersistence httpOperation={multipartFormdataOperation} />);

userEvent.upload(screen.getByLabelText('Upload'), new File(['something'], 'some-file'));

clickSend();

const body = fetchMock.mock.calls[0][1]!.body as FormData;

expect(body.get('someFile')).toBeInstanceOf(File);
expect((body.get('someFile') as File).name).toBe('some-file');
});
});
});

describe('Mocking', () => {
Expand Down
13 changes: 5 additions & 8 deletions packages/elements/src/components/TryIt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@ import * as React from 'react';
import { HttpCodeDescriptions } from '../../constants';
import { getHttpCodeColor } from '../../utils/http';
import { FormDataBody } from './FormDataBody';
import { getMockData, MockingOptions } from './mocking-utils';
import { getMockData, MockData, MockingOptions } from './mocking-utils';
import { MockingButton } from './MockingButton';
import { OperationParameters } from './OperationParameters';
import { createRequestBody, useBodyParameterState } from './request-body-utils';
import { BodyParameterValues, createRequestBody, useBodyParameterState } from './request-body-utils';
import { useRequestParameters } from './useOperationParameters';

export interface TryItProps {
Expand Down Expand Up @@ -138,14 +138,11 @@ const ResponseError: React.FC<{ state: ErrorState }> = ({ state }) => (
</Panel>
);

export interface BuildFetchRequestInput {
interface BuildFetchRequestInput {
httpOperation: IHttpOperation;
parameterValues: Dictionary<string, string>;
bodyParameterValues?: Dictionary<string, string>;
mockData?: {
url: string;
header?: Record<'Prefer', string>;
};
bodyParameterValues?: BodyParameterValues;
mockData?: MockData;
}

function buildFetchRequest({
Expand Down
8 changes: 6 additions & 2 deletions packages/elements/src/components/TryIt/mocking-utils.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
import { compact } from 'lodash';

import { formatMultiValueHeader } from '../../utils/headers';
import { BuildFetchRequestInput } from '.';

export type MockingOptions = { isEnabled: boolean; code?: string; example?: string; dynamic?: boolean };
type PreferHeaderProps = { code: string; example?: string; dynamic?: boolean };

export type MockData = {
url: string;
header?: Record<'Prefer', string>;
};

export function getMockData(
url: string | undefined,
{ isEnabled, code, dynamic, example }: MockingOptions,
): BuildFetchRequestInput['mockData'] {
): MockData | undefined {
return isEnabled && code && url ? { url, header: buildPreferHeader({ code, dynamic, example }) } : undefined;
}

Expand Down
4 changes: 4 additions & 0 deletions packages/elements/src/components/TryIt/parameter-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ export function exampleOptions(parameter: ParameterSpec) {
: null;
}

export function parameterSupportsFileUpload(parameter: ParameterSpec) {
return parameter.schema?.type === 'string' && parameter.schema.format === 'binary';
}

function exampleValue(example: INodeExample | INodeExternalExample) {
return 'value' in example ? String(example.value) : String(example.externalValue);
}
Expand Down
16 changes: 9 additions & 7 deletions packages/elements/src/components/TryIt/request-body-utils.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { IHttpOperation, IMediaTypeContent } from '@stoplight/types';
import { isString, pickBy } from 'lodash';
import * as React from 'react';

import { initialParameterValues } from './parameter-utils';

export type BodyParameterValues = Record<string, string | File>;

export const isFormDataContent = (content: IMediaTypeContent) =>
isUrlEncodedContent(content) || isMultipartContent(content);

Expand All @@ -14,10 +17,7 @@ function isMultipartContent(content: IMediaTypeContent) {
return content.mediaType.toLowerCase() === 'multipart/form-data';
}

export function createRequestBody(
httpOperation: IHttpOperation,
bodyParameterValues: Record<string, string> | undefined,
) {
export function createRequestBody(httpOperation: IHttpOperation, bodyParameterValues: BodyParameterValues | undefined) {
const bodySpecification = httpOperation.request?.body?.contents?.[0];
if (!bodySpecification) return undefined;

Expand All @@ -27,12 +27,14 @@ export function createRequestBody(

type RequestBodyCreator = (options: {
httpOperation: IHttpOperation;
bodyParameterValues?: Record<string, string>;
bodyParameterValues?: BodyParameterValues;
rawBodyValue?: string;
}) => BodyInit;

const createUrlEncodedRequestBody: RequestBodyCreator = ({ bodyParameterValues = {} }) => {
return new URLSearchParams(bodyParameterValues);
const filteredValues = pickBy(bodyParameterValues, isString);

return new URLSearchParams(filteredValues);
};

const createMultipartRequestBody: RequestBodyCreator = ({ bodyParameterValues = {} }) => {
Expand Down Expand Up @@ -67,7 +69,7 @@ export const useBodyParameterState = (httpOperation: IHttpOperation) => {
return initialParameterValues(parameters);
}, [isFormDataBody, bodySpecification]);

const [bodyParameterValues, setBodyParameterValues] = React.useState<Record<string, string>>(initialState);
const [bodyParameterValues, setBodyParameterValues] = React.useState<BodyParameterValues>(initialState);

React.useEffect(() => {
setBodyParameterValues(initialState);
Expand Down
5 changes: 5 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -15626,6 +15626,11 @@ nan@^2.12.1, nan@^2.13.2:
resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.0.tgz#7818f722027b2459a86f0295d434d1fc2336c52c"
integrity sha512-INOFj37C7k3AfaNTtX8RhsTw7qRy7eLET14cROi9+5HAVbbHuIWUHEauBv5qT4Av2tWasiTY1Jw6puUNqRJXQg==

nanoid@^3.1.20:
version "3.1.20"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.1.20.tgz#badc263c6b1dcf14b71efaa85f6ab4c1d6cfc788"
integrity sha512-a1cQNyczgKbLX9jwbS/+d7W8fX/RfgYR7lVWwWOGIPNgK2m0MWvrGF6/m4kk6U3QcFMnZf3RIhL0v2Jgh/0Uxw==

nanomatch@^1.2.9:
version "1.2.13"
resolved "https://registry.yarnpkg.com/nanomatch/-/nanomatch-1.2.13.tgz#b87a8aa4fc0de8fe6be88895b38983ff265bd119"
Expand Down