From 4ec410ad681022316977dec78875b7e6e4d9fb36 Mon Sep 17 00:00:00 2001 From: Chris Austin Date: Wed, 17 Jan 2018 13:40:54 -0800 Subject: [PATCH] Fix (#13, #14): Render suggestions over (popover) components/elements (#15) * Fix: Don't set height on suggestions container (fixes #13) This commit changes the styling of the suggestions container so that it no longer has a set height of 200 units. This caused the suggestions container to take up space when the suggestions container wasn't rendered. This commit fixes bug: #13 modified: src/MUIPlacesAutocomplete.jsx * Fix: Render suggestions container over target (fixes #14) This commit changes the behavior of how we render our suggestions container. In the past I naively believed that when our suggestions container was rendered it would magically "hide" elements that came after it. As I'm learning this isn't the case. As a result of my inexperience the suggestions container would "blend" with elements of the same "depth". This is all documented in bug #14. We now render the suggestions container over a target provided to us. We now get the desired behavior where the suggestions container renders over elements and properly receives all of the focus/events. While fixing how we rendered the suggestions container though I took a big departure from how I was doing things in the past. In the past I used the 'react-autosuggest' to provide autocomplete functionality to our component. When trying to use it to properly render our suggestions container (i.e. render over/popover a target) I found it increasingly difficult to work with. As a result I looked for other solutions and decided to use 'downshift'. 'downshift' was a lot easier to work with and "did more" for me out of the box. I want to make clear for anyone who may read this in the future that the 'react-autosuggest' package is great and people ought to look into using it to meet their needs. If I were more technically adept then I may have been able to make 'react-autosuggest' work. In conjunction with 'downshift' we also use 'react-popper' which is a React wrapper around PopperJS to provide the popover (modal) functionality of our suggestions container. This commit fixes bug: #14 modified: package.json modified: src/MUIPlacesAutocomplete.jsx modified: src/index.js modified: test/setup-dom.js modified: test/test.jsx modified: test/test.jsx.snap modified: yarn.lock * Docs: Link to my (Chris') MIT license This commit modifies the license section of the README to link to my MIT license hosted at https://mit-license.org/ (via https://github.com/remy/mit-license). modified: README.md * Chore: Add test coverage to verify rendered suggestions are unique This commit adds test coverage to verify that non-unique predictions returned by the Google Maps Autocomplete Service are rendered as unique suggestions. We refactored the code some by moving the logic of creating a list of unique suggestions out of the 'onInputValueChange()' method into the 'renderSuggestionsContainer()' method. I did this as it allows for more clear testing of ideas. While adding test coverage I was conflating the issues of having predictions returned async/React setting state async and having all rendered suggestions be unique. This caused me to try some weird testing patterns where I was waiting for the async React state setting to complete and then verify my suggestions. It seems doing this is less performant/optimized but maybe I was being premature in that regard and this is going to lead to better debugging/maintenance. modified: src/MUIPlacesAutocomplete.jsx modified: test/test.jsx modified: test/test.jsx.snap * Docs: Update README to show 'renderTarget' prop usage This commit updates the README to show how to use the 'renderTarget' prop as it is required. The 'renderTarget' prop takes a function which renders components/elements that the rendered suggestions list ought to popover. modified: README.md * Chore: Update demo to make use of 'renderTarget' prop Commit e40dbec5ba08bb3b8d680da4332afe0bfaa8b0e7 added a new library named 'react-popper' (wrapper around PopperJS) to help us render over/popover the list of suggestions over a component/element. It also added a prop to the component named 'renderTarget' that renders said components/elements to render the list of suggestions over. This commit updates the demo to make use of the newly required 'renderTarget' prop. We simply provided a self-closing
which is unexciting. In the future maybe we can invest sometime in the demo to provide something like a form for providing address information that autofills when a suggestion is selected. modified: demo/Demo.jsx * Docs: Remove erroneous chars in entry in table under Usage->Props Remove erroneous '[]' chars around the 'renderTarget' entry in the table that describes the props on the component. modified: README.md --- README.md | 57 +- demo/Demo.jsx | 2 +- package.json | 8 +- src/MUIPlacesAutocomplete.jsx | 306 ++--- src/index.js | 4 +- test/setup-dom.js | 62 +- test/test.jsx | 155 ++- test/test.jsx.snap | 2084 ++++++++++----------------------- yarn.lock | 54 +- 9 files changed, 994 insertions(+), 1738 deletions(-) diff --git a/README.md b/README.md index 6efc5b5..e77ece9 100644 --- a/README.md +++ b/README.md @@ -21,50 +21,59 @@ or npm install mui-places-autocomplete --save --ignore-scripts ``` -Note that if you exclude the `--ignore-scripts` option when installing a package then the `prepublish` script in `package.json` is ran after installing locally. Tests are ran as part of the `prepublish` script and they will fail if you haven't yet set a Google API key to the enivronment variable `GOOGLE_API_KEY` (see setup section). +Note that if you exclude the `--ignore-scripts` option when installing a package then the `prepublish` script in `package.json` is ran after installing locally. Tests are ran as part of the `prepublish` script and they will fail if you haven't yet set a Google API key to the enivronment variable `GOOGLE_API_KEY` (see [setup section](#setup)). -# 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: -1. Enable the Google Places API Web Service -2. Enable the Google Maps JavaScript API -3. Obtain a Google API key - -You can do all of these things from your Google developers console here: https://console.developers.google.com +# Demo +**Note that you must have followed the [setup steps](#setup) to run the demo as it depends on services provided by Google.** -The component relies on the Places library in the Google Maps JavaScript API. To load the Places library on the client you must add the following to the HTML document you deliver to your clients: +To see a demo of this component locally clone this repository and run: -```html - +``` +yarn demo ``` -Be sure that you replace `YOUR_API_KEY` with the one you just created or obtained previously. +or -This component also has testing which makes use of the Places library in the Google Maps JavaScript API. Rather than loading the Places library it uses a module provided by Google. It also requires an API key. This key can be provided to a file @ `test/api-key.js`. If you would like it can also be provided as an environment variable named `GOOGLE_API_KEY`. +``` +npm run demo +``` # Usage ```javascript import React from 'react' +import SomeCoolComponent from 'some-third-party-package' import MUIPlacesAutocomplete from 'mui-places-autocomplete' -const Example = () => () +// Use 'renderTarget' prop to render a component/target we want the suggestions to popover +const Example = () => ( ()} />) export default Example ``` -# Demo -To see a demo of this component locally clone this repository and run: + +### 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: +1. Enable the Google Places API Web Service +2. Enable the Google Maps JavaScript API +3. Obtain a Google API key -``` -yarn demo -``` +You can do all of these things from your Google developers console here: https://console.developers.google.com -or +The component relies on the Places library in the Google Maps JavaScript API. To load the Places library on the client you must add the following to the HTML document you deliver to your clients: +```html + ``` -npm run demo -``` -Note that you must have followed the setup steps to run the demo as it depends on services provided by Google. +Be sure that you replace `YOUR_API_KEY` with the one you just created or obtained previously. + +This component also has testing which makes use of the Places library in the Google Maps JavaScript API. Rather than loading the Places library it uses a module provided by Google. It also requires an API key. This key can be provided to a file @ `test/api-key.js`. If you would like it can also be provided as an environment variable named `GOOGLE_API_KEY`. + +### Props + +| Prop | Type | Required | Description | +| :--- | :--- | :---: | :--- | +| `renderTarget` | Function | ✓ | Renders the components/elements that you would like to have the list of suggestions popover. | # 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. @@ -74,4 +83,4 @@ This was my first open-source project that I undertook while I was teaching myse * Overview and examples for the Autocomplete features in the Places library: https://developers.google.com/maps/documentation/javascript/places-autocomplete # License -MIT +[MIT](https://gine.mit-license.org/) diff --git a/demo/Demo.jsx b/demo/Demo.jsx index e737204..f7a7e07 100644 --- a/demo/Demo.jsx +++ b/demo/Demo.jsx @@ -1,6 +1,6 @@ import React from 'react' import MUIPlacesAutocomplete from './../dist' -const Demo = () => () +const Demo = () => ( (
)} />) export default Demo diff --git a/package.json b/package.json index ce5da11..fe71d50 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "main": "dist/index.js", "scripts": { "test": "yarn test:mocha && yarn test:eslint", - "test:mocha": "mocha --require babel-register --require ignore-styles --require test/setup-dom.js test/test.jsx", + "test:mocha": "mocha --exit --require babel-register --require ignore-styles --require test/setup-dom.js test/test.jsx", "test:eslint": "eslint --max-warnings 0 --cache --ext .js,.jsx src test demo", "demo": "webpack-dev-server --config demo/webpack.config.js --content-base dist --hot", "build": "rm -rf dist/ && webpack", @@ -29,8 +29,9 @@ }, "dependencies": { "autosuggest-highlight": "^3.1.0", + "downshift": "^1.22.5", "prop-types": "^15.6.0", - "react-autosuggest": "^9.3.2" + "react-popper": "^0.7.4" }, "peerDependencies": { "material-ui": "^1.0.0-beta.21", @@ -49,7 +50,7 @@ "chai-jest-snapshot": "^1.3.0", "conventional-changelog-eslint": "^0.2.1", "enzyme": "^3.1.0", - "enzyme-adapter-react-16": "^1.0.2", + "enzyme-adapter-react-16": "^1.1.0", "enzyme-to-json": "^3.1.4", "eslint": "^4.9.0", "eslint-config-airbnb": "^16.1.0", @@ -64,6 +65,7 @@ "jsdom": "^11.3.0", "material-ui": "^1.0.0-beta.21", "mocha": "^4.0.1", + "raf": "^3.4.0", "react": "^16.0.0", "react-dom": "^16.0.0", "semantic-release": "^8.2.0", diff --git a/src/MUIPlacesAutocomplete.jsx b/src/MUIPlacesAutocomplete.jsx index d83493c..22c47c3 100644 --- a/src/MUIPlacesAutocomplete.jsx +++ b/src/MUIPlacesAutocomplete.jsx @@ -1,99 +1,137 @@ import React from 'react' import PropTypes from 'prop-types' -import Autosuggest from 'react-autosuggest' -import { MenuItem } from 'material-ui/Menu' +import Grow from 'material-ui/transitions/Grow' +import { MenuList, MenuItem } from 'material-ui/Menu' +import Paper from 'material-ui/Paper' import TextField from 'material-ui/TextField' -import { withStyles } from 'material-ui/styles' +import Downshift from 'downshift' +import { Manager, Target, Popper } from 'react-popper' import match from 'autosuggest-highlight/match' import parse from 'autosuggest-highlight/parse' import googleLogo from './images/google-logo-on-white-bg.png' -// These MUI styles will be applied as the 'theme' to the component which comes with -// no styles. For info on styling the component see: -// https://github.com/moroshko/react-autosuggest#theme-optional -const styles = theme => ({ - container: { - flexGrow: 1, - position: 'relative', - height: 200, - }, - suggestionsContainerOpen: { - position: 'absolute', - marginTop: theme.spacing.unit, - marginBottom: theme.spacing.unit * 3, - left: 0, - right: 0, - }, - suggestion: { - display: 'block', - }, - suggestionsList: { - margin: 0, - padding: 0, - listStyleType: 'none', - }, - textField: { - width: '100%', - }, -}) - -// We export our component for testing purposes. For production use the default export from this -// module. -export class MUIPlacesAutocomplete extends React.Component { - static getSuggestionValue({ description }) { - return description - } - - static renderInputComponent({ classes, autoFocus, value, ref, ...other }) { - // The 'inputRef' prop takes a callback which has 1 argument which represents the DOM node for - // the element. In this case 'ref' comes from the 'react-autowhatever' lib (which is - // used by the 'react-autosuggest' lib) and in this case it simply maintains a reference to the - // DOM node for internal purposes. +export default class MUIPlacesAutocomplete extends React.Component { + // Renders the container that will hold the suggestions and defers to other methods to render the + // suggestions themselves. This method should only be called if you do indeed plan on rendering + // the suggestions. In our case this is when 'isOpen' Downshift render prop is 'true'. Thats + // because the methods that are used to render the suggestions invoke the 'getItemProps' prop + // getter from Downshift which is an impure function. In otherwords even if you don't render the + // suggestions container Downshift will still think we are rendering suggestions. + // + // The 'downshiftRenderProps' argument expects an object of props that Downshift passes to the + // function which is set as the value of the 'render' prop on the component. Currently + // the following Downshift render props are expected on the value provided to the + // 'downshiftRenderProps' argument: + // * getItemProps - function that returns the props that ought to be applied to menu item elements + // that are rendered + // * inputValue - current value of the controlled element + // * highlightedIndex - index of the currently highlighted menu item elements that have been + // rendered + static renderSuggestionsContainer(suggestions, downshiftRenderProps) { + // Return null here if there are no suggestions to render. If we don't we will show a little box + // that is empty and popped over the render target. This handles the case where a suggestion is + // selected, the input value is updated, and then the user deletes the input value. This + // behavior is attributed to setting the suggestions to the empty array in the + // 'onInputValueChange' method. + // + // Be sure we return null here before we render any of our suggestions lest we invoke the impure + // 'getItemProps' function. + if (suggestions.length === 0) { + return null + } + + // The autocomplete service can return multiple of the same predictions. This can sometimes be + // seen after someone selects a suggestion and starts to delete/backspace the input value which + // contains their selected suggestion. Here we will ensure uniqueness amongst suggestions using + // an ES6 Map so that we don't get duplicate key errors when we render our suggestions. + const uniqueSuggestions = + new Map(suggestions.map(({ description }) => [description, { description }])) + + const renderedSuggestions = + MUIPlacesAutocomplete.renderSuggestions([...uniqueSuggestions.values()], downshiftRenderProps) + + // On the component we enable the 'inner' modifier. This is needed as Popper JS will + // try to change the position of the popover depending on if it deems the popover is in or out + // of view. The result of enabling the 'inner' modifier means that the position of the popover + // won't change at all regardless of if the popover is in or out of view. + // + // Typically the receives actual nodes for its children but in our case we opted to + // provided a function that creats a
with styles applied to it at the top-level. This + //
with the styles applied is to account for issues that arise when testing. Without it we + // will get the following warnings: NaN is an invalid value for the top/left css style property. + // This is because the DOM provider/implementation we use when testing (jsdom) doesn't return + // values for bounding client rect. return ( - + + {({ popperProps, restProps }) => ( +
+ + + + {renderedSuggestions} + {renderedSuggestions.length > 0 + ? ( +
+ + +
+ ) + : null} +
+
+
+
+ )} +
) } - static renderSuggestionsContainer({ containerProps, children }) { - return ( -
- {children} - {children ? ( -
- - -
- ) : null} -
- ) + // Helper method to be called by 'renderSuggestionsContainer'. Returns list of rendered + // suggestions. + static renderSuggestions(suggestions, { getItemProps, inputValue, highlightedIndex }) { + return suggestions.map((suggestion, index) => + MUIPlacesAutocomplete.renderSuggestion( + suggestion, + { getItemProps, inputValue, isHighlighted: index === highlightedIndex }, + )) } - // Renders suggestions where they are highlighted based on the parts of the suggestion that match - // the query the user entered. This is inline with the Google Maps webapp at the time of writing. - // This behavior is opposite of how the Google Search bar/component/element works though. - static renderSuggestion({ description }, { query, isHighlighted }) { - // Calculate the chars to highlight in the suggestion 'description' based on the query that the - // user provided us. An array is returned and if any chars ought to be highlighted the array - // will contain a pair ([a, b]) which denote the indexes of chars to highlight (i.e. - // text.slice(a, b)). - const matches = match(description, query) + // Helper method to be called by 'renderSuggestions'. Renders suggestions where they are + // highlighted based on the parts of the suggestion that match the query the user entered. This is + // inline with the Google Maps webapp at the time of writing. This behavior is opposite of how the + // Google Search bar/component/element works though. + static renderSuggestion({ description }, { getItemProps, inputValue, isHighlighted }) { + // Calculate the chars to highlight in the suggestion 'description' based on the query + // ('inputValue') that the user provided us. An array is returned and if any chars ought to be + // highlighted the array will contain a pair ([a, b]) which denote the indexes of chars to + // highlight (i.e. text.slice(a, b)). + const matches = match(description, inputValue) // Break up the suggestion 'description' based on the parts that matched. An array is returned // of parts where each one has an indication of if it ought to be highlighted or not. const parts = parse(description, matches) return ( - +
{parts.map((part, index) => { if (part.highlight) { @@ -120,11 +158,12 @@ export class MUIPlacesAutocomplete extends React.Component { // Control the element/ component and make this React component the source // of truth for their state. - this.state = { value: '', suggestions: [] } + this.state = { + suggestions: [], + } - this.onSuggestionsFetchRequested = this.onSuggestionsFetchRequested.bind(this) - this.onSuggestionsClearRequested = this.onSuggestionsClearRequested.bind(this) - this.onChange = this.onChange.bind(this) + this.onInputValueChange = this.onInputValueChange.bind(this) + this.renderAutocomplete = this.renderAutocomplete.bind(this) } componentDidMount() { @@ -135,11 +174,23 @@ export class MUIPlacesAutocomplete extends React.Component { this.autocompleteService = new window.google.maps.places.AutocompleteService() } - onSuggestionsFetchRequested({ value }) { + // This function is called whenever Downshift detects that the input value, well, has changed. + // Although we only use a single argument in our function signature Downshift documents the + // function signature as: + // onInputValueChange(inputValue: string, stateAndHelpers: object) + onInputValueChange(inputValue) { + // If the inputs value is empty we can return as we will get an error if we provide the empty + // string when we perform a search. Set our suggestions to empty here as well so we don't render + // the old suggestions. + if (inputValue === '') { + this.setState({ suggestions: [] }) + return + } + this.autocompleteService.getPlacePredictions( - { input: value }, + { input: inputValue }, (predictions, serviceStatus) => { - // If the response doesn't contain a valid result then set our state as if not suggestions + // If the response doesn't contain a valid result then set our state as if no suggestions // were returned if (serviceStatus !== window.google.maps.places.PlacesServiceStatus.OK) { this.setState({ suggestions: [] }) @@ -153,65 +204,52 @@ export class MUIPlacesAutocomplete extends React.Component { ) } - onSuggestionsClearRequested() { - this.setState({ suggestions: [] }) - } - - // Implementation of the 'onChange' event callback for the element that is expected in the - // 'inputProps' prop passed to the component. The component expects - // the callback to have the following signature: function onChange(event, { newValue, method }) - onChange(event, { newValue }) { - this.setState({ value: newValue }) + renderAutocomplete({ + getInputProps, + getItemProps, + isOpen, + inputValue, + highlightedIndex, + }) { + const { suggestions } = this.state + const { renderTarget } = 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 + return ( +
+ + + {renderTarget()} + {isOpen ? MUIPlacesAutocomplete.renderSuggestionsContainer( + suggestions, + { getItemProps, inputValue, highlightedIndex }, + ) + : null} + +
+ ) } render() { - const { suggestions, value } = this.state - const { classes } = this.props - - // The following props are required for the component: - // * suggestions - // * getSuggestionValue - // * renderSuggestion - // * onSuggestionsFetchedRequested - // * onSuggestionsClearRequested - // * inputProps - // - // We supply the following props to give a MUI feel to our component - // * theme - // * renderInputComponent - // * renderSuggestionsContainer (for the Google logo) - // * renderSuggestion return ( - ) } } MUIPlacesAutocomplete.propTypes = { - classes: PropTypes.object.isRequired, + renderTarget: PropTypes.func.isRequired, } - -const MUIPlacesAutocompleteHOC = withStyles(styles)(MUIPlacesAutocomplete) - -export default MUIPlacesAutocompleteHOC diff --git a/src/index.js b/src/index.js index 236412f..b27293f 100644 --- a/src/index.js +++ b/src/index.js @@ -1,3 +1,3 @@ -import MUIPlacesAutocompleteHOC from './MUIPlacesAutocomplete' +import MUIPlacesAutocomplete from './MUIPlacesAutocomplete' -export default MUIPlacesAutocompleteHOC +export default MUIPlacesAutocomplete diff --git a/test/setup-dom.js b/test/setup-dom.js index dd46f23..120e17a 100644 --- a/test/setup-dom.js +++ b/test/setup-dom.js @@ -13,17 +13,15 @@ // Note that it is important that your tests using the global DOM APIs don't have "leaky" // side-effects which could change the results of other tests. // -// For more info see: http://airbnb.io/enzyme/docs/guides/jsdom.html - +// The DOM was setup based on the guideance that Enzyme gives as well as how MUI sets up their DOM +// for testing purposes. For more info see: +// * http://airbnb.io/enzyme/docs/guides/jsdom.html +// * https://github.com/mui-org/material-ui/blob/3fdf302d44594c68b0700843aa79793525ad0f7c/test/utils/createDOM.js import { JSDOM } from 'jsdom' +import Node from 'jsdom/lib/jsdom/living/node-document-position' +import { polyfill as rafPolyfill } from 'raf' -const copyProps = (src, target) => { - const props = Object.getOwnPropertyNames(src) - .filter(prop => typeof target[prop] === 'undefined') - .map(prop => Object.getOwnPropertyDescriptor(src, prop)) - - Object.defineProperties(target, props) -} +const KEYS = ['HTMLElement'] const jsdom = new JSDOM(` @@ -32,10 +30,46 @@ const jsdom = new JSDOM(` `) -const { window } = jsdom +global.window = jsdom.window +global.document = undefined +global.Node = Node + +// This ought to add 'getComputedStyle()' which is used by PopperJS to the 'global' object +Object.keys(jsdom.window).forEach((property) => { + if (typeof global[property] === 'undefined') { + global[property] = jsdom.window[property] + } +}) + +// PopperJS isn't going to work with jsdom out of the box as it relies on real browser DOM APIs to +// work: https://github.com/FezVrasta/popper.js/issues/478#issuecomment-341377821 +// +// One of those APIs is 'createRange' which PopperJS uses when finding the offset of the parent of +// some nodes. Although jsdom did provide the API it doesn't anymore: +// https://github.com/tmpvar/jsdom/blame/4c7698f760fc64f20b2a0ddff450eddbdd193176/lib/jsdom/living/nodes/Document.webidl#L39 +// +// Here we mock the API ourselves. Note that typically the property +// 'commonAncestorContainer.ownerDocument' on the returned object isn't always the 'document'. But +// for testing purposes this is okay as it merely matches what PopperJS was doing in the past when +// they returned an element for the offset parent. For reference see: +// https://github.com/FezVrasta/popper.js/commit/5e84ac0240bc9551143a8d6647b39eaf02d212a8 +global.document.createRange = () => ({ + setStart: () => {}, + setEnd: () => {}, + commonAncestorContainer: { + nodeName: 'BODY', + ownerDocument: document, + }, +}) + +global.navigator = { + userAgent: 'node.js', + appVersion: global.navigator.appVersion, +} -global.window = window -global.document = window.document -global.navigator = { userAgent: 'node.js' } +KEYS.forEach((key) => { + global[key] = window[key] +}) -copyProps(window, global) +// 'requestAnimationFrame' is used by React +rafPolyfill(global) diff --git a/test/test.jsx b/test/test.jsx index 7612c17..4ca48d4 100644 --- a/test/test.jsx +++ b/test/test.jsx @@ -13,9 +13,9 @@ import toJson from 'enzyme-to-json' import React from 'react' // Code under test -import { MUIPlacesAutocomplete } from './../src/MUIPlacesAutocomplete' +import MUIPlacesAutocomplete from './../src' -// Supporting code +// Supporting test code import getACServiceClassDef from './testHelper' // Configure Chai to work with Jest @@ -42,6 +42,21 @@ describe('React component test: ', function () { // component that will be under test let mpaWrapper = null + // Helper function to get the JSON of the component in our + // component. Useful for snapshot testing only the components that we are in charge of + // rendering (i.e. the suggestions). + const getMenuListJSON = () => { + expect(mpaWrapper).to.not.be.null + + const mlWrapper = mpaWrapper.find('MenuList') + + // Make sure we have a wrapper around a and only a single + expect(mlWrapper).to.not.be.null + expect(mlWrapper.length).to.equal(1) + + return toJson(mlWrapper) + } + before('"Load" the Google Maps JavaScript API on \'window\'', function () { // These tests depend on a DOM to be setup for more indepth tests that either do full DOM // rendering or for leveraging DOM APIs. At this point we presume that the DOM has been setup @@ -49,7 +64,7 @@ describe('React component test: ', function () { expect(global.window).to.exist // The component expects the Google Maps JavaScript API to be loaded - // in the 'window' object. Since we aren't loading it we mock our the API and add it to the + // in the 'window' object. Since we aren't loading it we mock out the API and add it to the // 'window' object. global.window.google = { maps: { @@ -70,77 +85,115 @@ describe('React component test: ', function () { }) beforeEach('Setup Enzyme wrapper', function () { - // Since we aren't testing the MUIPlacesAutocomplete HOC (see MUI 'withStyles()') we provide - // an empty object for the MUI styling/classes that would normally get applied so we don't try - // to reference an undefined prop. - mpaWrapper = mount() + mpaWrapper = mount( (
)} />) expect(mpaWrapper).to.not.be.null }) it('Initial state', function () { - expect(mpaWrapper.state().value).to.exist - expect(mpaWrapper.state().value).to.be.empty expect(mpaWrapper.state().suggestions).to.exist expect(mpaWrapper.state().suggestions).to.be.empty - // Protect us from forgetting to update this test if we add an additional key to the - // components state. We currently only expect the 'value' and 'suggestions' key... - expect(Object.keys(mpaWrapper.state()).length).to.equal(2) + // Protect us from forgetting to update this test if we add an additional key(s) to the + // components state. We currently only expect the 'suggestions' key... + expect(Object.keys(mpaWrapper.state()).length).to.equal(1) - // We snapshot the initial state to provide visibility to any changes we have made to our - // component... - expect(toJson(mpaWrapper)).to.matchSnapshot() + // We don't snapshot test our component since 1) the / components that our + // component composes is massive and takes long to diff the + // serializations and 2) our container ought not be open anyway. We can verify that the + // suggestions container isn't open by searching for the component. + expect(mpaWrapper.find('MenuList').length).to.equal(0) }) it('Suggestions rendered from \'suggestions\' state', function () { - // Provide a search input and a suggestion to our state so a suggestion is rendered - mpaWrapper.setState({ - value: searchInputValue, - suggestions: [{ description: 'Bellingham, WA, United States' }], - }) - mpaWrapper.find('input').simulate('focus') + // To get suggestions to be rendered first simulate an input onChange event which will cause + // to believe that our autocomplete/dropdown is open... + mpaWrapper.find('input').simulate('change', { target: { value: searchInputValue } }) + + // Second set the state of our component to provide suggestions as if they were returned from + // the Google AutocompleteService... + const expectedSuggestion = { description: 'Bellingham, WA, United States' } + const expectedSuggestionCount = 1 - expect(mpaWrapper.state().suggestions.length).to.equal(1) - expect(mpaWrapper.find('MenuItem').length).to.equal(mpaWrapper.state().suggestions.length) + mpaWrapper.setState({ suggestions: [expectedSuggestion] }) - expect(toJson(mpaWrapper)).to.matchSnapshot() + // Now check that our suggestions are rendered... + expect(mpaWrapper.find('MenuItem').length).to.equal(expectedSuggestionCount) + expect(mpaWrapper.find('MenuItem').text()).to.equal(expectedSuggestion.description) + + // Snapshot test only the of our suggestions as the / components + // that our component composes is massive and takes to long to diff + // the serializations. + expect(getMenuListJSON()).to.matchSnapshot() }) it('Empty input renders no suggestions after previous ones rendered', function () { - // Make sure we aren't asserting an empty list of suggestions in the first place after - // focusing the 'input' element... - mpaWrapper.setState({ - value: searchInputValue, - suggestions: [{ description: 'Bellingham, WA, United States' }], - }) - mpaWrapper.find('input').simulate('focus') - - expect(mpaWrapper.state().suggestions.length).to.equal(1) - expect(mpaWrapper.find('MenuItem').length).to.equal(mpaWrapper.state().suggestions.length) - - // Now check that we no longer render suggestions on empty input... + // To get suggestions to be rendered first simulate an input onChange event which will cause + // to believe that our autocomplete/dropdown is open... + mpaWrapper.find('input').simulate('change', { target: { value: searchInputValue } }) + + // Second set the start of our component to provide suggestions as if they were returned from + // the Google AutocompleteService... + const expectedSuggestion = { description: 'Bellingham, WA, United States' } + let expectedSuggestionCount = 1 + + mpaWrapper.setState({ suggestions: [expectedSuggestion] }) + + // Now check that our suggestions are rendered... + expect(mpaWrapper.find('MenuItem').length).to.equal(expectedSuggestionCount) + expect(mpaWrapper.find('MenuItem').text()).to.equal(expectedSuggestion.description) + + // Now clear the input and check that no suggestions are rendered... mpaWrapper.find('input').simulate('change', { target: { value: '' } }) - expect(mpaWrapper.state().value).to.be.empty - expect(mpaWrapper.state().suggestions).to.be.empty - expect(mpaWrapper.find('MenuItem').length).to.equal(0) + expectedSuggestionCount = 0 + + expect(mpaWrapper.find('MenuItem').length).to.equal(expectedSuggestionCount) + + // We don't snapshot test since our suggestions container ought not be open. We can verify it + // isn't by searching for the component + expect(mpaWrapper.find('MenuList').length).to.equal(0) + }) + + it('Duplicate suggestions aren\'t rendered', function () { + // To get suggestions to be rendered first simulate an input onChange event which will cause + // to believe that our autocomplete/dropdown is open... + mpaWrapper.find('input').simulate('change', { target: { value: searchInputValue } }) - expect(toJson(mpaWrapper)).to.matchSnapshot() + // Second set the state of our component to provide duplicate suggestions as if they were + // returned from the Google AutocompleteService... + const expectedSuggestion = { description: 'Bellingham, WA, United States' } + const expectedSuggestionCount = 1 + + mpaWrapper.setState({ suggestions: [expectedSuggestion, expectedSuggestion] }) + + // Now check that our suggestions are uniquely rendered... + expect(mpaWrapper.find('MenuItem').length).to.equal(expectedSuggestionCount) + expect(mpaWrapper.find('MenuItem').text()).to.equal(expectedSuggestion.description) + + // Snapshot test only the of our suggestions as the / components + // that our component composes is massive and takes to long to diff + // the serializations. + expect(getMenuListJSON()).to.matchSnapshot() }) it('Google logo is present in a populated list of suggestions', function () { - // Provide a search input and a suggestion to our state so we can render the suggestions - // container which will have the Google Logo in a single element - mpaWrapper.setState({ - value: searchInputValue, - suggestions: [{ description: 'Bellingham, WA, United States' }], - }) - mpaWrapper.find('input').simulate('focus') + // To get suggestions to be rendered first simulate an input onChange event which will cause + // to believe that our autocomplete/dropdown is open... + mpaWrapper.find('input').simulate('change', { target: { value: searchInputValue } }) + + // Second set the start of our component to provide suggestions as if they were returned from + // the Google AutocompleteService... + + mpaWrapper.setState({ suggestions: [{ description: 'Bellingham, WA, United States' }] }) + // Now check that our image is rendered... expect(mpaWrapper.find('img').length).to.equal(1) - expect(toJson(mpaWrapper)).to.matchSnapshot() + // Snapshot test only the of our suggestions as the / components + // that our component composes is massive and takes to long to diff + // the serializations. + expect(getMenuListJSON()).to.matchSnapshot() }) }) @@ -151,10 +204,9 @@ describe('React component test: ', function () { // Find the 'input' element so we can simulate an event for changing the input which will // cause our component to get place predictions with the Google Maps API and ultimately update // its state with place suggestions. Before doing so setup our test callback so we can assert - // that suggestions have been populated and signal that we have completed the test. + // that suggestions have been populated. const testCallback = () => { try { - expect(mpaWrapper.state().value).to.equal(searchInputValue) expect(mpaWrapper.state().suggestions).to.not.be.empty } catch (e) { done(e) @@ -187,10 +239,11 @@ describe('React component test: ', function () { // Now mount our component to make use of our mock API when we send a 'change' event on our // 'input' element - mpaWrapper = mount() + mpaWrapper = mount( (
)} />) const inputWrapper = mpaWrapper.find('input') + inputWrapper.simulate('focus') inputWrapper.simulate('change', { target: { value: searchInputValue } }) }) }) diff --git a/test/test.jsx.snap b/test/test.jsx.snap index 11423c0..2875972 100644 --- a/test/test.jsx.snap +++ b/test/test.jsx.snap @@ -1,1577 +1,727 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`React component test: Renders correctly for given application state: Empty input renders no suggestions after previous ones rendered 1`] = ` - - Renders correctly for given application state: Duplicate suggestions aren't rendered 1`] = ` + + - -
- - - -
- - -
- -
- -
-
-
-
-
-
-
- - - -`; - -exports[`React component test: Renders correctly for given application state: Google logo is present in a populated list of suggestions 1`] = ` - - - -
- - - - - - - -
- -
    - -
  • - - - - + Bellingham + + + , WA, United States + +
+ + - - -
-
- - Bellingham - - - , WA, United States - -
- - - - - - - -
-
-
- - - - - - - - -
+ + + +
+ + + + + + +
+ - - -
+ /> +
-
-
-
-
+ + + + `; -exports[`React component test: Renders correctly for given application state: Initial state 1`] = ` - - Renders correctly for given application state: Google logo is present in a populated list of suggestions 1`] = ` + + - -
- - - + - - - - + + + + + +
-
- - - + key=".1" + onFocus={[Function]} + style={ + Object { + "display": "flex", + } + } + tabIndex={-1} + > + + +
+ + +
+
`; exports[`React component test: Renders correctly for given application state: Suggestions rendered from 'suggestions' state 1`] = ` - - + - -
- - - -
- - -
- -
- -
-
-
-
-
-
- -
    - -
  • - - - - + Bellingham + + + , WA, United States + +
+ + - - -
-
- - Bellingham - - - , WA, United States - -
- - - - - - - -
-
-
- - - - - - - - -
+ + + +
+ + + + + + +
+ - - -
+ /> +
-
- - -
+ + + +
`; diff --git a/yarn.lock b/yarn.lock index bf90c88..4f73703 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1952,6 +1952,10 @@ dotenv@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-4.0.0.tgz#864ef1379aced55ce6f95debecdce179f7a0cd1d" +downshift@^1.22.5: + version "1.22.5" + resolved "https://registry.yarnpkg.com/downshift/-/downshift-1.22.5.tgz#48103b2f80259157b01b96d6bdc344c51ce0743e" + duplexer3@^0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/duplexer3/-/duplexer3-0.1.4.tgz#ee01dd1cac0ed3cbc7fdbea37dc0a8f1ce002ce2" @@ -2013,20 +2017,20 @@ entities@^1.1.1, entities@~1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/entities/-/entities-1.1.1.tgz#6e5c2d0a5621b5dadaecef80b90edfb5cd7772f0" -enzyme-adapter-react-16@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/enzyme-adapter-react-16/-/enzyme-adapter-react-16-1.0.2.tgz#8c6f431f17c69e1e9eeb25ca4bd92f31971eb2dd" +enzyme-adapter-react-16@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/enzyme-adapter-react-16/-/enzyme-adapter-react-16-1.1.0.tgz#86c5db7c10f0be6ec25d54ca41b59f2abb397cf4" dependencies: - enzyme-adapter-utils "^1.0.0" + enzyme-adapter-utils "^1.1.0" lodash "^4.17.4" object.assign "^4.0.4" object.values "^1.0.4" prop-types "^15.5.10" react-test-renderer "^16.0.0-0" -enzyme-adapter-utils@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/enzyme-adapter-utils/-/enzyme-adapter-utils-1.0.1.tgz#fcd81223339a55a312f7552641e045c404084009" +enzyme-adapter-utils@^1.1.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/enzyme-adapter-utils/-/enzyme-adapter-utils-1.2.0.tgz#7f4471ee0a70b91169ec8860d2bf0a6b551664b2" dependencies: lodash "^4.17.4" object.assign "^4.0.4" @@ -4393,10 +4397,6 @@ oauth-sign@~0.8.1, oauth-sign@~0.8.2: version "0.8.2" resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.8.2.tgz#46a6ab7f0aead8deae9ec0565780b7d4efeb9d43" -object-assign@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-3.0.0.tgz#9bedd5ca0897949bca47e7ff408062d549f587f2" - object-assign@^4.0.1, object-assign@^4.1.0, object-assign@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" @@ -4906,7 +4906,7 @@ querystringify@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-1.0.0.tgz#6286242112c5b712fa654e526652bf6a13ff05cb" -raf@^3.3.2: +raf@^3.3.2, raf@^3.4.0: version "3.4.0" resolved "https://registry.yarnpkg.com/raf/-/raf-3.4.0.tgz#a28876881b4bc2ca9117d4138163ddb80f781575" dependencies: @@ -4964,22 +4964,6 @@ rc@^1.1.7: minimist "^1.2.0" strip-json-comments "~2.0.1" -react-autosuggest@^9.3.2: - version "9.3.2" - resolved "https://registry.yarnpkg.com/react-autosuggest/-/react-autosuggest-9.3.2.tgz#dd8c0fbe9c25aa94afe296180353647f6ecc10a7" - dependencies: - prop-types "^15.5.10" - react-autowhatever "^10.1.0" - shallow-equal "^1.0.0" - -react-autowhatever@^10.1.0: - version "10.1.0" - resolved "https://registry.yarnpkg.com/react-autowhatever/-/react-autowhatever-10.1.0.tgz#41f6d69382437d3447a0a3c8913bb8ca2feaabc1" - dependencies: - prop-types "^15.5.8" - react-themeable "^1.1.0" - section-iterator "^2.0.0" - react-dom@^16.0.0: version "16.2.0" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.2.0.tgz#69003178601c0ca19b709b33a83369fe6124c044" @@ -5034,12 +5018,6 @@ react-test-renderer@^16.0.0-0: fbjs "^0.8.16" object-assign "^4.1.1" -react-themeable@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/react-themeable/-/react-themeable-1.1.0.tgz#7d4466dd9b2b5fa75058727825e9f152ba379a0e" - dependencies: - object-assign "^3.0.0" - react-transition-group@^2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-2.2.1.tgz#e9fb677b79e6455fd391b03823afe84849df4a10" @@ -5400,10 +5378,6 @@ scroll@^2.0.1: dependencies: rafl "~1.2.1" -section-iterator@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/section-iterator/-/section-iterator-2.0.0.tgz#bf444d7afeeb94ad43c39ad2fb26151627ccba2a" - select-hose@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/select-hose/-/select-hose-2.0.0.tgz#625d8658f865af43ec962bfc376a37359a4994ca" @@ -5512,10 +5486,6 @@ sha.js@^2.4.0, sha.js@^2.4.8: inherits "^2.0.1" safe-buffer "^5.0.1" -shallow-equal@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/shallow-equal/-/shallow-equal-1.0.0.tgz#508d1838b3de590ab8757b011b25e430900945f7" - shebang-command@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea"