diff --git a/package.json b/package.json index 16e730a9e7..c840c7076a 100644 --- a/package.json +++ b/package.json @@ -27,12 +27,13 @@ "mocha": "^2.3.3", "react": "^0.14.1", "react-addons-test-utils": "^0.14.1", + "react-component-gulp-tasks": "^0.7.7", "react-dom": "^0.14.1", "react-gravatar": "^2.2.2", - "react-component-gulp-tasks": "^0.7.7", "sinon": "^1.17.2", "unexpected": "^10.0.2", "unexpected-dom": "^3.0.2", + "unexpected-react": "^0.3.0", "unexpected-sinon": "^8.0.0" }, "peerDependencies": { diff --git a/src/Async.js b/src/Async.js index 74d7087e94..a8ec0c7a9a 100644 --- a/src/Async.js +++ b/src/Async.js @@ -19,7 +19,7 @@ function updateCache (cache, input, data) { function getFromCache (cache, input) { if (!cache) return; - for (let i = 0; i <= input.length; i++) { + for (let i = input.length; i >= 0; --i) { let cacheKey = input.slice(0, i); if (cache[cacheKey] && (input === cacheKey || cache[cacheKey].complete)) { return cache[cacheKey]; @@ -29,7 +29,7 @@ function getFromCache (cache, input) { function thenPromise (promise, callback) { if (!promise || typeof promise.then !== 'function') return; - promise.then((data) => { + return promise.then((data) => { callback(null, data); }, (err) => { callback(err); @@ -118,7 +118,7 @@ const Async = React.createClass({ isLoading: true, }); let responseHandler = this.getResponseHandler(input); - thenPromise(this.props.loadOptions(input, responseHandler), responseHandler); + return thenPromise(this.props.loadOptions(input, responseHandler), responseHandler); }, render () { let { noResultsText } = this.props; diff --git a/test/Async-test.js b/test/Async-test.js new file mode 100644 index 0000000000..fe74bfd7ff --- /dev/null +++ b/test/Async-test.js @@ -0,0 +1,534 @@ +// Emulating the DOM here, only so that if this test file gets +// included first, then React thinks there's a DOM, so the other tests +// (e.g. Select-test.js) that do require a DOM work correctly + +var jsdomHelper = require('../testHelpers/jsdomHelper'); +jsdomHelper(); +var unexpected = require('unexpected'); +var unexpectedReact = require('unexpected-react'); +var unexpectedSinon = require('unexpected-sinon'); +var expect = unexpected + .clone() + .installPlugin(unexpectedReact) + .installPlugin(unexpectedSinon); + +var React = require('react'); +var ReactDOM = require('react-dom'); +var TestUtils = require('react-addons-test-utils'); +var sinon = require('sinon'); + + +var Select = require('../src/Select'); + +describe('Async', () => { + + var renderer; + var loadOptions; + + const typeSearchText = function(text) { + const output = renderer.getRenderOutput(); + // onInputChange is actually bound to loadOptions(...), + // loadOptions returns the promise, if there is one, so we can use this + // to chain the assertions onto + return output.props.onInputChange(text); + }; + + beforeEach(() => { + + renderer = TestUtils.createRenderer(); + loadOptions = sinon.stub(); + }); + + describe('using promises', () => { + + + beforeEach(() => { + + // Render an instance of the component + renderer.render(); + }); + + + it('does not call loadOptions', () => { + + expect(loadOptions, 'was not called'); + }); + + it('renders the select with no options', () => { + + return expect(renderer, 'to have rendered', + ); + }); + + + it('shows the returns options when the result returns', () => { + + // Unexpected comes with promises built in - we'll use them + // rather than depending on another library + loadOptions.returns(expect.promise((resolve, reject) => { + resolve({ options: [{ value: 1, label: 'test' }] }); + })); + + typeSearchText('te'); + + return loadOptions.firstCall.returnValue.then(() => { + + return expect(renderer, 'to have rendered', + ); + }); + }); + + it('treats a rejected promise as empty options', () => { + + let promise1Resolve, promise2Reject; + + const promise1 = expect.promise((resolve, reject) => { + promise1Resolve = resolve; + }); + const promise2 = expect.promise((resolve, reject) => { + promise2Reject = reject; + }); + loadOptions.withArgs('te').returns(promise1); + + loadOptions.withArgs('tes').returns(promise2); + + const result1 = typeSearchText('te'); + const result2 = typeSearchText('tes'); + promise1Resolve({ options: [ { value: 1, label: 'from te input'}]}); + promise2Reject(); + + return expect.promise.all([ result1, result2]).then(() => { + // Previous results (from 'te') are thrown away, and we render with an empty options list + return expect(renderer, 'to have rendered', ); + }) + }); + + describe('with a cache', () => { + + beforeEach(() => { + + // Render an instance of the component + renderer.render(); + }); + + it('caches previous responses', () => { + loadOptions.withArgs('te').returns(expect.promise((resolve,reject) => { + resolve({ options: [{ value: 'te', label: 'test 1' }] }); + })); + loadOptions.withArgs('tes').returns(expect.promise((resolve,reject) => { + resolve({ options: [{ value: 'tes', label: 'test 2' } ] }); + })); + + // Need to wait for the results be returned, otherwise the cache won't be used + return typeSearchText('te') + .then(() => typeSearchText('tes')) + .then(() => typeSearchText('te')) // this should use the cache + .then(() => { + return expect(loadOptions, 'was called times', 2); + }); + }); + + it('does not call `loadOptions` for a longer input, after a `complete=true` response', () => { + loadOptions.withArgs('te').returns(expect.promise((resolve,reject) => { + resolve({ + complete: true, + options: [{ value: 'te', label: 'test 1' }] + }); + })); + + return typeSearchText('te') + .then(() => typeSearchText('tes')) + .then(() => { + return expect(loadOptions, 'was called once'); + }); + }); + + it('updates the cache when the cache is reset as a prop', () => { + + renderer.render(); + + typeSearchText('tes'); + expect(loadOptions, 'was not called'); + return expect(renderer, 'to have rendered', + ); + + }); + + }); + }); + + describe('using callbacks', () => { + + beforeEach(() => { + + // Render an instance of the component + renderer.render(); + }); + + it('shows the returns options when the result returns', () => { + + typeSearchText('te'); + + expect(loadOptions, 'was called'); + + const callback = loadOptions.args[0][1]; + + callback(null, { options: [ { value: 1, label: 'test callback' } ] }); + + + return expect(renderer, 'to have rendered', + ); + }); + + it('ignores callbacks from earlier requests (out-of-order responses)', () => { + + typeSearchText('te'); + typeSearchText('tes'); + + expect(loadOptions, 'was called times', 2); + + const callback1 = loadOptions.args[0][1]; + const callback2 = loadOptions.args[1][1]; + + callback2(null, { options: [ { value: 2, label: 'test callback 2' } ] }); + // Callback1 should be ignored + callback1(null, { options: [ { value: 1, label: 'test callback' } ] }); + + return expect(renderer, 'to have rendered', + ); + }); + + it('throws an error when the callback returns an error', () => { + + typeSearchText('te'); + + expect(() => loadOptions.args[0][1](new Error('Something went wrong')), + 'to throw', 'Something went wrong'); + }); + + it('assumes no options when the result has no `options` property', () => { + + typeSearchText('te'); + loadOptions.args[0][1](null, [ { value: 1, label: 'should be wrapped in an object' } ] ); + + return expect(renderer, 'to have rendered', ); + + }); + }); + }); + + describe('with ignoreAccents', () => { + + beforeEach(() => { + renderer.render(); + }); + + it('calls loadOptions with unchanged text', () => { + + typeSearchText('TeSt'); + expect(loadOptions, 'was called with', 'TeSt'); + }); + + it('calls loadOptions with accents stripped', () => { + typeSearchText('Gedünstmaßig'); + // This should really be Gedunstmassig: ß -> ss + expect(loadOptions, 'was called with', 'Gedunstmasig'); + }); + }); + + describe('with ignore case', () => { + + beforeEach(() => { + + renderer.render(); + }); + + it('converts everything to lowercase', () => { + + typeSearchText('TeSt'); + expect(loadOptions, 'was called with', 'test'); + }); + + it('converts accents to lowercase', () => { + + typeSearchText('WÄRE'); + expect(loadOptions, 'was called with', 'wäre'); + }); + }); + + describe('with ignore case and ignore accents', () => { + + beforeEach(() => { + + renderer.render(); + }); + + it('converts everything to lowercase', () => { + + typeSearchText('TeSt'); + expect(loadOptions, 'was called with', 'test'); + }); + + it('removes accents and converts to lowercase', () => { + + typeSearchText('WÄRE'); + expect(loadOptions, 'was called with', 'ware'); + }); + }); +}); + diff --git a/test/Select-test.js b/test/Select-test.js index d383903260..d3305a4d47 100644 --- a/test/Select-test.js +++ b/test/Select-test.js @@ -1155,386 +1155,6 @@ describe('Select', () => { }); }); - describe('with async options', () => { - - // TODO: Need to use the new Select.Async control for this - return; - - var asyncOptions; - - beforeEach(() => { - - asyncOptions = sinon.stub(); - - asyncOptions.withArgs('te').callsArgWith(1, null, { - options: [ - { value: 'test', label: 'TEST one' }, - { value: 'test2', label: 'TEST two' }, - { value: 'tell', label: 'TELL three' } - ] - }); - - asyncOptions.withArgs('tes').callsArgWith(1, null, { - options: [ - { value: 'test', label: 'TEST one' }, - { value: 'test2', label: 'TEST two' } - ] - }); - - - }); - - describe('with autoload=true', () => { - - beforeEach(() => { - - // Render an instance of the component - wrapper = createControlWithWrapper({ - value: '', - asyncOptions: asyncOptions, - autoload: true - }); - }); - - - it('calls the asyncOptions initially with autoload=true', () => { - - expect(asyncOptions, 'was called with', ''); - }); - - it('calls the asyncOptions again when the input changes', () => { - - typeSearchText('ab'); - - expect(asyncOptions, 'was called twice'); - expect(asyncOptions, 'was called with', 'ab'); - }); - - it('shows the returned options after asyncOptions calls back', () => { - - typeSearchText('te'); - - var optionList = ReactDOM.findDOMNode(instance).querySelectorAll('.Select-menu .Select-option'); - expect(optionList, 'to have length', 3); - expect(optionList[0], 'to have text', 'TEST one'); - expect(optionList[1], 'to have text', 'TEST two'); - expect(optionList[2], 'to have text', 'TELL three'); - }); - - it('uses the options cache when the same text is entered again', () => { - - - typeSearchText('te'); - typeSearchText('tes'); - - expect(asyncOptions, 'was called times', 3); - - typeSearchText('te'); - - expect(asyncOptions, 'was called times', 3); - - }); - - it('displays the correct options from the cache after the input is changed back to a previous value', () => { - - typeSearchText('te'); - typeSearchText('tes'); - typeSearchText('te'); - // Double check the options list is still correct - var optionList = ReactDOM.findDOMNode(instance).querySelectorAll('.Select-menu .Select-option'); - expect(optionList, 'to have length', 3); - expect(optionList[0], 'to have text', 'TEST one'); - expect(optionList[1], 'to have text', 'TEST two'); - expect(optionList[2], 'to have text', 'TELL three'); - }); - - it('re-filters an existing options list if complete:true is provided', () => { - - asyncOptions.withArgs('te').callsArgWith(1, null, { - options: [ - { value: 'test', label: 'TEST one' }, - { value: 'test2', label: 'TEST two' }, - { value: 'tell', label: 'TELL three' } - ], - complete: true - }); - - typeSearchText('te'); - expect(asyncOptions, 'was called times', 2); - typeSearchText('tel'); - expect(asyncOptions, 'was called times', 2); - var optionList = ReactDOM.findDOMNode(instance).querySelectorAll('.Select-menu .Select-option'); - expect(optionList, 'to have length', 1); - expect(optionList[0], 'to have text', 'TELL three'); - }); - - it('rethrows the error if err is set in the callback', () => { - - asyncOptions.withArgs('tes').callsArgWith(1, new Error('Something\'s wrong jim'), { - options: [ - { value: 'test', label: 'TEST one' }, - { value: 'test2', label: 'TEST two' } - ] - }); - - expect(() => { - typeSearchText('tes'); - }, 'to throw exception', new Error('Something\'s wrong jim')); - }); - - it('calls the asyncOptions function when the value prop changes', () => { - - expect(asyncOptions, 'was called once'); - - wrapper.setPropsForChild({ value: 'test2' }); - - expect(asyncOptions, 'was called twice'); - }); - - - }); - - describe('with autoload=false', () => { - - beforeEach(() => { - - // Render an instance of the component - instance = createControl({ - value: '', - asyncOptions: asyncOptions, - autoload: false - }); - }); - - it('does not initially call asyncOptions', () => { - - expect(asyncOptions, 'was not called'); - }); - - it('calls the asyncOptions on first key entry', () => { - - typeSearchText('a'); - expect(asyncOptions, 'was called with', 'a'); - }); - }); - - describe('with cacheAsyncResults=false', () => { - - beforeEach(() => { - - // Render an instance of the component - wrapper = createControlWithWrapper({ - value: '', - asyncOptions: asyncOptions, - cacheAsyncResults: false - }); - - // Focus on the input, such that mouse events are accepted - searchInputNode = ReactDOM.findDOMNode(instance.getInputNode()).querySelector('input'); - TestUtils.Simulate.focus(searchInputNode); - }); - - it('does not use cache when the same text is entered again', () => { - - typeSearchText('te'); - typeSearchText('tes'); - - expect(asyncOptions, 'was called times', 3); - - typeSearchText('te'); - - expect(asyncOptions, 'was called times', 4); - - }); - - it('updates the displayed value after changing value and refreshing from asyncOptions', () => { - - asyncOptions.reset(); - asyncOptions.callsArgWith(1, null, { - options: [ - { value: 'newValue', label: 'New Value from Server' }, - { value: 'test', label: 'TEST one' } - ] - }); - - wrapper.setPropsForChild({ value: 'newValue' }); - - expect(ReactDOM.findDOMNode(instance), 'queried for first', DISPLAYED_SELECTION_SELECTOR, - 'to have text', 'New Value from Server'); - }); - - - }); - }); - - describe('with async options (using promises)', () => { - - var asyncOptions, callCount, callInput; - - // TODO: Async Options need to use the Select.Async component - return; - - beforeEach(() => { - - asyncOptions = sinon.spy((input) => { - const options = [ - { value: 'test', label: 'TEST one' }, - { value: 'test2', label: 'TEST two' }, - { value: 'tell', label: 'TELL three' } - ].filter((elm) => { - return (elm.value.indexOf(input) !== -1 || elm.label.indexOf(input) !== -1); - }); - - return new Promise((resolve, reject) => { - input === '_FAIL'? reject('nope') : resolve({options: options}); - }); - }); - }); - - describe('[mocked promise]', () => { - - beforeEach(() => { - - // Render an instance of the component - wrapper = createControlWithWrapper({ - value: '', - asyncOptions: asyncOptions, - autoload: true - }); - }); - - it('should fulfill asyncOptions promise', () => { - return expect(instance.props.asyncOptions(''), 'to be fulfilled'); - }); - - it('should fulfill with 3 options when asyncOptions promise with input = "te"', () => { - return expect(instance.props.asyncOptions('te'), 'to be fulfilled with', { - options: [ - { value: 'test', label: 'TEST one' }, - { value: 'test2', label: 'TEST two' }, - { value: 'tell', label: 'TELL three' } - ] - }); - }); - - it('should fulfill with 2 options when asyncOptions promise with input = "tes"', () => { - return expect(instance.props.asyncOptions('tes'), 'to be fulfilled with', { - options: [ - { value: 'test', label: 'TEST one' }, - { value: 'test2', label: 'TEST two' } - ] - }); - }); - - it('should reject when asyncOptions promise with input = "_FAIL"', () => { - return expect(instance.props.asyncOptions('_FAIL'), 'to be rejected'); - }); - }); - - describe('with autoload=true', () => { - - beforeEach(() => { - - // Render an instance of the component - wrapper = createControlWithWrapper({ - value: '', - asyncOptions: asyncOptions, - autoload: true - }); - }); - - it('should be called once at the beginning', () => { - expect(asyncOptions, 'was called'); - }); - - it('calls the asyncOptions again when the input changes', () => { - - typeSearchText('ab'); - - expect(asyncOptions, 'was called twice'); - expect(asyncOptions, 'was called with', 'ab'); - }); - - it('shows the returned options after asyncOptions promise is resolved', (done) => { - - typeSearchText('te'); - - return asyncOptions.secondCall.returnValue.then(() => { - setTimeout(() => { - expect(ReactDOM.findDOMNode(instance), 'queried for', '.Select-option', - 'to satisfy', [ - expect.it('to have text', 'TEST one'), - expect.it('to have text', 'TEST two'), - expect.it('to have text', 'TELL three') - ]); - done(); - }); - }); - - }); - - it('doesn\'t update the returned options when asyncOptions is rejected', (done) => { - - typeSearchText('te'); - expect(asyncOptions, 'was called with', 'te'); - - asyncOptions.secondCall.returnValue.then(() => { - setTimeout(() => { - expect(ReactDOM.findDOMNode(instance), 'queried for', '.Select-option', - 'to satisfy', [ - expect.it('to have text', 'TEST one'), - expect.it('to have text', 'TEST two'), - expect.it('to have text', 'TELL three') - ]); - - // asyncOptions mock is set to reject the promise when invoked with '_FAIL' - typeSearchText('_FAIL'); - expect(asyncOptions, 'was called with', '_FAIL'); - - asyncOptions.thirdCall.returnValue.then(null, () => { - setTimeout(() => { - expect(ReactDOM.findDOMNode(instance), 'queried for', '.Select-option', - 'to satisfy', [ - expect.it('to have text', 'TEST one'), - expect.it('to have text', 'TEST two'), - expect.it('to have text', 'TELL three') - ]); - - done(); - }); - - }); - }); - - }); - }); - - }); - - describe('with autoload=false', () => { - - beforeEach(() => { - - // Render an instance of the component - instance = createControl({ - value: '', - asyncOptions: asyncOptions, - autoload: false - }); - }); - - it('does not initially call asyncOptions', () => { - expect(asyncOptions, 'was not called'); - }); - - it('calls the asyncOptions on first key entry', () => { - - typeSearchText('a'); - expect(asyncOptions, 'was called once'); - expect(asyncOptions, 'was called with', 'a'); - }); - }); - }); describe('with multi-select', () => {