diff --git a/.babelrc b/.babelrc new file mode 100644 index 00000000..d8acbb57 --- /dev/null +++ b/.babelrc @@ -0,0 +1,7 @@ +{ + "env": { + "test": { + "presets": ["es2015", "stage-0", "react"] + } + } +} diff --git a/.gitignore b/.gitignore index 783821d1..cd39e1e0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ examples/**/*-bundle.js node_modules/ +coverage/ diff --git a/README.md b/README.md index fed12614..5187bb57 100644 --- a/README.md +++ b/README.md @@ -15,4 +15,13 @@ Stuff we need help with: pretty lean, on purpose, apps should style this however they'd like) - tests (eventually) +# Tests! +Run them: +`npm test` + +Write them: +`lib/__tests__/Autocomplete-test.js` + +Check your work: +`npm run coverage` diff --git a/examples/async-data/app.css b/examples/async-data/app.css index 306b935f..90cfd090 100644 --- a/examples/async-data/app.css +++ b/examples/async-data/app.css @@ -7,3 +7,8 @@ body { .example { padding: 0 25px; } + +label { + display: block; + margin: 5px 0; +} diff --git a/examples/async-data/app.js b/examples/async-data/app.js index bee691b5..c1ff0b17 100644 --- a/examples/async-data/app.js +++ b/examples/async-data/app.js @@ -1,6 +1,6 @@ import React from 'react' import Autocomplete from '../../lib/index' -import { getStates, matchStateToTerm, sortStates, styles, fakeRequest } from '../utils' +import { getStates, matchStateToTerm, sortStates, styles, fakeRequest } from '../../lib/utils' let App = React.createClass({ @@ -24,6 +24,8 @@ let App = React.createClass({

