react-select
Version:
A Select control built with and for ReactJS
577 lines (431 loc) • 15.3 kB
JavaScript
;
/* eslint react/jsx-boolean-value: 0 */
// 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(<Select.Async loadOptions={loadOptions} minimumInput={1}/>);
});
it('does not call loadOptions', () => {
expect(loadOptions, 'was not called');
});
it('renders the select with no options', () => {
return expect(renderer, 'to have rendered',
<Select options={ [] } noResultsText="Type to search"/>);
});
it('calls the loadOptions for each input', () => {
typeSearchText('te');
typeSearchText('tes');
typeSearchText('te');
return expect(loadOptions, 'was called times', 3);
});
it('shows "Loading..." after typing', () => {
typeSearchText('te');
return expect(renderer, 'to have rendered',
<Select
isLoading
placeholder="Loading..."
noResultsText="Searching..."
/>);
});
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',
<Select
isLoading={false}
placeholder="Select..."
noResultsText="No results found"
options={ [ { value: 1, label: 'test' } ] }
/>);
});
});
it('shows "Loading..." after typing if refined search matches none of the previous results', () => {
loadOptions.returns(expect.promise((resolve, reject) => {
resolve({ options: [{ value: 1, label: 'test' }] });
}));
typeSearchText('te');
return loadOptions.firstCall.returnValue.then(() => {
typeSearchText('ten');
return expect(renderer, 'to have rendered',
<Select
isLoading
placeholder="Loading..."
noResultsText="Searching..."
/>);
});
});
it('ignores the result of an earlier call, when the responses come in the wrong order', () => {
let promise1Resolve, promise2Resolve;
const promise1 = expect.promise((resolve, reject) => {
promise1Resolve = resolve;
});
const promise2 = expect.promise((resolve, reject) => {
promise2Resolve = resolve;
});
loadOptions.withArgs('te').returns(promise1);
loadOptions.withArgs('tes').returns(promise2);
const firstResult = typeSearchText('te');
const secondResult = typeSearchText('tes');
promise2Resolve({ options: [ { value: 2, label: 'test' } ] });
promise1Resolve({ options: [ { value: 1, label: 'test to ignore' } ] });
return expect.promise.all([firstResult, secondResult]).then(() => {
return expect(renderer, 'to have rendered',
<Select
isLoading={false}
placeholder="Select..."
noResultsText="No results found"
options={ [ { value: 2, label: 'test' } ] }
/>);
});
});
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', <Select options={ [] }/>);
});
});
});
describe('with an onInputChange prop', () => {
let input;
const onInputChange = v => input = v;
beforeEach(() => {
// Render an instance of the component
renderer.render(<Select.Async
loadOptions={loadOptions}
minimumInput={1}
onInputChange={onInputChange}
/>);
});
it('calls through to the provided onInputChange function', () => {
typeSearchText('hi');
return expect(input, 'to equal', 'hi');
});
});
describe('with an isLoading prop', () => {
beforeEach(() => {
// Render an instance of the component
renderer.render(<Select.Async
loadOptions={loadOptions}
minimumInput={1}
isLoading
/>);
});
it('forces the isLoading prop on Select', () => {
return expect(renderer, 'to have rendered',
<Select
isLoading
noResultsText="Searching..."
placeholder="Loading..."
/>);
});
});
describe('with a cache', () => {
beforeEach(() => {
// Render an instance of the component
renderer.render(<Select.Async
loadOptions={loadOptions}
minimumInput={1}
cache={ {} }
/>);
});
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(<Select.Async
loadOptions={loadOptions}
minimumInput={1}
cache={ { 'tes': { options: [ { value: 1, label: 'updated cache' } ] } } }
/>);
typeSearchText('tes');
expect(loadOptions, 'was not called');
return expect(renderer, 'to have rendered',
<Select options={ [ { value: 1, label: 'updated cache' } ] } />
);
});
it('uses the longest complete response if the responses come out-of-order', () => {
let resolveTe, resolveTes;
const promises = [];
promises.push(loadOptions.withArgs('te').returns(expect.promise((resolve, reject) => {
resolveTe = () => resolve({
complete: true,
options: [ { value: 'te', label: 'test 1' } ]
});
})));
promises.push(loadOptions.withArgs('tes').returns(expect.promise((resolve, reject) => {
resolveTes = () => resolve({
complete: true,
options: [ { value: 'tes', label: 'test 2' } ]
});
})));
/* Order is:
* 1. User types 'te'
* 2. loadOptions is called with 'te'
* 3. User types 'tes'
* 4. loadOptions is called with 'tes'
* 5. Response for 'tes' comes back, with { complete: true }
* 6. Response for 'te' comes back, with { complete: true }
* 7. User types 'test'
* 8. Expect that we get the results for 'tes' as options, not 'te'
*/
typeSearchText('te');
typeSearchText('tes');
resolveTes();
resolveTe();
return expect.promise.all(promises).then(() => {
typeSearchText('test');
expect(loadOptions, 'was called times', 2);
return expect(renderer, 'to have rendered', <Select options={
[ { value: 'tes', label: 'test 2' }]
} />);
});
});
});
describe('using callbacks', () => {
beforeEach(() => {
// Render an instance of the component
renderer.render(<Select.Async loadOptions={loadOptions} minimumInput={1}/>);
});
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',
<Select
isLoading={false}
placeholder="Select..."
noResultsText="No results found"
options={ [ { value: 1, label: 'test callback' } ] }
/>);
});
it('renders results for each callback', () => {
typeSearchText('te');
typeSearchText('tes');
expect(loadOptions, 'was called times', 2);
const callback1 = loadOptions.args[0][1];
const callback2 = loadOptions.args[1][1];
callback1(null, { options: [ { value: 1, label: 'test callback' } ] });
callback2(null, { options: [ { value: 2, label: 'test callback 2' } ] });
return expect(renderer, 'to have rendered',
<Select
isLoading={false}
placeholder="Select..."
noResultsText="No results found"
options={ [ { value: 2, label: 'test callback 2' } ] }
/>);
});
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',
<Select
isLoading={false}
placeholder="Select..."
noResultsText="No results found"
options={ [ { value: 2, label: 'test callback 2' } ] }
/>);
});
it('uses the cache when available', () => {
renderer.render(<Select.Async cache={true} loadOptions={loadOptions} />);
typeSearchText('te');
loadOptions.args[0][1](null, { options: [ { value: 1, label: 'test1' } ] });
typeSearchText('tes');
loadOptions.args[1][1](null, { options: [ { value: 2, label: 'test2' } ] });
typeSearchText('te');
expect(loadOptions, 'was called times', 2);
return expect(renderer, 'to have rendered',
<Select options={ [ { value: 1, label: 'test1' } ] } />);
});
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', <Select options={ [] } />);
});
});
describe('with minimumInput', () => {
describe('=0', () => {
beforeEach(() => {
renderer.render(<Select.Async loadOptions={loadOptions} minimumInput={0} cache={false} />);
});
it('calls loadOptions immediately', () => {
// componentDidMount is not currently called with the shallowRenderer, so we need to fake it here
// When we upgrade to 0.15, we'll be able to use renderer.getMountedInstance()
// (or, componentDidMount will be called with the shallow renderer)
renderer._instance._instance.componentDidMount();
expect(loadOptions, 'was called');
});
it('calls loadOptions again when input returns to empty', () => {
renderer._instance._instance.componentDidMount();
typeSearchText('t');
typeSearchText('');
expect(loadOptions, 'was called times', 3);
});
});
describe('=3', () => {
beforeEach(() => {
renderer.render(<Select.Async loadOptions={loadOptions} minimumInput={3} cache={false} />);
});
it('does not call loadOptions initially', () => {
renderer._instance._instance.componentDidMount();
expect(loadOptions, 'was not called');
});
it('does not call loadOptions when 2 characters are entered', () => {
typeSearchText('te');
expect(loadOptions, 'was not called');
});
it('calls loadOptions when 3 characters are entered', () => {
typeSearchText('tes');
expect(loadOptions, 'was called');
});
it('resets to `type to search` when text returns to < 3', () => {
typeSearchText('tes');
loadOptions.args[0][1](null, { options: [ { value: 1, label: 'test1' } ] });
typeSearchText('te');
return expect(renderer, 'to have rendered',
<Select
options={ [] }
placeholder="Select..."
noResultsText="Type to search"
/>);
});
});
});
describe('with ignoreAccents', () => {
beforeEach(() => {
renderer.render(<Select.Async
loadOptions={loadOptions}
ignoreAccents={true}
ignoreCase={false}
cache={false} />);
});
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(<Select.Async
loadOptions={loadOptions}
ignoreAccents={false}
ignoreCase={true}
cache={false} />);
});
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(<Select.Async
loadOptions={loadOptions}
ignoreAccents={true}
ignoreCase={true}
cache={false} />);
});
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');
});
});
});