feat: Multiple Examples for OpenAPI 3 Parameters, Request Bodies, and Responses (via #5427)

* add opt-in Prettier config

* remove legacy `examples` implementation

* create ExamplesSelect

* support `Response.examples` in OpenAPI 3

* create response controls group

* prettier reformat

* prepare to break up Parameters

* reunify Parameters and OAS3 Parameters

* Parameter Examples

* Example component

* handle parameter value stringification correctly

* FOR REVIEW: add prop for controlling Select

* use regular header for param examples in Try-It-Out

* manage active examples member via Redux

* Request Body Try-It-Out examples

* remove special Response description styling

* omit Example value display in Try-It-Out

* support disabled text inputs in JsonSchemaForm

* Example.omitValue => Example.showValue

* ExamplesSelectValueRetainer

* styling for disabled inputs

* remove console.log

* support "Modified Values" in ExamplesSelect

* remove Examples component
(wasn't used anywhere)

* use ParameterRow.getParamKey for active examples member keying

* split-rendering of examples in ParameterRow

* send disabled prop to JsonSchemaForm

* use content type to key request body active examples members

* remove debugger

* rewire RequestBodyEditor to be a controlled component

REVIEW: does this have perf implications?

* trigger synthetic onSelect events in ExamplesSelect

* prettier updates

* remove outdated Examples usage in RequestBody

* don't handle examples changes in ESVR

* make RequestBodyEditor semi-controlled

* don't default to an empty Map for request bodies

* add namespaceKey to ESVR for state mgmt

* don't key RequestBody activeExampleKeys on media type

* tweak ESVR isModifiedValueSelected calculation

* add trace class to ExamplesSelect

* remove usage of ESVR.currentNamespace

* reset to first example if currentExampleKey is invalid

* add default values to RequestBody rendering

* stringify things in ESVR

* avoid null select value (silences React warning)

* detect user inputs that match any examples member's value

* add trace class for json-schema-array

* shallowly convert namespace state, to preserve Immutable stucts in state

* stringify RBE values; don't trim JSON in editor

* match user input to an example when non-primitives are expressed in state as strings

* update Cypress

* don't apply sample values in JsonSchema_Object

* support disabling all JsonSchemaForm subcomponents

* Core tests

* style changes to accomodate Examples

* fix version-checking error in Response

* disable SCU for Responses

* don't stringify Select values

* ModelExample: default to Model tab if no example is available; provide a default no example message

* don't trim JSON ParamBody inputs

* read directly from 2.0 Response.schema instead of inferring a value

* show current Example information in RequestBody

* show label for Examples dropdown by default

* rework Response content ordering

* style disabled textareas like other read-only blocks

* meta: fix sourcemaps

* refactor ESVR setNameForNamespace

* protect second half of ternary expession

* cypress: `select.examples-select` => `.examples-select > select`

* clarify ModelExample.componentWillReceiveProps

* add gates/defaults to prevent issues in very bare-boned documents

* fix test block organization problem

* simplify RequestBodyEditor interface

* linter fixes

* prettier updates

* use plugin system for new components

* move ME Cypress helpers to other file
This commit is contained in:
kyle
2019-06-29 19:52:51 +01:00
committed by GitHub
parent 332ddaedcd
commit 23d7260f92
34 changed files with 3148 additions and 653 deletions

View File

@@ -3,6 +3,7 @@
export const UPDATE_SELECTED_SERVER = "oas3_set_servers"
export const UPDATE_REQUEST_BODY_VALUE = "oas3_set_request_body_value"
export const UPDATE_ACTIVE_EXAMPLES_MEMBER = "oas3_set_active_examples_member"
export const UPDATE_REQUEST_CONTENT_TYPE = "oas3_set_request_content_type"
export const UPDATE_RESPONSE_CONTENT_TYPE = "oas3_set_response_content_type"
export const UPDATE_SERVER_VARIABLE_VALUE = "oas3_set_server_variable_value"
@@ -21,6 +22,13 @@ export function setRequestBodyValue ({ value, pathMethod }) {
}
}
export function setActiveExamplesMember ({ name, pathMethod, contextType, contextName }) {
return {
type: UPDATE_ACTIVE_EXAMPLES_MEMBER,
payload: { name, pathMethod, contextType, contextName }
}
}
export function setRequestContentType ({ value, pathMethod }) {
return {
type: UPDATE_REQUEST_CONTENT_TYPE,

View File

@@ -1,24 +1,19 @@
import React, { PureComponent } from "react"
import PropTypes from "prop-types"
import { fromJS } from "immutable"
import { getSampleSchema, stringify } from "core/utils"
import { stringify } from "core/utils"
const NOOP = Function.prototype
export default class RequestBodyEditor extends PureComponent {
static propTypes = {
requestBody: PropTypes.object.isRequired,
mediaType: PropTypes.string.isRequired,
onChange: PropTypes.func,
getComponent: PropTypes.func.isRequired,
isExecute: PropTypes.bool,
specSelectors: PropTypes.object.isRequired,
value: PropTypes.string,
defaultValue: PropTypes.string,
};
static defaultProps = {
mediaType: "application/json",
requestBody: fromJS({}),
onChange: NOOP,
};
@@ -26,108 +21,75 @@ export default class RequestBodyEditor extends PureComponent {
super(props, context)
this.state = {
isEditBox: false,
userDidModify: false,
value: ""
}
}
componentDidMount() {
this.setValueToSample.call(this)
}
componentWillReceiveProps(nextProps) {
if(this.props.mediaType !== nextProps.mediaType) {
// media type was changed
this.setValueToSample(nextProps.mediaType)
value: stringify(props.value) || props.defaultValue
}
if(!this.props.isExecute && nextProps.isExecute) {
// we just entered execute mode,
// so enable editing for convenience
this.setState({ isEditBox: true })
}
// this is the glue that makes sure our initial value gets set as the
// current request body value
// TODO: achieve this in a selector instead
props.onChange(props.value)
}
componentDidUpdate(prevProps) {
if(this.props.requestBody !== prevProps.requestBody) {
// force recalc of value if the request body definition has changed
this.setValueToSample(this.props.mediaType)
}
}
applyDefaultValue = (nextProps) => {
const { onChange, defaultValue } = (nextProps ? nextProps : this.props)
setValueToSample = (explicitMediaType) => {
this.onChange(this.sample(explicitMediaType))
}
resetValueToSample = (explicitMediaType) => {
this.setState({ userDidModify: false })
this.setValueToSample(explicitMediaType)
}
sample = (explicitMediaType) => {
let { requestBody, mediaType } = this.props
let mediaTypeValue = requestBody.getIn(["content", explicitMediaType || mediaType])
let schema = mediaTypeValue.get("schema").toJS()
let mediaTypeExample = mediaTypeValue.get("example") !== undefined ? stringify(mediaTypeValue.get("example")) : null
return mediaTypeExample || getSampleSchema(schema, explicitMediaType || mediaType, {
includeWriteOnly: true
this.setState({
value: defaultValue
})
return onChange(defaultValue)
}
onChange = (value) => {
this.setState({value})
this.props.onChange(value)
this.props.onChange(stringify(value))
}
handleOnChange = e => {
const { mediaType } = this.props
const isJson = /json/i.test(mediaType)
const inputValue = isJson ? e.target.value.trim() : e.target.value
onDomChange = e => {
const inputValue = e.target.value
this.setState({ userDidModify: true })
this.onChange(inputValue)
this.setState({
value: inputValue,
}, () => this.onChange(inputValue))
}
toggleIsEditBox = () => this.setState( state => ({isEditBox: !state.isEditBox}))
componentWillReceiveProps(nextProps) {
if(
this.props.value !== nextProps.value &&
nextProps.value !== this.state.value
) {
this.setState({
value: stringify(nextProps.value)
})
}
if(!nextProps.value && nextProps.defaultValue && !!this.state.value) {
// if new value is falsy, we have a default, AND the falsy value didn't
// come from us originally
this.applyDefaultValue(nextProps)
}
}
render() {
let {
isExecute,
getComponent,
mediaType,
getComponent
} = this.props
const Button = getComponent("Button")
const TextArea = getComponent("TextArea")
const HighlightCode = getComponent("highlightCode")
let {
value
} = this.state
let { value, isEditBox, userDidModify } = this.state
const TextArea = getComponent("TextArea")
return (
<div className="body-param">
{
isEditBox && isExecute
? <TextArea className={"body-param__text"} value={value} onChange={ this.handleOnChange }/>
: (value && <HighlightCode className="body-param__example"
value={ value }/>)
}
<div className="body-param-options">
<div className="body-param-edit">
{
!isExecute ? null
: <Button className={isEditBox ? "btn cancel body-param__example-edit" : "btn edit body-param__example-edit"}
onClick={this.toggleIsEditBox}>{ isEditBox ? "Cancel" : "Edit"}
</Button>
}
{ userDidModify &&
<Button className="btn ml3" onClick={() => { this.resetValueToSample(mediaType) }}>Reset</Button>
}
</div>
</div>
<TextArea
className={"body-param__text"}
value={value}
onChange={ this.onDomChange }
/>
</div>
)

View File

@@ -4,6 +4,36 @@ import ImPropTypes from "react-immutable-proptypes"
import { Map, OrderedMap, List } from "immutable"
import { getCommonExtensions, getSampleSchema, stringify } from "core/utils"
function getDefaultRequestBodyValue(requestBody, mediaType, activeExamplesKey) {
let mediaTypeValue = requestBody.getIn(["content", mediaType])
let schema = mediaTypeValue.get("schema").toJS()
let example =
mediaTypeValue.get("example") !== undefined
? stringify(mediaTypeValue.get("example"))
: null
let currentExamplesValue = mediaTypeValue.getIn([
"examples",
activeExamplesKey,
"value"
])
if (mediaTypeValue.get("examples")) {
// the media type DOES have examples
return stringify(currentExamplesValue) || ""
} else {
// the media type DOES NOT have examples
return stringify(
example ||
getSampleSchema(schema, mediaType, {
includeWriteOnly: true
}) ||
""
)
}
}
const RequestBody = ({
requestBody,
requestBodyValue,
@@ -14,7 +44,9 @@ const RequestBody = ({
contentType,
isExecute,
specPath,
onChange
onChange,
activeExamplesKey,
updateActiveExamplesKey,
}) => {
const handleFile = (e) => {
onChange(e.target.files[0])
@@ -23,6 +55,9 @@ const RequestBody = ({
const Markdown = getComponent("Markdown")
const ModelExample = getComponent("modelExample")
const RequestBodyEditor = getComponent("RequestBodyEditor")
const HighlightCode = getComponent("highlightCode")
const ExamplesSelectValueRetainer = getComponent("ExamplesSelectValueRetainer")
const Example = getComponent("Example")
const { showCommonExtensions } = getConfigs()
@@ -32,6 +67,11 @@ const RequestBody = ({
const mediaTypeValue = requestBodyContent.get(contentType, OrderedMap())
const schemaForMediaType = mediaTypeValue.get("schema", OrderedMap())
const examplesForMediaType = mediaTypeValue.get("examples", OrderedMap())
const handleExamplesSelect = (key /*, { isSyntheticChange } */) => {
updateActiveExamplesKey(key)
}
if(!mediaTypeValue.size) {
return null
@@ -83,7 +123,7 @@ const RequestBody = ({
const format = prop.get("format")
const description = prop.get("description")
const currentValue = requestBodyValue.get(key)
let initialValue = prop.get("default") || prop.get("example") || ""
if (initialValue === "" && type === "object") {
@@ -139,23 +179,63 @@ const RequestBody = ({
{ requestBodyDescription &&
<Markdown source={requestBodyDescription} />
}
<ModelExample
getComponent={ getComponent }
getConfigs={ getConfigs }
specSelectors={ specSelectors }
expandDepth={1}
isExecute={isExecute}
schema={mediaTypeValue.get("schema")}
specPath={specPath.push("content", contentType)}
example={<RequestBodyEditor
requestBody={requestBody}
onChange={onChange}
mediaType={contentType}
getComponent={getComponent}
isExecute={isExecute}
specSelectors={specSelectors}
/>}
/>
{
examplesForMediaType ? (
<ExamplesSelectValueRetainer
examples={examplesForMediaType}
currentKey={activeExamplesKey}
currentUserInputValue={requestBodyValue}
onSelect={handleExamplesSelect}
updateValue={onChange}
defaultToFirstExample={true}
getComponent={getComponent}
/>
) : null
}
{
isExecute ? (
<div>
<RequestBodyEditor
value={requestBodyValue}
defaultValue={getDefaultRequestBodyValue(
requestBody,
contentType,
activeExamplesKey,
)}
onChange={onChange}
getComponent={getComponent}
/>
</div>
) : (
<ModelExample
getComponent={ getComponent }
getConfigs={ getConfigs }
specSelectors={ specSelectors }
expandDepth={1}
isExecute={isExecute}
schema={mediaTypeValue.get("schema")}
specPath={specPath.push("content", contentType)}
example={
<HighlightCode
className="body-param__example"
value={stringify(requestBodyValue) || getDefaultRequestBodyValue(
requestBody,
contentType,
activeExamplesKey,
)}
/>
}
/>
)
}
{
examplesForMediaType ? (
<Example
example={examplesForMediaType.get(activeExamplesKey)}
getComponent={getComponent}
/>
) : null
}
</div>
}
@@ -169,7 +249,9 @@ RequestBody.propTypes = {
contentType: PropTypes.string,
isExecute: PropTypes.bool.isRequired,
onChange: PropTypes.func.isRequired,
specPath: PropTypes.array.isRequired
specPath: PropTypes.array.isRequired,
activeExamplesKey: PropTypes.string,
updateActiveExamplesKey: PropTypes.func,
}
export default RequestBody

View File

@@ -1,6 +1,7 @@
import {
UPDATE_SELECTED_SERVER,
UPDATE_REQUEST_BODY_VALUE,
UPDATE_ACTIVE_EXAMPLES_MEMBER,
UPDATE_REQUEST_CONTENT_TYPE,
UPDATE_SERVER_VARIABLE_VALUE,
UPDATE_RESPONSE_CONTENT_TYPE
@@ -15,6 +16,10 @@ export default {
let [path, method] = pathMethod
return state.setIn( [ "requestData", path, method, "bodyValue" ], value)
},
[UPDATE_ACTIVE_EXAMPLES_MEMBER]: (state, { payload: { name, pathMethod, contextType, contextName } } ) =>{
let [path, method] = pathMethod
return state.setIn( [ "examples", path, method, contextType, contextName, "activeExample" ], name)
},
[UPDATE_REQUEST_CONTENT_TYPE]: (state, { payload: { value, pathMethod } } ) =>{
let [path, method] = pathMethod
return state.setIn( [ "requestData", path, method, "requestContentType" ], value)

View File

@@ -26,6 +26,11 @@ export const requestBodyValue = onlyOAS3((state, path, method) => {
}
)
export const activeExamplesMember = onlyOAS3((state, path, method, type, name) => {
return state.getIn(["examples", path, method, type, name, "activeExample"]) || null
}
)
export const requestContentType = onlyOAS3((state, path, method) => {
return state.getIn(["requestData", path, method, "requestContentType"]) || null
}

View File

@@ -1,6 +1,5 @@
import Markdown from "./markdown"
import AuthItem from "./auth-item"
import parameters from "./parameters"
import VersionStamp from "./version-stamp"
import OnlineValidatorBadge from "./online-validator-badge"
import Model from "./model"
@@ -9,7 +8,6 @@ import JsonSchema_string from "./json-schema-string"
export default {
Markdown,
AuthItem,
parameters,
JsonSchema_string,
VersionStamp,
model: Model,

View File

@@ -1,214 +0,0 @@
import React, { Component } from "react"
import PropTypes from "prop-types"
import Im, { Map, List } from "immutable"
import ImPropTypes from "react-immutable-proptypes"
import { OAS3ComponentWrapFactory } from "../helpers"
// More readable, just iterate over maps, only
const eachMap = (iterable, fn) => iterable.valueSeq().filter(Im.Map.isMap).map(fn)
class Parameters extends Component {
constructor(props) {
super(props)
this.state = {
callbackVisible: false,
parametersVisible: true
}
}
static propTypes = {
parameters: ImPropTypes.list.isRequired,
specActions: PropTypes.object.isRequired,
operation: PropTypes.object.isRequired,
getComponent: PropTypes.func.isRequired,
getConfigs: PropTypes.func.isRequired,
specSelectors: PropTypes.object.isRequired,
oas3Actions: PropTypes.object.isRequired,
oas3Selectors: PropTypes.object.isRequired,
fn: PropTypes.object.isRequired,
tryItOutEnabled: PropTypes.bool,
allowTryItOut: PropTypes.bool,
specPath: ImPropTypes.list.isRequired,
onTryoutClick: PropTypes.func,
onCancelClick: PropTypes.func,
onChangeKey: PropTypes.array,
pathMethod: PropTypes.array.isRequired
}
static defaultProps = {
onTryoutClick: Function.prototype,
onCancelClick: Function.prototype,
tryItOutEnabled: false,
allowTryItOut: true,
onChangeKey: [],
}
onChange = ( param, value, isXml ) => {
let {
specActions: { changeParamByIdentity },
onChangeKey,
} = this.props
changeParamByIdentity( onChangeKey, param, value, isXml)
}
onChangeConsumesWrapper = ( val ) => {
let {
specActions: { changeConsumesValue },
onChangeKey
} = this.props
changeConsumesValue(onChangeKey, val)
}
toggleTab = (tab) => {
if(tab === "parameters"){
return this.setState({
parametersVisible: true,
callbackVisible: false
})
}else if(tab === "callbacks"){
return this.setState({
callbackVisible: true,
parametersVisible: false
})
}
}
render(){
let {
onTryoutClick,
onCancelClick,
parameters,
allowTryItOut,
tryItOutEnabled,
fn,
getComponent,
getConfigs,
specSelectors,
specActions,
oas3Actions,
oas3Selectors,
pathMethod,
specPath,
operation
} = this.props
const ParameterRow = getComponent("parameterRow")
const TryItOutButton = getComponent("TryItOutButton")
const ContentType = getComponent("contentType")
const Callbacks = getComponent("Callbacks", true)
const RequestBody = getComponent("RequestBody", true)
const isExecute = tryItOutEnabled && allowTryItOut
const { isOAS3 } = specSelectors
const requestBody = operation.get("requestBody")
const requestBodySpecPath = specPath.slice(0, -1).push("requestBody") // remove the "parameters" part
return (
<div className="opblock-section">
<div className="opblock-section-header">
<div className="tab-header">
<div onClick={() => this.toggleTab("parameters")} className={`tab-item ${this.state.parametersVisible && "active"}`}>
<h4 className="opblock-title"><span>Parameters</span></h4>
</div>
{ operation.get("callbacks") ?
(
<div onClick={() => this.toggleTab("callbacks")} className={`tab-item ${this.state.callbackVisible && "active"}`}>
<h4 className="opblock-title"><span>Callbacks</span></h4>
</div>
) : null
}
</div>
{ allowTryItOut ? (
<TryItOutButton enabled={ tryItOutEnabled } onCancelClick={ onCancelClick } onTryoutClick={ onTryoutClick } />
) : null }
</div>
{this.state.parametersVisible ? <div className="parameters-container">
{ !parameters.count() ? <div className="opblock-description-wrapper"><p>No parameters</p></div> :
<div className="table-container">
<table className="parameters">
<thead>
<tr>
<th className="col col_header parameters-col_name">Name</th>
<th className="col col_header parameters-col_description">Description</th>
</tr>
</thead>
<tbody>
{
eachMap(parameters, (parameter, i) => (
<ParameterRow fn={ fn }
getComponent={ getComponent }
specPath={specPath.push(i)}
getConfigs={ getConfigs }
rawParam={ parameter }
param={ specSelectors.parameterWithMetaByIdentity(pathMethod, parameter) }
key={ parameter.get( "name" ) }
onChange={ this.onChange }
onChangeConsumes={this.onChangeConsumesWrapper}
specSelectors={ specSelectors }
specActions={ specActions }
pathMethod={ pathMethod }
isExecute={ isExecute }/>
)).toArray()
}
</tbody>
</table>
</div>
}
</div> : "" }
{this.state.callbackVisible ? <div className="callbacks-container opblock-description-wrapper">
<Callbacks
callbacks={Map(operation.get("callbacks"))}
specPath={specPath.slice(0, -1).push("callbacks")}
/>
</div> : "" }
{
isOAS3() && requestBody && this.state.parametersVisible &&
<div className="opblock-section opblock-section-request-body">
<div className="opblock-section-header">
<h4 className={`opblock-title parameter__name ${requestBody.get("required") && "required"}`}>Request body</h4>
<label>
<ContentType
value={oas3Selectors.requestContentType(...pathMethod)}
contentTypes={ requestBody.get("content", List()).keySeq() }
onChange={(value) => {
oas3Actions.setRequestContentType({ value, pathMethod })
}}
className="body-param-content-type" />
</label>
</div>
<div className="opblock-description-wrapper">
<RequestBody
specPath={requestBodySpecPath}
requestBody={requestBody}
requestBodyValue={oas3Selectors.requestBodyValue(...pathMethod) || Map()}
isExecute={isExecute}
onChange={(value, path) => {
if(path) {
const lastValue = oas3Selectors.requestBodyValue(...pathMethod)
const usableValue = Map.isMap(lastValue) ? lastValue : Map()
return oas3Actions.setRequestBodyValue({
pathMethod,
value: usableValue.setIn(path, value)
})
}
oas3Actions.setRequestBodyValue({ value, pathMethod })
}}
contentType={oas3Selectors.requestContentType(...pathMethod)}/>
</div>
</div>
}
</div>
)
}
}
export default OAS3ComponentWrapFactory(Parameters)