Skip to content

Commit

Permalink
feat(ux): enhance media-type switching experience in RequestBodyEditor (
Browse files Browse the repository at this point in the history
#6837)

* feat(ux): enhance media-type switching experience in RequestBodyEditor

1. When canceling the try-out mode the request body will be reset to its initial state.
2. When the user switches the media-type in the try-out mode, the experience is as follows:
   - If the user did edit the request body the body wont be touched and only media type is updated. This is to ensure that user content is NEVER accidentally overwritten with a default value.
   - If the user did not edit the request body it is safe to be replaced by the default value of the target media-type.

Multiple example needed some care in order to allow the retain example value to function properly

* fix(test): workaround cypress issue that can't be reproduced manually

* test: added new feature to ensure enhanced user editing flow

Signed-off-by: mathis-m <mathis.michel@outlook.de>
  • Loading branch information
mathis-m committed Jan 25, 2021
1 parent a5eb3dc commit e877580
Show file tree
Hide file tree
Showing 12 changed files with 235 additions and 32 deletions.
51 changes: 40 additions & 11 deletions src/core/components/examples-select-value-retainer.jsx
Expand Up @@ -36,16 +36,22 @@ export default class ExamplesSelectValueRetainer extends React.PureComponent {
examples: ImPropTypes.map,
onSelect: PropTypes.func,
updateValue: PropTypes.func, // mechanism to update upstream value
userHasEditedBody: PropTypes.bool,
getComponent: PropTypes.func.isRequired,
currentUserInputValue: PropTypes.any,
currentKey: PropTypes.string,
currentNamespace: PropTypes.string,
setRetainRequestBodyValueFlag: PropTypes.func.isRequired,
// (also proxies props for Examples)
}

static defaultProps = {
userHasEditedBody: false,
examples: Map({}),
currentNamespace: "__DEFAULT__NAMESPACE__",
setRetainRequestBodyValueFlag: () => {
// NOOP
},
onSelect: (...args) =>
console.log( // eslint-disable-line no-console
"ExamplesSelectValueRetainer: no `onSelect` function was provided",
Expand All @@ -72,11 +78,16 @@ export default class ExamplesSelectValueRetainer extends React.PureComponent {
lastDownstreamValue: valueFromExample,
isModifiedValueSelected:
// valueFromExample !== undefined &&
this.props.userHasEditedBody ||
this.props.currentUserInputValue !== valueFromExample,
}),
}
}

componentWillUnmount() {
this.props.setRetainRequestBodyValueFlag(false)
}