item.name} diff --git a/examples/custom-menu/app.css b/examples/custom-menu/app.css index 306b935f..90cfd090 100644 --- a/examples/custom-menu/app.css +++ b/examples/custom-menu/app.css @@ -7,3 +7,8 @@ body { .example { padding: 0 25px; } + +label { + display: block; + margin: 5px 0; +} diff --git a/examples/custom-menu/app.js b/examples/custom-menu/app.js index dbaac622..1ea62c24 100644 --- a/examples/custom-menu/app.js +++ b/examples/custom-menu/app.js @@ -1,6 +1,6 @@ import React from 'react' import Autocomplete from '../../lib/index' -import { getStates, matchStateToTerm, sortStates, styles, fakeRequest } from '../utils' +import { getStates, matchStateToTerm, sortStates, styles, fakeRequest } from '../../lib/utils' let App = React.createClass({ @@ -21,6 +21,8 @@ let App = React.createClass({ letter of the alphabet.

item.name} onSelect={() => this.setState({ unitedStates: [] }) } diff --git a/examples/static-data/app.css b/examples/static-data/app.css index 306b935f..90cfd090 100644 --- a/examples/static-data/app.css +++ b/examples/static-data/app.css @@ -7,3 +7,8 @@ body { .example { padding: 0 25px; } + +label { + display: block; + margin: 5px 0; +} diff --git a/examples/static-data/app.js b/examples/static-data/app.js index 81dde9ef..9def5b08 100644 --- a/examples/static-data/app.js +++ b/examples/static-data/app.js @@ -1,5 +1,5 @@ import React from 'react' -import { getStates, matchStateToTerm, sortStates, styles } from '../utils' +import { getStates, matchStateToTerm, sortStates, styles } from '../../lib/utils' import Autocomplete from '../../lib/index' let App = React.createClass({ @@ -15,6 +15,8 @@ let App = React.createClass({ item.name} shouldItemRender={matchStateToTerm} diff --git a/lib/Autocomplete.js b/lib/Autocomplete.js index 6f7efaa5..fe479517 100644 --- a/lib/Autocomplete.js +++ b/lib/Autocomplete.js @@ -1,4 +1,5 @@ const React = require('react') +const lodash = require('lodash') const scrollIntoView = require('dom-scroll-into-view') let _debugStates = [] @@ -12,12 +13,14 @@ let Autocomplete = React.createClass({ shouldItemRender: React.PropTypes.func, renderItem: React.PropTypes.func.isRequired, menuStyle: React.PropTypes.object, - inputProps: React.PropTypes.object + inputProps: React.PropTypes.object, + labelText: React.PropTypes.string }, getDefaultProps () { return { inputProps: {}, + labelText: '', onChange () {}, onSelect (value, item) {}, renderMenu (items, value, style) { @@ -46,6 +49,7 @@ let Autocomplete = React.createClass({ }, componentWillMount () { + this.id = lodash.uniqueId('autocomplete-'); this._ignoreBlur = false this._performAutoCompleteOnUpdate = false this._performAutoCompleteOnKeyUp = false @@ -103,7 +107,7 @@ let Autocomplete = React.createClass({ }, keyDownHandlers: { - ArrowDown () { + ArrowDown (event) { event.preventDefault() var { highlightedIndex } = this.state var index = ( @@ -133,11 +137,11 @@ let Autocomplete = React.createClass({ Enter (event) { if (this.state.isOpen === false) { - // already selected this, do nothing + // menu is closed so there is no selection to accept -> do nothing return } else if (this.state.highlightedIndex == null) { - // hit enter after focus but before typing anything so no autocomplete attempt yet + // input has focus but no menu item is selected + enter is hit -> close the menu, highlight whatever's in input this.setState({ isOpen: false }, () => { @@ -145,6 +149,7 @@ let Autocomplete = React.createClass({ }) } else { + // text entered + menu item has been highlighted + enter is hit -> update value to that of selected menu item, close the menu var item = this.getFilteredItems()[this.state.highlightedIndex] this.setState({ value: this.props.getItemValue(item), @@ -216,10 +221,10 @@ let Autocomplete = React.createClass({ setMenuPositions () { var node = this.refs.input var rect = node.getBoundingClientRect() - var computedStyle = getComputedStyle(node) - var marginBottom = parseInt(computedStyle.marginBottom, 10) - var marginLeft = parseInt(computedStyle.marginLeft, 10) - var marginRight = parseInt(computedStyle.marginRight, 10) + var computedStyle = global.window.getComputedStyle(node) + var marginBottom = parseInt(computedStyle.marginBottom, 10) || 0; + var marginLeft = parseInt(computedStyle.marginLeft, 10) || 0; + var marginRight = parseInt(computedStyle.marginRight, 10) || 0; this.setState({ menuTop: rect.bottom + marginBottom, menuLeft: rect.left + marginLeft, @@ -297,8 +302,11 @@ let Autocomplete = React.createClass({ state: this.state }) } - return ( + return (
+ this.handleKeyUp(event)} onClick={this.handleInputClick} value={this.state.value} + id={this.id} /> {this.state.isOpen && this.renderMenu()} {this.props.debug && ( diff --git a/lib/__tests__/.setup.js b/lib/__tests__/.setup.js new file mode 100644 index 00000000..8f39f132 --- /dev/null +++ b/lib/__tests__/.setup.js @@ -0,0 +1,6 @@ +// .setup.js +import { jsdom } from 'jsdom'; + +global.document = jsdom(''); +global.window = document.defaultView; +global.navigator = global.window.navigator; diff --git a/lib/__tests__/Autocomplete-test.js b/lib/__tests__/Autocomplete-test.js index 84bde946..0ed62408 100644 --- a/lib/__tests__/Autocomplete-test.js +++ b/lib/__tests__/Autocomplete-test.js @@ -1,11 +1,248 @@ -import React from 'react/addons'; -let { TestUtils } = React.addons; -import Autocomplete from '../Autocomplete'; +import React from 'react' +import ReactDOM from 'react-dom' +import TestUtils from 'react-addons-test-utils' +import jsdom from 'mocha-jsdom'; +import chai from 'chai'; +const expect = chai.expect; +import chaiEnzyme from 'chai-enzyme' import { ok, equal } from 'assert'; +import { mount, shallow } from 'enzyme'; +import Autocomplete from '../Autocomplete'; +import { getStates, matchStateToTerm, sortStates, styles } from '../utils' + +chai.use(chaiEnzyme()); + +function AutocompleteComponentJSX (extraProps) { + return ( + item.name} + items={getStates()} + renderItem={(item, isHighlighted) => ( +
{item.name}
+ )} + shouldItemRender={matchStateToTerm} + {...extraProps} + /> + ) +}; + +describe('Autocomplete acceptance tests', () => { + + var autocompleteWrapper = mount(AutocompleteComponentJSX({})); + var autocompleteInputWrapper = autocompleteWrapper.find('input'); + + it('should display autocomplete menu when input has focus', () => { + + expect(autocompleteWrapper.state('isOpen')).to.be.false; + expect(autocompleteWrapper.instance().refs.menu).to.not.exist; + + // Display autocomplete menu upon input focus + autocompleteInputWrapper.simulate('focus'); + + expect(autocompleteWrapper.state('isOpen')).to.be.true; + expect(autocompleteWrapper.instance().refs.menu).to.exist; + }); + + it('should show results when partial match is typed in', () => { + + // Render autocomplete results upon partial input match + expect(autocompleteWrapper.ref('menu').children()).to.have.length(50); + autocompleteInputWrapper.simulate('change', { target: { value: 'Ar' } }); + expect(autocompleteWrapper.ref('menu').children()).to.have.length(6); + + }); + + it('should close autocomplete menu when input is blurred', () => { + + autocompleteInputWrapper.simulate('blur'); + + expect(autocompleteWrapper.state('isOpen')).to.be.false; + expect(autocompleteWrapper.instance().refs.menu).to.not.exist; + + }); + +}); + +// Event handler unit tests + +describe('Autocomplete kewDown->ArrowDown event handlers', () => { + + var autocompleteWrapper = mount(AutocompleteComponentJSX({})); + var autocompleteInputWrapper = autocompleteWrapper.find('input'); + + it('should highlight the 1st item in the menu when none is selected', () => { + autocompleteWrapper.setState({'isOpen': true}); + autocompleteWrapper.setState({'highlightedIndex': null}); + + autocompleteInputWrapper.simulate('keyDown', { key : "ArrowDown", keyCode: 40, which: 40 }); + + expect(autocompleteWrapper.state('isOpen')).to.be.true; + expect(autocompleteWrapper.state('highlightedIndex')).to.equal(0); + }); + + it('should highlight the "n+1" item in the menu when "n" is selected', () => { + autocompleteWrapper.setState({'isOpen': true}); + + var n = 4; + // Set input to be an empty value, which displays all 50 states as items in the menu + autocompleteInputWrapper.simulate('change', { target: { value: '' } }); + autocompleteWrapper.setState({'highlightedIndex': n}); + + autocompleteInputWrapper.simulate('keyDown', { key : "ArrowDown", keyCode: 40, which: 40 }); + + expect(autocompleteWrapper.state('isOpen')).to.be.true; + expect(autocompleteWrapper.state('highlightedIndex')).to.equal(n+1); + }); + + it('should highlight the 1st item in the menu when the last is selected', () => { + autocompleteWrapper.setState({'isOpen': true}); + + // Set input to be an empty value, which displays all 50 states as items in the menu + autocompleteInputWrapper.simulate('change', { target: { value: '' } }); + autocompleteWrapper.setState({'highlightedIndex': 49}); + + autocompleteInputWrapper.simulate('keyDown', { key : "ArrowDown", keyCode: 40, which: 40 }); + + expect(autocompleteWrapper.state('isOpen')).to.be.true; + expect(autocompleteWrapper.state('highlightedIndex')).to.equal(0); + }); + +}); + +describe('Autocomplete kewDown->ArrowUp event handlers', () => { + + var autocompleteWrapper = mount(AutocompleteComponentJSX({})); + var autocompleteInputWrapper = autocompleteWrapper.find('input'); + + it('should highlight the last item in the menu when none is selected', () => { + autocompleteWrapper.setState({'isOpen': true}); + autocompleteWrapper.setState({'highlightedIndex': null}); + // Set input to be an empty value, which displays all 50 states as items in the menu + autocompleteInputWrapper.simulate('change', { target: { value: '' } }); + + autocompleteInputWrapper.simulate('keyDown', { key : 'ArrowUp', keyCode: 38, which: 38 }); -/* eslint func-names:0 */ -describe('react-autocomplete', function() { - it('should have tests', function() { - ok(false); + expect(autocompleteWrapper.state('isOpen')).to.be.true; + expect(autocompleteWrapper.state('highlightedIndex')).to.equal(49); }); + + it('should highlight the "n-1" item in the menu when "n" is selected', () => { + autocompleteWrapper.setState({'isOpen': true}); + + var n = 4; + // Set input to be an empty value, which displays all 50 states as items in the menu + autocompleteInputWrapper.simulate('change', { target: { value: '' } }); + autocompleteWrapper.setState({'highlightedIndex': n}); + + autocompleteInputWrapper.simulate('keyDown', { key : 'ArrowUp', keyCode: 38, which: 38 }); + + expect(autocompleteWrapper.state('isOpen')).to.be.true; + expect(autocompleteWrapper.state('highlightedIndex')).to.equal(n-1); + }); + + it('should highlight the last item in the menu when the 1st is selected', () => { + autocompleteWrapper.setState({'isOpen': true}); + + // Set input to be an empty value, which displays all 50 states as items in the menu + autocompleteInputWrapper.simulate('change', { target: { value: '' } }); + autocompleteWrapper.setState({'highlightedIndex': 0}); + + autocompleteInputWrapper.simulate('keyDown', { key : 'ArrowUp', keyCode: 38, which: 38 }); + + expect(autocompleteWrapper.state('isOpen')).to.be.true; + expect(autocompleteWrapper.state('highlightedIndex')).to.equal(49); + }); + +}); + +describe('Autocomplete kewDown->Enter event handlers', () => { + + var autocompleteWrapper = mount(AutocompleteComponentJSX({})); + var autocompleteInputWrapper = autocompleteWrapper.find('input'); + + it('should do nothing if the menu is closed', () => { + autocompleteWrapper.setState({'isOpen': false}); + autocompleteWrapper.simulate('keyDown', { key : 'Enter', keyCode: 13, which: 13 }); + expect(autocompleteWrapper.state('isOpen')).to.be.false; + }); + + it('should close menu if input has focus but no item has been selected and then the Enter key is hit', () => { + autocompleteWrapper.setState({'isOpen': true}); + autocompleteInputWrapper.simulate('focus'); + autocompleteInputWrapper.simulate('change', { target: { value: '' } }); + + // simulate keyUp of backspace, triggering autocomplete suggestion on an empty string, which should result in nothing highlighted + autocompleteInputWrapper.simulate('keyUp', { key : 'Backspace', keyCode: 8, which: 8 }); + expect(autocompleteWrapper.state('highlightedIndex')).to.be.null; + + autocompleteInputWrapper.simulate('keyDown', { key : 'Enter', keyCode: 13, which: 13 }); + + expect(autocompleteWrapper.state('value')).to.equal(''); + expect(autocompleteWrapper.state('isOpen')).to.be.false; + + }); + + it('should update input value from selected menu item and close the menu', () => { + autocompleteWrapper.setState({'isOpen': true}); + autocompleteInputWrapper.simulate('focus'); + autocompleteInputWrapper.simulate('change', { target: { value: 'Ar' } }); + + // simulate keyUp of last key, triggering autocomplete suggestion + selection of the suggestion in the menu + autocompleteInputWrapper.simulate('keyUp', { key : 'r', keyCode: 82, which: 82 }); + + // Hit enter, updating state.value with the selected Autocomplete suggestion + autocompleteInputWrapper.simulate('keyDown', { key : 'Enter', keyCode: 13, which: 13 }); + expect(autocompleteWrapper.state('value')).to.equal('Arizona'); + expect(autocompleteWrapper.state('isOpen')).to.be.false; + + }); + +}); + +describe('Autocomplete kewDown->Escape event handlers', () => { + + var autocompleteWrapper = mount(AutocompleteComponentJSX({})); + var autocompleteInputWrapper = autocompleteWrapper.find('input'); + + it('should unhighlight any selected menu item + close the menu', () => { + autocompleteWrapper.setState({'isOpen': true}); + autocompleteWrapper.setState({'highlightedIndex': 0}); + + autocompleteInputWrapper.simulate('keyDown', { key : 'Escape', keyCode: 27, which: 27 }); + + expect(autocompleteWrapper.state('isOpen')).to.be.false; + expect(autocompleteWrapper.state('highlightedIndex')).to.be.null; + }); + +}); + +// Component method unit tests +describe('Autocomplete#renderMenu', () => { + + var autocompleteWrapper = mount(AutocompleteComponentJSX({})); + var autocompleteInputWrapper = autocompleteWrapper.find('input'); + + it('should return a
ReactComponent when renderMenu() is called', () => { + //autocompleteInputWrapper.simulate('change', { target: { value: 'Ar' } }); + var autocompleteMenu = autocompleteWrapper.instance().renderMenu(); + expect(autocompleteMenu.type).to.be.equal('div'); + expect(autocompleteMenu.ref).to.be.equal('menu'); + expect(autocompleteMenu.props.children.length).to.be.equal(50); + }); + + it('should return a menu ReactComponent with a subset of children when partial match text has been entered', () => { + // Input 'Ar' should result in 6 items in the menu, populated from autocomplete. + autocompleteInputWrapper.simulate('change', { target: { value: 'Ar' } }); + + var autocompleteMenu = autocompleteWrapper.instance().renderMenu(); + expect(autocompleteMenu.props.children.length).to.be.equal(6); + + }); + }); diff --git a/examples/utils.js b/lib/utils.js similarity index 100% rename from examples/utils.js rename to lib/utils.js diff --git a/package.json b/package.json index 11639a5f..d6ba81d2 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "example": "examples" }, "scripts": { - "test": "echo 'lol'", + "test": "BABEL_ENV=test mocha --require './lib/__tests__/.setup.js' --compilers js:babel-register './lib/__tests__/Autocomplete-test.js'", + "coverage": "BABEL_ENV=test node_modules/.bin/babel-node node_modules/isparta/bin/isparta cover --report lcov node_modules/mocha/bin/_mocha -- --reporter dot --require './lib/__tests__/.setup.js' './lib/__tests__/Autocomplete-test.js'", "start": "rackt server" }, "authors": [ @@ -21,8 +22,21 @@ ], "license": "MIT", "devDependencies": { + "babel-cli": "^6.5.1", + "babel-preset-es2015": "^6.5.0", + "babel-preset-react": "^6.5.0", + "babel-preset-stage-0": "^6.5.0", + "babel-register": "^6.6.5", + "chai": "^3.5.0", + "chai-enzyme": "^0.4.0", + "enzyme": "^2.0.0", + "isparta": "^4.0.0", + "jsdom": "^8.1.0", + "mocha": "~2.4.5", + "mocha-jsdom": "^1.1.0", "rackt-cli": "^0.4.0", - "react": "^0.14.0" + "react": "^0.14.0", + "react-addons-test-utils": "^0.14.7" }, "tags": [ "react", @@ -32,6 +46,10 @@ ], "keywords": [], "dependencies": { - "dom-scroll-into-view": "1.0.1" + "babel-preset-es2015": "^6.5.0", + "dom-scroll-into-view": "1.0.1", + "lodash": "^4.5.0", + "react": "^0.14.7", + "react-dom": "^0.14.7" } }