From 6d6727dc27719ef2742127cc559aa1eeee2ad745 Mon Sep 17 00:00:00 2001 From: Chris Austin Date: Fri, 2 Feb 2018 03:11:19 -0800 Subject: [PATCH] New: Allow the element to be controlled (fixes #21) (#23) * New: Allow the element to be controlled (fixes #21) This commit implements functionality that allows the element rendered by the MUI component to be controlled externally. This is done by passing the value of the element as the 'inputValue' prop on the component we render. This commit satisfies the work of: #21 modified: README.md modified: demo/Demo.jsx copied: demo/Demo.jsx -> demo/DemoBasic.jsx new file: demo/DemoControlledInput.jsx new file: demo/rootReducer.js modified: package.json modified: src/MUIPlacesAutocomplete.jsx modified: test/test.jsx modified: yarn.lock * New: Allow the element to be controlled, part deux (fixes #21) PR #22 sought to introduce new functionality that would allow the element rendered by the MUI component to be controlled externally. After some initial feedback and talking to Matt about it some it was obvious that I went about implementing this functionality incorrectly in commit e27d46596523f0206fef05b00641fe94f7c1c39e. After re-reading the docs for Downshift and MUI this commit re-implements the functionality in a more consistent fashion with how Downshift/MUI are documented/expected to be used. modified: README.md modified: demo/DemoControlledInput.jsx modified: src/MUIPlacesAutocomplete.jsx modified: test/test.jsx --- README.md | 29 +++++++- demo/Demo.jsx | 99 +++++++++++++++++++-------- demo/DemoBasic.jsx | 48 +++++++++++++ demo/DemoControlledInput.jsx | 122 ++++++++++++++++++++++++++++++++++ demo/rootReducer.js | 17 +++++ package.json | 3 + src/MUIPlacesAutocomplete.jsx | 31 ++++++--- test/test.jsx | 38 ++++++++++- yarn.lock | 49 ++++++++++++-- 9 files changed, 392 insertions(+), 44 deletions(-) create mode 100644 demo/DemoBasic.jsx create mode 100644 demo/DemoControlledInput.jsx create mode 100644 demo/rootReducer.js diff --git a/README.md b/README.md index 4a8854e..02688c1 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,12 @@ [![Travis CI](https://img.shields.io/travis/Giners/mui-places-autocomplete/master.svg)](https://travis-ci.org/Giners/mui-places-autocomplete/builds) # Features -* Easy to use input for searching for places +* Easy-to-use component for searching for places * Place suggestions displayed in realtime +* Input state can be controlled externally * Google Material Design styling provided by next version of Material-UI (v1) * Safe to render on the server (SSR) +* Integrates with other 3rd party libraries (e.g. [Redux Form](#advancedUsage)) * Thoroughly tested # Installation @@ -71,6 +73,11 @@ class Example extends React.Component { export default Example ``` + +### Advanced Usage + +* [DemoControlledInput.jsx](https://github.com/Giners/mui-places-autocomplete/blob/master/demo/DemoControlledInput.jsx) - Example that shows how to control the `` element as well as integrate with Redux Form. + ### Setup This component relies on some basic setup before usage. It makes use of services provided by Google. To properly make use of the services you will need to do three things: @@ -96,6 +103,7 @@ This component also has testing which makes use of the Places library in the Goo | :--- | :--- | :---: | :--- | | [`onSuggestionSelected`](#onSuggestionSelected) | Function | ✓ | Callback that provides the selected suggestion. | | [`renderTarget`](#renderTarget) | Function | ✓ | Renders the components/elements that you would like to have the list of suggestions popover. | +| [`textFieldProps`](#textFieldProps) | Object | | Props that will be spread onto a `` MUI component that is responsible for rendering the `` element. If you would like to [control the state of the `` element](#textFieldPropsValueProp) externally you must set the `value` key on the object passed to `textFieldProps`. | #### onSuggestionSelected (required) @@ -111,6 +119,25 @@ function onSuggestionSelected(suggestion) This function is invoked during rendering. It ought to return the components/elements that you want the list of suggestions to render (pop) over. + +#### textFieldProps + +A MUI [``](https://material-ui-next.com/api/text-field/) component is used to render the `` element. It can be customized to meet your needs by supplying an object to the `textFieldProps` prop. All properties on the object supplied to the `textFieldProps` prop will be spread onto the `` component. You can read more about the props that the `` component accepts here: [`` API documentation](https://material-ui-next.com/api/text-field/) + + +##### `textFieldProps.value` `` control prop + +To help meet your needs the state of the `` element can be controlled externally. It is also useful if you would like to integrate `` with other 3rd party libraries such as [Redux Form](https://redux-form.com). To control the state of the `` element you must set the `value` property on the object passed to the `textFieldProps` prop. + +```javascript +// 'getState()' can return state from a React component, your app (e.g via Redux), some other source, etc. +const { inputValue } = getState() + + +``` + +If you would like to have consistency between the controlled `` elements state as well as any suggestions that are selected then you need to update the controlled state of the `` element when a [suggestion is selected](#onSuggestionSelected). There is an example of how to do this in the [advanced usage section](#advancedUsage). + # Feedback This was my first open-source project that I undertook while I was teaching myself full-stack development (JS (ES6)/HTML/CSS, Node, Express, NoSQL (DynamoDB), GraphQL, React, Redux, Material-UI, etc.). I'm very interested in taking feedback to either improve my skills (i.e. correct errors :)) or to make this component more useful in general/for your use case. Please feel free to provide feedback by opening an issue or messaging me. diff --git a/demo/Demo.jsx b/demo/Demo.jsx index c9a57c4..4e63bef 100644 --- a/demo/Demo.jsx +++ b/demo/Demo.jsx @@ -1,45 +1,88 @@ -import React from 'react' -import Snackbar from 'material-ui/Snackbar' -import MUIPlacesAutocomplete from './../dist' +// ESLint rule config for demo file +/* eslint import/no-extraneous-dependencies: 0 */ +// Our demo files showcase how to integrates with other 3rd party libraries often. The 3rd party +// library dependencies are added to the 'devDependencies' of 'package.json' so disable the +// 'import/no-extraneous-dependencies' rule in this demo file so we don't have to hear about them +// not being in 'dependencies' all the time. +import React, { createElement } from 'react' +import PropTypes from 'prop-types' +import { createStore } from 'redux' +import { Provider } from 'react-redux' +import { withStyles } from 'material-ui/styles' +import Grid from 'material-ui/Grid' +import { MenuItem } from 'material-ui/Menu' +import Select from 'material-ui/Select' +import Typography from 'material-ui/Typography' + +import rootReducer from './rootReducer' +import DemoBasic from './DemoBasic' +import DemoControlledInput from './DemoControlledInput' + +// Map of demos that one can select to view +const demos = { + [DemoBasic.name]: { description: DemoBasic.description, component: DemoBasic }, + [DemoControlledInput.name]: { + description: DemoControlledInput.description, + component: DemoControlledInput, + }, +} + +const store = createStore(rootReducer) + +const demoStyles = { + container: { + marginTop: 32, + }, +} class Demo extends React.Component { constructor() { super() - this.state = { open: false, suggestion: null } - - this.onClose = this.onClose.bind(this) - this.onSuggestionSelected = this.onSuggestionSelected.bind(this) - } + this.state = { selectedDemo: DemoBasic } - onClose() { - this.setState({ open: false, suggestion: null }) + this.onChange = this.onChange.bind(this) } - onSuggestionSelected(suggestion) { - // Add your business logic here. In this case we simply set our state to show our . - this.setState({ open: true, suggestion }) + onChange(event) { + this.setState({ selectedDemo: demos[event.target.value].component }) } render() { - const { open, suggestion } = this.state + const { selectedDemo } = this.state + const { classes: { container } } = this.props return ( -
- (
)} - /> - Selected suggestion: {suggestion.description}) : ''} - /> -
+ +
+ + + + Select a demo + + + + +
+ {createElement(selectedDemo)} +
+
+
) } } -export default Demo +Demo.propTypes = { + classes: PropTypes.shape({ + container: PropTypes.string, + }).isRequired, +} + +export default withStyles(demoStyles)(Demo) diff --git a/demo/DemoBasic.jsx b/demo/DemoBasic.jsx new file mode 100644 index 0000000..e3a3d1b --- /dev/null +++ b/demo/DemoBasic.jsx @@ -0,0 +1,48 @@ +import React from 'react' +import Snackbar from 'material-ui/Snackbar' +import MUIPlacesAutocomplete from './../dist' + +class DemoBasic extends React.Component { + constructor() { + super() + + this.state = { open: false, suggestion: null } + + this.onClose = this.onClose.bind(this) + this.onSuggestionSelected = this.onSuggestionSelected.bind(this) + } + + onClose() { + this.setState({ open: false }) + } + + onSuggestionSelected(suggestion) { + // Add your business logic here. In this case we simply set our state to show our . + this.setState({ open: true, suggestion }) + } + + render() { + const { open, suggestion } = this.state + + return ( +
+ (
)} + /> + Selected suggestion: {suggestion.description}) : ''} + style={{ width: '70vw' }} + /> +
+ ) + } +} + +DemoBasic.description = 'Basic usage' + +export default DemoBasic diff --git a/demo/DemoControlledInput.jsx b/demo/DemoControlledInput.jsx new file mode 100644 index 0000000..ceda03e --- /dev/null +++ b/demo/DemoControlledInput.jsx @@ -0,0 +1,122 @@ +// ESLint rule config for demo file +/* eslint import/no-extraneous-dependencies: 0 */ +// Our demo files showcase how to integrates with other 3rd party libraries often. The 3rd party +// library dependencies are added to the 'devDependencies' of 'package.json' so disable the +// 'import/no-extraneous-dependencies/ rule in this demo file so we don't have to hear about them +// not being in 'dependencies' all the time. +import React from 'react' +import PropTypes from 'prop-types' +import { Field, reduxForm } from 'redux-form' +import Button from 'material-ui/Button' +import Snackbar from 'material-ui/Snackbar' +import MUIPlacesAutocomplete from './../dist' + +// Stateless function that we pass to the 'component' prop of the to render +// . By passing the props that passes our stateless function as the +// 'textFieldProps' on we are essentially stating that the would +// like to control the state of the resulting element that gets rendered. This is due to the +// addition of the 'value' property that gets spread onto the object passed to the 'textFieldProps' +// prop. +// +// It is important to define this stateless function outside of the method that renders the actual +// to avoid causing it to re-render. For more info please refer to the following docs: +// https://redux-form.com/7.2.0/docs/api/field.md/#2-a-stateless-function +const renderMUIPlacesAutocomplete = ({ onSuggestionSelected, ...other }) => ( + ( +
+ +
+ )} + textFieldProps={{ ...other }} + /> +) + +renderMUIPlacesAutocomplete.propTypes = { + onSuggestionSelected: PropTypes.func.isRequired, + input: PropTypes.object.isRequired, +} + +const DemoControlledInput = (props) => { + const { change, handleSubmit } = props + + // Since we are controlling the state of the element via Redux Form we want to ensure that + // the elements state is consistent with any suggestions a user may select. To do so we + // dispatch an action to Redux Form to update the with a name of 'demoField'. For more + // info see: https://redux-form.com/7.2.1/docs/api/actioncreators.md/ + const onSuggestionSelected = (suggestion) => { + change('demoField', suggestion.description) + } + + return ( +
+ + + ) +} + +DemoControlledInput.propTypes = { + // Injected by Redux Form + change: PropTypes.func.isRequired, + handleSubmit: PropTypes.func.isRequired, +} + +const ConnectedDemoControlledInput = reduxForm({ + form: 'DemoControlledInput', +})(DemoControlledInput) + +class DemoControlledInputContainer extends React.Component { + constructor() { + super() + + this.state = { open: false, formValues: { } } + + this.onClose = this.onClose.bind(this) + this.onSubmit = this.onSubmit.bind(this) + } + + onClose() { + this.setState({ open: false }) + } + + onSubmit(formValues) { + this.setState({ open: true, formValues }) + } + + render() { + const { open, formValues } = this.state + + return ( +
+ + +
+ ) + } +} + +DemoControlledInputContainer.description = '"Controlled" input state via Redux Form' + +export default DemoControlledInputContainer diff --git a/demo/rootReducer.js b/demo/rootReducer.js new file mode 100644 index 0000000..7898c45 --- /dev/null +++ b/demo/rootReducer.js @@ -0,0 +1,17 @@ +// ESLint rule config for demo file +/* eslint import/no-extraneous-dependencies: 0 */ +// Our demo files showcase how to integrates with other 3rd party libraries often. The 3rd party +// library dependencies are added to the 'devDependencies' of 'package.json' so disable the +// 'import/no-extraneous-dependencies/ rule in this demo file so we don't have to hear about them +// not being in 'dependencies' all the time. +import { combineReducers } from 'redux' +import { reducer as FormReducer } from 'redux-form' + +// If any demos need reducers they ought to add them in here. Having a separate file for our +// reducers seems like a bit much, but at the same time having them in the main component +// seems wrong as well... +const rootReducer = combineReducers({ + form: FormReducer, +}) + +export default rootReducer diff --git a/package.json b/package.json index 7e64399..bf1a756 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,9 @@ "raf": "^3.4.0", "react": "^16.0.0", "react-dom": "^16.0.0", + "react-redux": "^5.0.6", + "redux": "^3.7.2", + "redux-form": "^7.2.1", "semantic-release": "^8.2.0", "webpack": "^3.7.1", "webpack-dev-server": "^2.9.1", diff --git a/src/MUIPlacesAutocomplete.jsx b/src/MUIPlacesAutocomplete.jsx index 1ffa3d0..330c52e 100644 --- a/src/MUIPlacesAutocomplete.jsx +++ b/src/MUIPlacesAutocomplete.jsx @@ -225,23 +225,23 @@ export default class MUIPlacesAutocomplete extends React.Component { highlightedIndex, }) { const { suggestions } = this.state - const { renderTarget } = this.props + const { renderTarget, textFieldProps } = this.props // We set the value of 'tag' on the component to false to allow the rendering of // children instead of a specific DOM element. // // We only want to render our suggestions container if Downshift says we are open AND there are - // suggestions to actually + // suggestions to actually render. There may not be suggestions yet due to the async nature of + // requesting them from the Google Maps/Places service. + // + // Provide an 'id' to the input props (see ) to accommodate SSR. If we don't then we + // will see checksum errors with the 'id' prop of the element not matching what was + // rendered on the server vs. what was rendered on the client after rehydration due to automatic + // 'id' prop generation by . return (
- + {renderTarget()} {isOpen ? MUIPlacesAutocomplete.renderSuggestionsContainer( suggestions, @@ -254,12 +254,20 @@ export default class MUIPlacesAutocomplete extends React.Component { } render() { + // Check to see if a consumer would like to exert control on the elements state. If so + // we pass it to the component as the 'inputValue' prop to provide control of the + // elements state to the consumer. + const controlProps = this.props.textFieldProps && this.props.textFieldProps.value ? + { inputValue: this.props.textFieldProps.value } : + { } + return ( (suggestion ? suggestion.description : '')} render={this.renderAutocomplete} + {...controlProps} /> ) } @@ -268,4 +276,9 @@ export default class MUIPlacesAutocomplete extends React.Component { MUIPlacesAutocomplete.propTypes = { onSuggestionSelected: PropTypes.func.isRequired, renderTarget: PropTypes.func.isRequired, + textFieldProps: PropTypes.object, +} + +MUIPlacesAutocomplete.defaultProps = { + textFieldProps: { autoFocus: false, placeholder: 'Search for a place', fullWidth: true }, } diff --git a/test/test.jsx b/test/test.jsx index afac352..167d753 100644 --- a/test/test.jsx +++ b/test/test.jsx @@ -199,10 +199,44 @@ describe('React component test: ', function () { }) describe('Provides expected UX:', function () { + it('\'value\' input prop can be used to control ', function () { + const controlValue = 'LOL Bananas' + + mpaWrapper.setProps({ textFieldProps: { value: controlValue } }) + + expect(mpaWrapper.find(`input[value="${controlValue}"]`).length).to.equal(1) + }) + + it('\'onChange\' input prop invoked when input changed', function (done) { + // Setup our wrapper to signal that our test has completed successfully + const testSuccessCB = (inputValue) => { + try { + expect(inputValue).to.be.an('object') + expect(inputValue.target).to.exist + expect(inputValue.target.value).to.exist + expect(inputValue.target.value).to.equal(searchInputValue) + } catch (e) { + done(e) + return + } + + done() + } + + mpaWrapper.setProps({ textFieldProps: { onChange: testSuccessCB } }) + + // Signal to we would like to be called back when the input changes + mpaWrapper.find('input').simulate('change', { target: { value: searchInputValue } }) + }) + it('\'onSuggestionSelected\' invoked when suggestion selected', function (done) { // Setup our wrapper to signal that our test has completed successfully const testSuccessCB = (suggestion) => { - expect(suggestion).to.exist + try { + expect(suggestion).to.exist + } catch (e) { + done(e) + } done() } @@ -217,7 +251,7 @@ describe('React component test: ', function () { // the Google AutocompleteService... mpaWrapper.setState({ suggestions: [{ description: 'Bellingham, WA, United States' }] }) - // Now simulate a click on a rendered suggestion which ought to signal + // Now simulate a click on a rendered suggestion which ought to signal our success callback const miWrapper = mpaWrapper.find('MenuItem') expect(miWrapper.length).to.equal(1) diff --git a/yarn.lock b/yarn.lock index 4f73703..f269923 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2094,6 +2094,10 @@ es5-ext@^0.10.14, es5-ext@^0.10.9, es5-ext@~0.10.14: es6-iterator "~2.0.1" es6-symbol "~3.1.1" +es6-error@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/es6-error/-/es6-error-4.1.1.tgz#9e3af407459deed47e9a91f9b885a84eb05c561d" + es6-iterator@^2.0.1, es6-iterator@~2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/es6-iterator/-/es6-iterator-2.0.1.tgz#8e319c9f0453bf575d374940a655920e59ca5512" @@ -3001,7 +3005,7 @@ hoek@4.x.x: version "4.2.0" resolved "https://registry.yarnpkg.com/hoek/-/hoek-4.2.0.tgz#72d9d0754f7fe25ca2d01ad8f8f9a9449a89526d" -hoist-non-react-statics@^2.3.1: +hoist-non-react-statics@^2.2.1, hoist-non-react-statics@^2.3.1: version "2.3.1" resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-2.3.1.tgz#343db84c6018c650778898240135a1420ee22ce0" @@ -3238,7 +3242,7 @@ into-stream@^3.1.0: from2 "^2.1.1" p-is-promise "^1.1.0" -invariant@^2.2.0, invariant@^2.2.2: +invariant@^2.0.0, invariant@^2.2.0, invariant@^2.2.2: version "2.2.2" resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.2.tgz#9e1f56ac0acdb6bf303306f338be3b204ae60360" dependencies: @@ -3841,6 +3845,10 @@ locate-path@^2.0.0: p-locate "^2.0.0" path-exists "^3.0.0" +lodash-es@^4.17.3, lodash-es@^4.2.0, lodash-es@^4.2.1: + version "4.17.4" + resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.4.tgz#dcc1d7552e150a0640073ba9cb31d70f032950e7" + lodash._baseassign@^3.0.0: version "3.2.0" resolved "https://registry.yarnpkg.com/lodash._baseassign/-/lodash._baseassign-3.2.0.tgz#8c38a099500f215ad09e59f1722fd0c52bfe0a4e" @@ -4821,7 +4829,7 @@ promise@^7.1.1: dependencies: asap "~2.0.3" -prop-types@^15.5.10, prop-types@^15.5.8, prop-types@^15.6.0: +prop-types@^15.5.10, prop-types@^15.5.8, prop-types@^15.5.9, prop-types@^15.6.0: version "15.6.0" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.6.0.tgz#ceaf083022fc46b4a35f69e13ef75aed0d639856" dependencies: @@ -5003,6 +5011,17 @@ react-popper@^0.7.4: popper.js "^1.12.5" prop-types "^15.5.10" +react-redux@^5.0.6: + version "5.0.6" + resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-5.0.6.tgz#23ed3a4f986359d68b5212eaaa681e60d6574946" + dependencies: + hoist-non-react-statics "^2.2.1" + invariant "^2.0.0" + lodash "^4.2.0" + lodash-es "^4.2.0" + loose-envify "^1.1.0" + prop-types "^15.5.10" + react-scrollbar-size@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/react-scrollbar-size/-/react-scrollbar-size-2.0.2.tgz#237dd091fa39dec8e3a6f720807787703c5ebf9e" @@ -5114,6 +5133,28 @@ redent@^1.0.0: indent-string "^2.1.0" strip-indent "^1.0.1" +redux-form@^7.2.1: + version "7.2.1" + resolved "https://registry.yarnpkg.com/redux-form/-/redux-form-7.2.1.tgz#011f6ff0cf050552a4a182a271f22217f4584684" + dependencies: + deep-equal "^1.0.1" + es6-error "^4.1.1" + hoist-non-react-statics "^2.3.1" + invariant "^2.2.2" + is-promise "^2.1.0" + lodash "^4.17.3" + lodash-es "^4.17.3" + prop-types "^15.5.9" + +redux@^3.7.2: + version "3.7.2" + resolved "https://registry.yarnpkg.com/redux/-/redux-3.7.2.tgz#06b73123215901d25d065be342eb026bc1c8537b" + dependencies: + lodash "^4.2.1" + lodash-es "^4.2.1" + loose-envify "^1.1.0" + symbol-observable "^1.0.3" + regenerate@^1.2.1: version "1.3.3" resolved "https://registry.yarnpkg.com/regenerate/-/regenerate-1.3.3.tgz#0c336d3980553d755c39b586ae3b20aa49c82b7f" @@ -5751,7 +5792,7 @@ symbol-observable@^0.2.2: version "0.2.4" resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-0.2.4.tgz#95a83db26186d6af7e7a18dbd9760a2f86d08f40" -symbol-observable@^1.0.4: +symbol-observable@^1.0.3, symbol-observable@^1.0.4: version "1.1.0" resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.1.0.tgz#5c68fd8d54115d9dfb72a84720549222e8db9b32"