_getStateForCurrentNamespace = () => {
const { currentNamespace } = this.props

Expand Down Expand Up @@ -122,7 +133,12 @@ export default class ExamplesSelectValueRetainer extends React.PureComponent {
}

_onExamplesSelect = (key, { isSyntheticChange } = {}, ...otherArgs) => {
const { onSelect, updateValue, currentUserInputValue } = this.props
const {
onSelect,
updateValue,
currentUserInputValue,
userHasEditedBody,
} = this.props
const { lastUserEditedValue } = this._getStateForCurrentNamespace()

const valueFromExample = this._getValueForExample(key)
Expand All @@ -141,9 +157,8 @@ export default class ExamplesSelectValueRetainer extends React.PureComponent {
this._setStateForCurrentNamespace({
lastDownstreamValue: valueFromExample,
isModifiedValueSelected:
isSyntheticChange &&
!!currentUserInputValue &&
currentUserInputValue !== valueFromExample,
(isSyntheticChange && userHasEditedBody) ||
(!!currentUserInputValue && currentUserInputValue !== valueFromExample),
})

// we never want to send up value updates from synthetic changes
Expand All @@ -157,7 +172,12 @@ export default class ExamplesSelectValueRetainer extends React.PureComponent {
componentWillReceiveProps(nextProps) {
// update `lastUserEditedValue` as new currentUserInput values come in

const { currentUserInputValue: newValue, examples, onSelect } = nextProps
const {
currentUserInputValue: newValue,
examples,
onSelect,
userHasEditedBody,
} = nextProps

const {
lastUserEditedValue,
Expand All @@ -170,7 +190,7 @@ export default class ExamplesSelectValueRetainer extends React.PureComponent {
)

const exampleMatchingNewValue = examples.find(
example =>
(example) =>
example.get("value") === newValue ||
// sometimes data is stored as a string (e.g. in Request Bodies), so
// let's check against a stringified version of our example too
Expand All @@ -186,15 +206,23 @@ export default class ExamplesSelectValueRetainer extends React.PureComponent {
newValue !== lastUserEditedValue && // value isn't already tracked
newValue !== lastDownstreamValue // value isn't what we've seen on the other side
) {
this.props.setRetainRequestBodyValueFlag(true)
this._setStateForNamespace(nextProps.currentNamespace, {
lastUserEditedValue: nextProps.currentUserInputValue,
isModifiedValueSelected: newValue !== valueFromCurrentExample,
isModifiedValueSelected:
userHasEditedBody || newValue !== valueFromCurrentExample,
})
}
}

render() {
const { currentUserInputValue, examples, currentKey, getComponent } = this.props
const {
currentUserInputValue,
examples,
currentKey,
getComponent,
userHasEditedBody,
} = this.props
const {
lastDownstreamValue,
lastUserEditedValue,
Expand All @@ -212,9 +240,10 @@ export default class ExamplesSelectValueRetainer extends React.PureComponent {
!!lastUserEditedValue && lastUserEditedValue !== lastDownstreamValue
}
isValueModified={
currentUserInputValue !== undefined &&
isModifiedValueSelected &&
currentUserInputValue !== this._getCurrentExampleValue()
(currentUserInputValue !== undefined &&
isModifiedValueSelected &&
currentUserInputValue !== this._getCurrentExampleValue()) ||
userHasEditedBody
}
/>
)
Expand Down
30 changes: 19 additions & 11 deletions src/core/components/parameters/parameters.jsx
Expand Up @@ -75,30 +75,29 @@ export default class Parameters extends Component {
}

onChangeMediaType = ({ value, pathMethod }) => {
let { specSelectors, specActions, oas3Selectors, oas3Actions } = this.props
let targetMediaType = value
let currentMediaType = oas3Selectors.requestContentType(...pathMethod)
let schemaPropertiesMatch = specSelectors.isMediaTypeSchemaPropertiesEqual(pathMethod, currentMediaType, targetMediaType)
if (!schemaPropertiesMatch) {
oas3Actions.clearRequestBodyValue({ pathMethod })
let { specActions, oas3Selectors, oas3Actions } = this.props
const userHasEditedBody = oas3Selectors.hasUserEditedBody(...pathMethod)
const shouldRetainRequestBodyValue = oas3Selectors.shouldRetainRequestBodyValue(...pathMethod)
oas3Actions.setRequestContentType({ value, pathMethod })
oas3Actions.initRequestBodyValidateError({ pathMethod })
if (!userHasEditedBody) {
if(!shouldRetainRequestBodyValue) {
oas3Actions.setRequestBodyValue({ value: undefined, pathMethod })
}
specActions.clearResponse(...pathMethod)
specActions.clearRequest(...pathMethod)
specActions.clearValidateParams(pathMethod)
}
oas3Actions.setRequestContentType({ value, pathMethod })
oas3Actions.initRequestBodyValidateError({ pathMethod })
}

render() {

let {
onTryoutClick,
onCancelClick,
parameters,
allowTryItOut,
tryItOutEnabled,
specPath,

fn,
getComponent,
getConfigs,
Expand Down Expand Up @@ -131,6 +130,7 @@ export default class Parameters extends Component {
}, {}))
.reduce((acc, x) => acc.concat(x), [])

const retainRequestBodyValueFlagForOperation = (f) => oas3Actions.setRetainRequestBodyValueFlag({ value: f, pathMethod })
return (
<div className="opblock-section">
<div className="opblock-section-header">
Expand All @@ -155,7 +155,13 @@ export default class Parameters extends Component {
</div>
)}
{allowTryItOut ? (
<TryItOutButton enabled={tryItOutEnabled} onCancelClick={onCancelClick} onTryoutClick={onTryoutClick} />
<TryItOutButton
isOAS3={specSelectors.isOAS3()}
hasUserEditedBody={oas3Selectors.hasUserEditedBody(...pathMethod)}
enabled={tryItOutEnabled}
onCancelClick={this.props.onCancelClick}
onTryoutClick={onTryoutClick}
onResetClick={() => oas3Actions.setRequestBodyValue({ value: undefined, pathMethod })}/>
) : null}
</div>
{this.state.parametersVisible ? <div className="parameters-container">
Expand Down Expand Up @@ -219,6 +225,8 @@ export default class Parameters extends Component {
</div>
<div className="opblock-description-wrapper">
<RequestBody
setRetainRequestBodyValueFlag={retainRequestBodyValueFlagForOperation}
userHasEditedBody={oas3Selectors.hasUserEditedBody(...pathMethod)}
specPath={specPath.slice(0, -1).push("requestBody")}
requestBody={requestBody}
requestBodyValue={oas3Selectors.requestBodyValue(...pathMethod)}
Expand Down
15 changes: 13 additions & 2 deletions src/core/components/try-it-out-button.jsx
Expand Up @@ -5,24 +5,35 @@ export default class TryItOutButton extends React.Component {

static propTypes = {
onTryoutClick: PropTypes.func,
onResetClick: PropTypes.func,
onCancelClick: PropTypes.func,
enabled: PropTypes.bool, // Try it out is enabled, ie: the user has access to the form
hasUserEditedBody: PropTypes.bool, // Try it out is enabled, ie: the user has access to the form
isOAS3: PropTypes.bool, // Try it out is enabled, ie: the user has access to the form
};

static defaultProps = {
onTryoutClick: Function.prototype,
onCancelClick: Function.prototype,
onResetClick: Function.prototype,
enabled: false,
hasUserEditedBody: false,
isOAS3: false,
};

render() {
const { onTryoutClick, onCancelClick, enabled } = this.props
const { onTryoutClick, onCancelClick, onResetClick, enabled, hasUserEditedBody, isOAS3 } = this.props

const showReset = isOAS3 && hasUserEditedBody
return (
<div className="try-out">
<div className={showReset ? "try-out btn-group" : "try-out"}>
{
enabled ? <button className="btn try-out__btn cancel" onClick={ onCancelClick }>Cancel</button>
: <button className="btn try-out__btn" onClick={ onTryoutClick }>Try it out </button>

}
{
showReset && <button className="btn try-out__btn reset" onClick={ onResetClick }>Reset</button>
}
</div>
)
Expand Down
9 changes: 9 additions & 0 deletions src/core/plugins/oas3/actions.js
Expand Up @@ -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_REQUEST_BODY_VALUE_RETAIN_FLAG = "oas3_set_request_body_retain_flag"
export const UPDATE_REQUEST_BODY_INCLUSION = "oas3_set_request_body_inclusion"
export const UPDATE_ACTIVE_EXAMPLES_MEMBER = "oas3_set_active_examples_member"
export const UPDATE_REQUEST_CONTENT_TYPE = "oas3_set_request_content_type"
Expand All @@ -26,6 +27,14 @@ export function setRequestBodyValue ({ value, pathMethod }) {
}
}

export const setRetainRequestBodyValueFlag = ({ value, pathMethod }) => {
return {
type: UPDATE_REQUEST_BODY_VALUE_RETAIN_FLAG,
payload: { value, pathMethod }
}
}


export function setRequestBodyInclusion ({ value, pathMethod, name }) {
return {
type: UPDATE_REQUEST_BODY_INCLUSION,
Expand Down
5 changes: 3 additions & 2 deletions src/core/plugins/oas3/components/request-body-editor.jsx
Expand Up @@ -17,6 +17,7 @@ export default class RequestBodyEditor extends PureComponent {

static defaultProps = {
onChange: NOOP,
userHasEditedBody: false,
};

constructor(props, context) {
Expand Down Expand Up @@ -65,7 +66,7 @@ export default class RequestBodyEditor extends PureComponent {
})
}



if(!nextProps.value && nextProps.defaultValue && !!this.state.value) {
// if new value is falsy, we have a default, AND the falsy value didn't
Expand All @@ -77,7 +78,7 @@ export default class RequestBodyEditor extends PureComponent {
render() {
let {
getComponent,
errors
errors,
} = this.props

let {
Expand Down
9 changes: 8 additions & 1 deletion src/core/plugins/oas3/components/request-body.jsx
Expand Up @@ -4,7 +4,7 @@ import ImPropTypes from "react-immutable-proptypes"
import { Map, OrderedMap, List } from "immutable"
import { getCommonExtensions, getSampleSchema, stringify, isEmptyValue } from "core/utils"

function getDefaultRequestBodyValue(requestBody, mediaType, activeExamplesKey) {
export const getDefaultRequestBodyValue = (requestBody, mediaType, activeExamplesKey) => {
const mediaTypeValue = requestBody.getIn(["content", mediaType])
const schema = mediaTypeValue.get("schema").toJS()

Expand Down Expand Up @@ -32,6 +32,7 @@ function getDefaultRequestBodyValue(requestBody, mediaType, activeExamplesKey) {


const RequestBody = ({
userHasEditedBody,
requestBody,
requestBodyValue,
requestBodyInclusionSetting,
Expand All @@ -47,6 +48,7 @@ const RequestBody = ({
onChangeIncludeEmpty,
activeExamplesKey,
updateActiveExamplesKey,
setRetainRequestBodyValueFlag
}) => {
const handleFile = (e) => {
onChange(e.target.files[0])
Expand Down Expand Up @@ -222,13 +224,15 @@ const RequestBody = ({
{
examplesForMediaType ? (
<ExamplesSelectValueRetainer
userHasEditedBody={userHasEditedBody}
examples={examplesForMediaType}
currentKey={activeExamplesKey}
currentUserInputValue={requestBodyValue}
onSelect={handleExamplesSelect}
updateValue={onChange}
defaultToFirstExample={true}
getComponent={getComponent}
setRetainRequestBodyValueFlag={setRetainRequestBodyValueFlag}
/>
) : null
}
Expand Down Expand Up @@ -276,6 +280,7 @@ const RequestBody = ({
}

RequestBody.propTypes = {
userHasEditedBody: PropTypes.bool.isRequired,
requestBody: ImPropTypes.orderedMap.isRequired,
requestBodyValue: ImPropTypes.orderedMap.isRequired,
requestBodyInclusionSetting: ImPropTypes.Map.isRequired,
Expand All @@ -291,6 +296,8 @@ RequestBody.propTypes = {
specPath: PropTypes.array.isRequired,
activeExamplesKey: PropTypes.string,
updateActiveExamplesKey: PropTypes.func,
setRetainRequestBodyValueFlag: PropTypes.func,
oas3Actions: PropTypes.object.isRequired
}

export default RequestBody
6 changes: 5 additions & 1 deletion src/core/plugins/oas3/reducers.js
Expand Up @@ -10,7 +10,7 @@ import {
UPDATE_RESPONSE_CONTENT_TYPE,
SET_REQUEST_BODY_VALIDATE_ERROR,
CLEAR_REQUEST_BODY_VALIDATE_ERROR,
CLEAR_REQUEST_BODY_VALUE,
CLEAR_REQUEST_BODY_VALUE, UPDATE_REQUEST_BODY_VALUE_RETAIN_FLAG,
} from "./actions"

export default {
Expand Down Expand Up @@ -42,6 +42,10 @@ export default {
})
return state.setIn(["requestData", path, method, "bodyValue"], newVal)
},
[UPDATE_REQUEST_BODY_VALUE_RETAIN_FLAG]: (state, { payload: { value, pathMethod } } ) =>{
let [path, method] = pathMethod
return state.setIn(["requestData", path, method, "retainBodyValue"], value)
},
[UPDATE_REQUEST_BODY_INCLUSION]: (state, { payload: { value, pathMethod, name } } ) =>{
let [path, method] = pathMethod
return state.setIn( [ "requestData", path, method, "bodyInclusion", name ], value)
Expand Down

0 comments on commit e877580

Please sign in to comment.