/** * @prettier */ import React from "react" import { Map, List } from "immutable" import PropTypes from "prop-types" import ImPropTypes from "react-immutable-proptypes" import { stringify } from "core/utils" // This stateful component lets us avoid writing competing values (user // modifications vs example values) into global state, and the mess that comes // with that: tracking which of the two values are currently used for // Try-It-Out, which example a modified value came from, etc... // // The solution here is to retain the last user-modified value in // ExamplesSelectValueRetainer's component state, so that our global state can stay // clean, always simply being the source of truth for what value should be both // displayed to the user and used as a value during request execution. // // This approach/tradeoff was chosen in order to encapsulate the particular // logic of Examples within the Examples component tree, and to avoid // regressions within our current implementation elsewhere (non-Examples // definitions, OpenAPI 2.0, etc). A future refactor to global state might make // this component unnecessary. // // TL;DR: this is not our usual approach, but the choice was made consciously. // Note that `currentNamespace` isn't currently used anywhere! const stringifyUnlessList = input => List.isList(input) ? input : stringify(input) export default class ExamplesSelectValueRetainer extends React.PureComponent { static propTypes = { 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", ...args ), updateValue: (...args) => console.log( // eslint-disable-line no-console "ExamplesSelectValueRetainer: no `updateValue` function was provided", ...args ), } constructor(props) { super(props) const valueFromExample = this._getCurrentExampleValue() this.state = { // user edited: last value that came from the world around us, and didn't // match the current example's value // internal: last value that came from user selecting an Example [props.currentNamespace]: Map({ lastUserEditedValue: this.props.currentUserInputValue, lastDownstreamValue: valueFromExample, isModifiedValueSelected: // valueFromExample !== undefined && this.props.userHasEditedBody || this.props.currentUserInputValue !== valueFromExample, }), } } componentWillUnmount() { this.props.setRetainRequestBodyValueFlag(false) } _getStateForCurrentNamespace = () => { const { currentNamespace } = this.props return (this.state[currentNamespace] || Map()).toObject() } _setStateForCurrentNamespace = obj => { const { currentNamespace } = this.props return this._setStateForNamespace(currentNamespace, obj) } _setStateForNamespace = (namespace, obj) => { const oldStateForNamespace = this.state[namespace] || Map() const newStateForNamespace = oldStateForNamespace.mergeDeep(obj) return this.setState({ [namespace]: newStateForNamespace, }) } _isCurrentUserInputSameAsExampleValue = () => { const { currentUserInputValue } = this.props const valueFromExample = this._getCurrentExampleValue() return valueFromExample === currentUserInputValue } _getValueForExample = (exampleKey, props) => { // props are accepted so that this can be used in UNSAFE_componentWillReceiveProps, // which has access to `nextProps` const { examples } = props || this.props return stringifyUnlessList( (examples || Map({})).getIn([exampleKey, "value"]) ) } _getCurrentExampleValue = props => { // props are accepted so that this can be used in UNSAFE_componentWillReceiveProps, // which has access to `nextProps` const { currentKey } = props || this.props return this._getValueForExample(currentKey, props || this.props) } _onExamplesSelect = (key, { isSyntheticChange } = {}, ...otherArgs) => { const { onSelect, updateValue, currentUserInputValue, userHasEditedBody, } = this.props const { lastUserEditedValue } = this._getStateForCurrentNamespace() const valueFromExample = this._getValueForExample(key) if (key === "__MODIFIED__VALUE__") { updateValue(stringifyUnlessList(lastUserEditedValue)) return this._setStateForCurrentNamespace({ isModifiedValueSelected: true, }) } if (typeof onSelect === "function") { onSelect(key, { isSyntheticChange }, ...otherArgs) } this._setStateForCurrentNamespace({ lastDownstreamValue: valueFromExample, isModifiedValueSelected: (isSyntheticChange && userHasEditedBody) || (!!currentUserInputValue && currentUserInputValue !== valueFromExample), }) // we never want to send up value updates from synthetic changes if (isSyntheticChange) return if (typeof updateValue === "function") { updateValue(stringifyUnlessList(valueFromExample)) } } UNSAFE_componentWillReceiveProps(nextProps) { // update `lastUserEditedValue` as new currentUserInput values come in const { currentUserInputValue: newValue, examples, onSelect, userHasEditedBody, } = nextProps const { lastUserEditedValue, lastDownstreamValue, } = this._getStateForCurrentNamespace() const valueFromCurrentExample = this._getValueForExample( nextProps.currentKey, nextProps ) const examplesMatchingNewValue = examples.filter( (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 stringify(example.get("value")) === newValue ) if (examplesMatchingNewValue.size) { let key if(examplesMatchingNewValue.has(nextProps.currentKey)) { key = nextProps.currentKey } else { key = examplesMatchingNewValue.keySeq().first() } onSelect(key, { isSyntheticChange: true, }) } else if ( newValue !== this.props.currentUserInputValue && // value has changed 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: userHasEditedBody || newValue !== valueFromCurrentExample, }) } } render() { const { currentUserInputValue, examples, currentKey, getComponent, userHasEditedBody, } = this.props const { lastDownstreamValue, lastUserEditedValue, isModifiedValueSelected, } = this._getStateForCurrentNamespace() const ExamplesSelect = getComponent("ExamplesSelect") return ( ) } }