Skip to content

Commit

Permalink
feat: parameters inputs (#755)
Browse files Browse the repository at this point in the history
* feat: parameter validations

* chore: review suggestions

* chore: cleanup and separete Parameter component

* test: update tests
  • Loading branch information
mallachari authored Dec 22, 2020
1 parent 58b4c32 commit fec2d79
Show file tree
Hide file tree
Showing 6 changed files with 238 additions and 35 deletions.
30 changes: 30 additions & 0 deletions packages/elements/src/__fixtures__/operations/put-todos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -369,10 +369,40 @@ export const httpOperation: IHttpOperation = {
schema: {
type: 'string',
description: 'Your Stoplight account id',
default: 'account-id-default',
},
name: 'account-id',
style: HttpParamStyles.Simple,
required: true,
examples: [
{
value: 'example id',
key: 'example',
},
],
},
{
schema: {
type: 'string',
description: 'Your Stoplight account id',
},
name: 'message-id',
style: HttpParamStyles.Simple,
required: true,
examples: [
{
value: 'example value',
key: 'example 1',
},
{
value: 'another example',
key: 'example 2',
},
{
value: 'something else',
key: 'example 3',
},
],
},
],
path: [
Expand Down
6 changes: 4 additions & 2 deletions packages/elements/src/components/TryIt/BasicSend.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import * as React from 'react';

import { HttpCodeDescriptions } from '../../constants';
import { getHttpCodeColor } from '../../utils/http';
import { OperationParameters } from './OperationParameters';
import { initialParameterValues, OperationParameters } from './OperationParameters';

export interface BasicSendProps {
httpOperation: IHttpOperation;
Expand All @@ -33,7 +33,9 @@ export const BasicSend: React.FC<BasicSendProps> = ({ httpOperation }) => {
};
const allParameters = Object.values(operationParameters).flat();

const [parameterValues, setParameterValues] = React.useState<Dictionary<string, string>>({});
const [parameterValues, setParameterValues] = React.useState<Dictionary<string, string>>(
initialParameterValues(operationParameters),
);

if (!server) return null;

Expand Down
80 changes: 52 additions & 28 deletions packages/elements/src/components/TryIt/OperationParameters.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { Flex, Input, Panel, Text } from '@stoplight/mosaic';
import { Dictionary, IHttpHeaderParam, IHttpPathParam, IHttpQueryParam } from '@stoplight/types';
import { Panel } from '@stoplight/mosaic';
import { Dictionary, IHttpHeaderParam, IHttpParam, IHttpPathParam, IHttpQueryParam } from '@stoplight/types';
import { sortBy } from 'lodash';
import * as React from 'react';

interface OperationParameters {
import { exampleValue, Parameter } from './Parameter';

export interface OperationParameters {
path?: IHttpPathParam[];
query?: IHttpQueryParam[];
headers?: IHttpHeaderParam[];
Expand All @@ -20,37 +22,59 @@ export const OperationParameters: React.FC<OperationParametersProps> = ({
values,
onChangeValues,
}) => {
const pathParameters = sortBy(operationParameters.path ?? [], ['name']);
const queryParameters = sortBy(operationParameters.query ?? [], ['name']);
const headerParameters = sortBy(operationParameters.headers ?? [], ['name']);
const parameters = [...pathParameters, ...queryParameters, ...headerParameters];
const parameters = flattenParameters(operationParameters);

const onChange = (parameter: IHttpParam) => (
e: React.FormEvent<HTMLSelectElement> | React.ChangeEvent<HTMLInputElement>,
) => {
const newValue = e.currentTarget.value;
onChangeValues({ ...values, [parameter.name]: newValue });
};

return (
<Panel id="collapse-open" defaultIsOpen>
<Panel.Titlebar>Parameters</Panel.Titlebar>
<Panel.Content className="sl-overflow-y-auto OperationParametersContent">
{parameters.map(parameter => {
return (
<Flex key={parameter.name} alignItems="center">
<Input appearance="minimal" readOnly value={parameter.name} />
<Text mx={3}>:</Text>
<Input
aria-label={parameter.name}
appearance="minimal"
flexGrow
placeholder={parameter.schema?.type as string}
type={parameter.schema?.type as string}
required
value={values[parameter.name] ?? ''}
onChange={e => {
const newValue = e.currentTarget.value;
onChangeValues({ ...values, [parameter.name]: newValue });
}}
/>
</Flex>
);
})}
{parameters.map((parameter, i) => (
<Parameter
key={parameter.name}
parameter={parameter}
value={values[parameter.name]}
onChange={onChange(parameter)}
/>
))}
</Panel.Content>
</Panel>
);
};

function flattenParameters(parameters: OperationParameters) {
const pathParameters = sortBy(parameters.path ?? [], ['name']);
const queryParameters = sortBy(parameters.query ?? [], ['name']);
const headerParameters = sortBy(parameters.headers ?? [], ['name']);
return [...pathParameters, ...queryParameters, ...headerParameters];
}

export function initialParameterValues(operationParameters: OperationParameters) {
const parameters = flattenParameters(operationParameters);

const enums = Object.fromEntries(
parameters
.map(p => [p.name, p.schema?.enum ?? []] as const)
.filter(([, enums]) => enums.length > 0)
.map(([name, enums]) => [name, String(enums[0])]),
);

const examples = Object.fromEntries(
parameters
.map(p => [p.name, p.examples ?? []] as const)
.filter(([, examples]) => examples.length > 0)
.map(([name, examples]) => [name, exampleValue(examples[0])]),
);

return {
// order matters - enums should be override examples
...examples,
...enums,
};
}
94 changes: 94 additions & 0 deletions packages/elements/src/components/TryIt/Parameter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { safeStringify } from '@stoplight/json';
import { Flex, Input, Select, Text } from '@stoplight/mosaic';
import { IHttpParam, INodeExample, INodeExternalExample } from '@stoplight/types';
import { isObject, map } from 'lodash';
import * as React from 'react';

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

const booleanOptions = [
{ label: 'Not Set', value: '' },
{ label: 'False', value: 'false' },
{ label: 'True', value: 'true' },
];

const selectExampleOption = { value: '', label: 'Pick an example' };

export const Parameter: React.FC<ParameterProps> = ({ parameter, value, onChange }) => {
const parameterValueOptions = parameterOptions(parameter);
const examples = exampleOptions(parameter);
const selectedExample = examples?.find(e => e.value === value) ?? selectExampleOption;
return (
<Flex align="center" key={parameter.name}>
<Input appearance="minimal" readOnly value={parameter.name} />
<Text mx={3}>:</Text>
{parameterValueOptions ? (
<Select
flexGrow
aria-label={parameter.name}
options={parameterValueOptions}
value={value}
onChange={onChange}
/>
) : (
<Flex flexGrow>
<Input
style={{ paddingLeft: 15 }}
aria-label={parameter.name}
appearance="minimal"
flexGrow
placeholder={getPlaceholderForParameter(parameter)}
type={parameter.schema?.type === 'number' ? 'number' : 'text'}
required
value={value ?? ''}
onChange={onChange}
/>
{examples && (
<Select
aria-label={`${parameter.name}-select`}
flexGrow
value={selectedExample.value}
options={examples}
onChange={onChange}
/>
)}
</Flex>
)}
</Flex>
);
};

function parameterOptions(parameter: IHttpParam) {
return parameter.schema?.type === 'boolean'
? booleanOptions
: parameter.schema?.enum !== undefined
? map(parameter.schema.enum, v => (Number.isNaN(Number(v)) ? String(v) : Number(v)))
: null;
}

function exampleOptions(parameter: IHttpParam) {
return parameter.examples?.length && parameter.examples.length > 1
? [
selectExampleOption,
...parameter.examples.map(example => ({ label: example.key, value: exampleValue(example) })),
]
: null;
}

export function exampleValue(example: INodeExample | INodeExternalExample) {
return 'value' in example ? example.value : example.externalValue;
}

function getPlaceholderForParameter(parameter: IHttpParam) {
const defaultOrType = getDefaultForParameter(parameter) ?? parameter.schema?.type;
return defaultOrType !== undefined ? String(defaultOrType) : undefined;
}

function getDefaultForParameter(parameter: IHttpParam) {
const defaultValue = parameter.schema?.default;
return isObject(defaultValue) ? safeStringify(defaultValue) : defaultValue;
}
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,33 @@ describe('TryIt', () => {
expect(todoIdField.placeholder).toMatch(/string/i);
});

it('Initializes parameters correctly', () => {
render(<BasicSend httpOperation={putOperation} />);

// path param
const completedField = screen.getByLabelText('completed');
expect(completedField).toHaveValue('');

// query params
const limitField = screen.getByLabelText('limit');
expect(limitField).toHaveValue('0');

const typeField = screen.getByLabelText('type');
expect(typeField).toHaveValue('something');

const valueField = screen.getByLabelText('value');
expect(valueField).toHaveValue('0');

// header param

const accountIdField = screen.getByLabelText('account-id') as HTMLInputElement;
expect(accountIdField).toHaveValue('example id');
expect(accountIdField.placeholder).toMatch(/account-id-default/i);

const messageIdField = screen.getByLabelText('message-id');
expect(messageIdField).toHaveValue('example value');
});

it('Passes all parameters to the request', async () => {
render(<BasicSend httpOperation={putOperation} />);

Expand All @@ -92,26 +119,30 @@ describe('TryIt', () => {

// query params
const limitField = screen.getByLabelText('limit');
await userEvent.type(limitField, '5');
await userEvent.selectOptions(limitField, '3');

const typeField = screen.getByLabelText('type');
await userEvent.type(typeField, 'some-type');
await userEvent.selectOptions(typeField, 'another');

// header param

const accountIdField = screen.getByLabelText('account-id');
await userEvent.type(accountIdField, '1999');
await userEvent.type(accountIdField, ' 1999');

const messageIdField = screen.getByLabelText('message-id-select');
await userEvent.selectOptions(messageIdField, 'example 2');

// click send
clickSend();

expect(fetchMock).toHaveBeenCalled();
expect(fetchMock).toBeCalledWith(
'https://todos.stoplight.io/todos/123?limit=5&type=some-type',
'https://todos.stoplight.io/todos/123?limit=3&value=0&type=another',
expect.objectContaining({
method: 'put',
headers: {
'account-id': '1999',
'account-id': 'example id 1999',
'message-id': 'another example',
},
}),
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { httpOperation } from '../../../__fixtures__/operations/put-todos';
import { initialParameterValues } from '../OperationParameters';

describe('Parameters', () => {
it('should fill initial parameters', () => {
const operationParameters = {
path: httpOperation.request?.path,
query: httpOperation.request?.query,
headers: httpOperation.request?.headers,
};

const parameters = initialParameterValues(operationParameters);

expect(parameters).toMatchObject({
limit: '0',
type: 'something',
value: '0',
'account-id': 'example id',
'message-id': 'example value',
});
});
});

0 comments on commit fec2d79

Please sign in to comment.