From b319024b20745e2cf788e7d08978376b493a08ac Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Sun, 18 Sep 2016 06:30:36 -0700 Subject: [PATCH] Implemented and tested cache. Also fixed a spacing issue. --- src/Async.js | 39 +++-- test/Async-test.js | 397 +++++++++++++++++++++++++-------------------- 2 files changed, 246 insertions(+), 190 deletions(-) diff --git a/src/Async.js b/src/Async.js index 5886f9ba56..8f8d7bb36a 100644 --- a/src/Async.js +++ b/src/Async.js @@ -2,21 +2,20 @@ import React, { Component, PropTypes } from 'react'; import Select from './Select'; import stripDiacritics from './utils/stripDiacritics'; -// @TODO Implement cache - const propTypes = { - autoload: React.PropTypes.bool.isRequired, - children: React.PropTypes.func.isRequired, // Child function responsible for creating the inner Select component; (props: Object): PropTypes.element - ignoreAccents: React.PropTypes.bool, // whether to strip diacritics when filtering (shared with Select) - ignoreCase: React.PropTypes.bool, // whether to perform case-insensitive filtering (shared with Select) - loadingPlaceholder: PropTypes.string.isRequired, - loadOptions: React.PropTypes.func.isRequired, - options: PropTypes.array.isRequired, - placeholder: React.PropTypes.oneOfType([ + autoload: React.PropTypes.bool.isRequired, // automatically call the `loadOptions` prop on-mount; defaults to true + cache: React.PropTypes.any, // object to use to cache results; set to null/false to disable caching + children: React.PropTypes.func.isRequired, // Child function responsible for creating the inner Select component; (props: Object): PropTypes.element + ignoreAccents: React.PropTypes.bool, // strip diacritics when filtering; defaults to true + ignoreCase: React.PropTypes.bool, // perform case-insensitive filtering; defaults to true + loadingPlaceholder: PropTypes.string.isRequired, // replaces the placeholder while options are loading + loadOptions: React.PropTypes.func.isRequired, // callback to load options asynchronously; (inputValue: string, callback: Function): ?Promise + options: PropTypes.array.isRequired, // array of options + placeholder: React.PropTypes.oneOfType([ // field placeholder, displayed when there's no value (shared with Select) React.PropTypes.string, React.PropTypes.node ]), - searchPromptText: React.PropTypes.oneOfType([ + searchPromptText: React.PropTypes.oneOfType([ // label to prompt for search input React.PropTypes.string, React.PropTypes.node ]), @@ -24,6 +23,7 @@ const propTypes = { const defaultProps = { autoload: true, + cache: {}, children: defaultChildren, ignoreAccents: true, ignoreCase: true, @@ -64,7 +64,18 @@ export default class Async extends Component { } loadOptions (inputValue) { - const { loadOptions } = this.props; + const { cache, loadOptions } = this.props; + + if ( + cache && + cache.hasOwnProperty(inputValue) + ) { + this.setState({ + options: cache[inputValue] + }); + + return; + } const callback = (error, data) => { if (callback === this._callback) { @@ -72,6 +83,10 @@ export default class Async extends Component { const options = data && data.options || []; + if (cache) { + cache[inputValue] = options; + } + this.setState({ isLoading: false, options diff --git a/test/Async-test.js b/test/Async-test.js index 05ff08699a..2ca62e207d 100644 --- a/test/Async-test.js +++ b/test/Async-test.js @@ -11,9 +11,9 @@ var unexpected = require('unexpected'); var unexpectedReact = require('unexpected-react'); var unexpectedSinon = require('unexpected-sinon'); var expect = unexpected - .clone() - .installPlugin(unexpectedReact) - .installPlugin(unexpectedSinon); + .clone() + .installPlugin(unexpectedReact) + .installPlugin(unexpectedSinon); var React = require('react'); var ReactDOM = require('react-dom'); @@ -23,154 +23,195 @@ var sinon = require('sinon'); var Select = require('../src/Select'); describe('Async', () => { - let asyncInstance, asyncNode, filterInputNode, loadOptions, renderer; - - beforeEach(() => renderer = TestUtils.createRenderer()); - - function createControl (props = {}) { - loadOptions = props.loadOptions || sinon.stub(); - asyncInstance = TestUtils.renderIntoDocument( - - ); - asyncNode = ReactDOM.findDOMNode(asyncInstance); - findAndFocusInputControl(); - }; - - function createOptionsResponse (options) { - return { - options: options.map((option) => ({ - label: option, - value: option - })) + let asyncInstance, asyncNode, filterInputNode, loadOptions; + + function createControl (props = {}) { + loadOptions = props.loadOptions || sinon.stub(); + asyncInstance = TestUtils.renderIntoDocument( + + ); + asyncNode = ReactDOM.findDOMNode(asyncInstance); + findAndFocusInputControl(); + }; + + function createOptionsResponse (options) { + return { + options: options.map((option) => ({ + label: option, + value: option + })) }; - } - - function findAndFocusInputControl () { - filterInputNode = asyncNode.querySelector('input'); - if (filterInputNode) { - TestUtils.Simulate.focus(filterInputNode); - } - }; - - function typeSearchText (text) { - TestUtils.Simulate.change(filterInputNode, { target: { value: text } }); - }; - - describe('autoload', () => { - it('false does not call loadOptions on-mount', () => { - createControl({ - autoload: false - }); - expect(loadOptions, 'was not called'); - }); - - it('true calls loadOptions on-mount', () => { - createControl({ - autoload: true, - }); - expect(loadOptions, 'was called'); - }); - }); - - describe('loadOptions', () => { - it('calls the loadOptions when search input text changes', () => { - createControl({ - autoload: false - }); - typeSearchText('te'); - typeSearchText('tes'); - typeSearchText('te'); - return expect(loadOptions, 'was called times', 3); - }); - - it('shows the loadingPlaceholder text while options are being fetched', () => { - function loadOptions (input, callback) {} - createControl({ - loadOptions, - loadingPlaceholder: 'Loading' - }); - typeSearchText('te'); - return expect(asyncNode.textContent, 'to contain', 'Loading'); - }); - - describe('with callbacks', () => { - it('should display the loaded options', () => { - function loadOptions (input, resolve) { - resolve(null, createOptionsResponse(['foo'])); - } - createControl({ - autoload: false, - loadOptions - }); - expect(asyncNode.querySelectorAll('[role=option]').length, 'to equal', 0); - typeSearchText('foo'); - expect(asyncNode.querySelectorAll('[role=option]').length, 'to equal', 1); - expect(asyncNode.querySelector('[role=option]').textContent, 'to equal', 'foo'); - }); - - it('should display the most recently-requested loaded options (if results are returned out of order)', () => { - const callbacks = []; - function loadOptions (input, callback) { - callbacks.push(callback); - } - createControl({ - autoload: false, - loadOptions - }); - typeSearchText('foo'); - typeSearchText('bar'); - expect(asyncNode.querySelectorAll('[role=option]').length, 'to equal', 0); - callbacks[1](null, createOptionsResponse(['bar'])); - callbacks[0](null, createOptionsResponse(['foo'])); - expect(asyncNode.querySelectorAll('[role=option]').length, 'to equal', 1); - expect(asyncNode.querySelector('[role=option]').textContent, 'to equal', 'bar'); - }); - - it('should handle an error by setting options to an empty array', () => { - function loadOptions (input, resolve) { - resolve(new Error('error')); - } - createControl({ - autoload: false, - loadOptions, - options: createOptionsResponse(['foo']).options - }); - expect(asyncNode.querySelectorAll('[role=option]').length, 'to equal', 1); - typeSearchText('bar'); - expect(asyncNode.querySelectorAll('[role=option]').length, 'to equal', 0); - }); - }); - - /* @TODO The Promise-based tests aren't working yet; not sure why - describe('with promises', () => { - it('should display the loaded options', () => { - let promise; - function loadOptions (input) { - promise = expect.promise((resolve) => { + } + + function findAndFocusInputControl () { + filterInputNode = asyncNode.querySelector('input'); + if (filterInputNode) { + TestUtils.Simulate.focus(filterInputNode); + } + }; + + function typeSearchText (text) { + TestUtils.Simulate.change(filterInputNode, { target: { value: text } }); + }; + + describe('autoload', () => { + it('false does not call loadOptions on-mount', () => { + createControl({ + autoload: false + }); + expect(loadOptions, 'was not called'); + }); + + it('true calls loadOptions on-mount', () => { + createControl({ + autoload: true + }); + expect(loadOptions, 'was called'); + }); + }); + + describe('cache', () => { + it('should be used instead of loadOptions if input has been previously loaded', () => { + createControl(); + typeSearchText('a'); + return expect(loadOptions, 'was called times', 1); + typeSearchText('b'); + return expect(loadOptions, 'was called times', 2); + typeSearchText('a'); + return expect(loadOptions, 'was called times', 2); + typeSearchText('b'); + return expect(loadOptions, 'was called times', 2); + typeSearchText('c'); + return expect(loadOptions, 'was called times', 3); + }); + + it('can be disabled by passing null/false', () => { + createControl({ + cache: false + }); + typeSearchText('a'); + return expect(loadOptions, 'was called times', 1); + typeSearchText('b'); + return expect(loadOptions, 'was called times', 2); + typeSearchText('a'); + return expect(loadOptions, 'was called times', 3); + typeSearchText('b'); + return expect(loadOptions, 'was called times', 4); + }); + + it('can be customized', () => { + createControl({ + cache: { + a: [] + } + }); + typeSearchText('a'); + return expect(loadOptions, 'was called times', 0); + typeSearchText('b'); + return expect(loadOptions, 'was called times', 1); + typeSearchText('a'); + return expect(loadOptions, 'was called times', 1); + }); + }); + + describe('loadOptions', () => { + it('calls the loadOptions when search input text changes', () => { + createControl(); + typeSearchText('te'); + typeSearchText('tes'); + typeSearchText('te'); + return expect(loadOptions, 'was called times', 3); + }); + + it('shows the loadingPlaceholder text while options are being fetched', () => { + function loadOptions (input, callback) {} + createControl({ + loadOptions, + loadingPlaceholder: 'Loading' + }); + typeSearchText('te'); + return expect(asyncNode.textContent, 'to contain', 'Loading'); + }); + + describe('with callbacks', () => { + it('should display the loaded options', () => { + function loadOptions (input, resolve) { + resolve(null, createOptionsResponse(['foo'])); + } + createControl({ + cache: false, + loadOptions + }); + expect(asyncNode.querySelectorAll('[role=option]').length, 'to equal', 0); + typeSearchText('foo'); + expect(asyncNode.querySelectorAll('[role=option]').length, 'to equal', 1); + expect(asyncNode.querySelector('[role=option]').textContent, 'to equal', 'foo'); + }); + + it('should display the most recently-requested loaded options (if results are returned out of order)', () => { + const callbacks = []; + function loadOptions (input, callback) { + callbacks.push(callback); + } + createControl({ + cache: false, + loadOptions + }); + typeSearchText('foo'); + typeSearchText('bar'); + expect(asyncNode.querySelectorAll('[role=option]').length, 'to equal', 0); + callbacks[1](null, createOptionsResponse(['bar'])); + callbacks[0](null, createOptionsResponse(['foo'])); + expect(asyncNode.querySelectorAll('[role=option]').length, 'to equal', 1); + expect(asyncNode.querySelector('[role=option]').textContent, 'to equal', 'bar'); + }); + + it('should handle an error by setting options to an empty array', () => { + function loadOptions (input, resolve) { + resolve(new Error('error')); + } + createControl({ + cache: false, + loadOptions, + options: createOptionsResponse(['foo']).options + }); + expect(asyncNode.querySelectorAll('[role=option]').length, 'to equal', 1); + typeSearchText('bar'); + expect(asyncNode.querySelectorAll('[role=option]').length, 'to equal', 0); + }); + }); + + /* @TODO The Promise-based tests aren't working yet; not sure why + describe('with promises', () => { + it('should display the loaded options', () => { + let promise; + function loadOptions (input) { + promise = expect.promise((resolve) => { resolve(createOptionsResponse(['foo'])); }); - return promise; - } - createControl({ - autoload: false, - loadOptions - }); - expect(asyncNode.querySelectorAll('[role=option]').length, 'to equal', 0); - typeSearchText('foo'); - return expect.promise.all([promise]) - .then(() => expect(asyncNode.querySelectorAll('[role=option]').length, 'to equal', 1)) - .then(() => expect(asyncNode.querySelector('[role=option]').textContent, 'to equal', 'foo')); - }); - - it('should display the most recently-requested loaded options (if results are returned out of order)', () => { - createControl({ - autoload: false, - loadOptions - }); + return promise; + } + createControl({ + autoload: false, + loadOptions + }); + expect(asyncNode.querySelectorAll('[role=option]').length, 'to equal', 0); + typeSearchText('foo'); + return expect.promise.all([promise]) + .then(() => expect(asyncNode.querySelectorAll('[role=option]').length, 'to equal', 1)) + .then(() => expect(asyncNode.querySelector('[role=option]').textContent, 'to equal', 'foo')); + }); + + it('should display the most recently-requested loaded options (if results are returned out of order)', () => { + createControl({ + autoload: false, + loadOptions + }); let resolveFoo, resolveBar; const promiseFoo = expect.promise((resolve) => { resolveFoo = resolve; @@ -180,36 +221,36 @@ describe('Async', () => { }); loadOptions.withArgs('foo').returns(promiseFoo); loadOptions.withArgs('bar').returns(promiseBar); - typeSearchText('foo'); - typeSearchText('bar'); - expect(asyncNode.querySelectorAll('[role=option]').length, 'to equal', 0); - resolveBar(createOptionsResponse(['bar'])); - resolveFoo(createOptionsResponse(['foo'])); - return expect.promise.all([promiseFoo, promiseBar]) - .then(() => expect(asyncNode.querySelectorAll('[role=option]').length, 'to equal', 1)) - .then(() => expect(asyncNode.querySelector('[role=option]').textContent, 'to equal', 'bar')); - }); - - it('should handle an error by setting options to an empty array', () => { - let promise, rejectPromise; - function loadOptions (input, resolve) { - promise = expect.promise((resolve, reject) => { + typeSearchText('foo'); + typeSearchText('bar'); + expect(asyncNode.querySelectorAll('[role=option]').length, 'to equal', 0); + resolveBar(createOptionsResponse(['bar'])); + resolveFoo(createOptionsResponse(['foo'])); + return expect.promise.all([promiseFoo, promiseBar]) + .then(() => expect(asyncNode.querySelectorAll('[role=option]').length, 'to equal', 1)) + .then(() => expect(asyncNode.querySelector('[role=option]').textContent, 'to equal', 'bar')); + }); + + it('should handle an error by setting options to an empty array', () => { + let promise, rejectPromise; + function loadOptions (input, resolve) { + promise = expect.promise((resolve, reject) => { rejectPromise = reject; }); - return promise; - } - createControl({ - autoload: false, - loadOptions, - options: createOptionsResponse(['foo']).options - }); - expect(asyncNode.querySelectorAll('[role=option]').length, 'to equal', 1); - typeSearchText('bar'); - rejectPromise(); - return expect.promise.all([promise]) - .then(() => expect(asyncNode.querySelectorAll('[role=option]').length, 'to equal', 0)); - }); - }); - */ - }); + return promise; + } + createControl({ + autoload: false, + loadOptions, + options: createOptionsResponse(['foo']).options + }); + expect(asyncNode.querySelectorAll('[role=option]').length, 'to equal', 1); + typeSearchText('bar'); + rejectPromise(); + return expect.promise.all([promise]) + .then(() => expect(asyncNode.querySelectorAll('[role=option]').length, 'to equal', 0)); + }); + }); + */ + }); });