diff --git a/README.md b/README.md index 279e7075f1..54f9aca6ac 100644 --- a/README.md +++ b/README.md @@ -217,6 +217,34 @@ const getOptions = (input) => { /> ``` +### Async options with pagination + +If you want to load additional options asynchronously when the user reaches the bottom of the options menu, you can pass the `pagination` prop. + +This will change the signature of `loadOptions` to pass the page which needs to be loaded: `loadOptions(inputValue, page, [callback])`. + +An example using the `fetch` API and ES6 syntax, with an API that returns the same object as the previous example: + +```javascript +import Select from 'react-select'; + +const getOptions = (input, page) => { + return fetch(`/users/${input}.json?page=${page}`) + .then((response) => { + return response.json(); + }).then((json) => { + return { options: json }; + }); +} + + +``` + ### Async options loaded externally If you want to load options asynchronously externally from the `Select` component, you can have the `Select` component show a loading spinner by passing in the `isLoading` prop set to `true`. @@ -448,6 +476,7 @@ function onInputKeyDown(event) { | `cache` | object | undefined | Sets the cache object used for options. Set to `false` if you would like to disable caching. | `loadingPlaceholder` | string or node | 'Loading...' | label to prompt for loading search result | | `loadOptions` | function | undefined | function that returns a promise or calls a callback with the options: `function(input, [callback])` | +| `pagination` | bool | false | Load more options when the menu is scrolled to the bottom. `loadOptions` is given a page: `function(input, page, [callback])` #### Creatable properties @@ -461,6 +490,7 @@ function onInputKeyDown(event) { | `shouldKeyDownEventCreateNewOption` | function | Decides if a keyDown event (eg its `keyCode`) should result in the creation of a new option. ENTER, TAB and comma keys create new options by default. Expected signature: `({ keyCode: number }): boolean` | | `promptTextCreator` | function | Factory for overriding default option creator prompt label. By default it will read 'Create option "{label}"'. Expected signature: `(label: String): String` | + ### Methods Use the `focus()` method to give the control focus. All other methods on ` + Multiselect + + + +
+ + +
+
This example uses fetch.js for showing Async options with Promises
+ + ); + } +}); + +module.exports = GithubUsersPagination; diff --git a/src/Async.js b/src/Async.js index 66810ee168..3059b0ab9a 100644 --- a/src/Async.js +++ b/src/Async.js @@ -23,6 +23,7 @@ const propTypes = { onChange: PropTypes.func, // onChange handler: function (newValue) {} onInputChange: PropTypes.func, // optional for keeping track of what is being typed options: PropTypes.array.isRequired, // array of options + pagination: PropTypes.bool, // automatically load more options when the option list is scrolled to the end; default to false placeholder: PropTypes.oneOfType([ // field placeholder, displayed when there's no value (shared with Select) PropTypes.string, PropTypes.node @@ -46,6 +47,7 @@ const defaultProps = { ignoreCase: true, loadingPlaceholder: 'Loading...', options: [], + pagination: false, searchPromptText: 'Type to search', }; @@ -58,10 +60,13 @@ export default class Async extends Component { this.state = { inputValue: '', isLoading: false, + isLoadingPage: false, + page: 1, options: props.options, }; this.onInputChange = this.onInputChange.bind(this); + this.onMenuScrollToBottom = this.onMenuScrollToBottom.bind(this); } componentDidMount () { @@ -84,8 +89,8 @@ export default class Async extends Component { this._callback = null; } - loadOptions (inputValue) { - const { loadOptions } = this.props; + loadOptions (inputValue, page = 1) { + const { loadOptions, pagination } = this.props; const cache = this._cache; if ( @@ -95,18 +100,30 @@ export default class Async extends Component { this._callback = null; this.setState({ - isLoading: false, - options: cache[inputValue] + isLoading: false, + options: cache[inputValue].options, + page: cache[inputValue].page, }); - return; + if ( + !pagination || + (pagination && (cache[inputValue].page >= page || cache[inputValue].hasReachedLastPage)) + ) { + return; + } } const callback = (error, data) => { - const options = data && data.options || []; + let options = data && data.options || []; + + const hasReachedLastPage = pagination && options.length === 0; + + if(page > 1) { + options = this.state.options.concat(options); + } if (cache) { - cache[inputValue] = options; + cache[inputValue] = { page, options, hasReachedLastPage }; } if (callback === this._callback) { @@ -114,7 +131,9 @@ export default class Async extends Component { this.setState({ isLoading: false, - options + isLoadingPage: false, + page, + options, }); } }; @@ -122,7 +141,14 @@ export default class Async extends Component { // Ignore all but the most recent request this._callback = callback; - const promise = loadOptions(inputValue, callback); + let promise; + + if (pagination) { + promise = loadOptions(inputValue, page, callback); + } else { + promise = loadOptions(inputValue, callback); + } + if (promise) { promise.then( (data) => callback(null, data), @@ -135,7 +161,8 @@ export default class Async extends Component { !this.state.isLoading ) { this.setState({ - isLoading: true + isLoading: true, + isLoadingPage: page > this.state.page, }); } } @@ -186,14 +213,20 @@ export default class Async extends Component { this.select.focus(); } + onMenuScrollToBottom (inputValue) { + if (!this.props.pagination || this.state.isLoading) return; + + this.loadOptions(inputValue, this.state.page + 1); + } + render () { const { children, loadingPlaceholder, placeholder } = this.props; - const { isLoading, options } = this.state; + const { isLoading, isLoadingPage, options } = this.state; const props = { noResultsText: this.noResultsText(), placeholder: isLoading ? loadingPlaceholder : placeholder, - options: (isLoading && loadingPlaceholder) ? [] : options, + options: (isLoading && loadingPlaceholder && !isLoadingPage) ? [] : options, ref: (ref) => (this.select = ref), }; @@ -201,7 +234,8 @@ export default class Async extends Component { ...this.props, ...props, isLoading, - onInputChange: this.onInputChange + onInputChange: this.onInputChange, + onMenuScrollToBottom: this.onMenuScrollToBottom, }); } } diff --git a/src/Select.js b/src/Select.js index d8b8542d20..ac00236868 100644 --- a/src/Select.js +++ b/src/Select.js @@ -563,7 +563,7 @@ class Select extends React.Component { if (!this.props.onMenuScrollToBottom) return; let { target } = event; if (target.scrollHeight > target.offsetHeight && (target.scrollHeight - target.offsetHeight - target.scrollTop) <= 0) { - this.props.onMenuScrollToBottom(); + this.props.onMenuScrollToBottom(this.state.inputValue); } } diff --git a/test/Async-test.js b/test/Async-test.js index ff663d42ea..81c409ada4 100644 --- a/test/Async-test.js +++ b/test/Async-test.js @@ -169,9 +169,9 @@ describe('Async', () => { // TODO: How to test this? setTimeout(function() { - expect(instance._cache.t, 'to equal', res.t.options); - expect(instance._cache.te, 'to equal', res.te.options); - expect(instance._cache.tes, 'to equal', res.tes.options); + expect(instance._cache.t.options, 'to equal', res.t.options); + expect(instance._cache.te.options, 'to equal', res.te.options); + expect(instance._cache.tes.options, 'to equal', res.tes.options); cb(); }, 30); }); @@ -536,4 +536,67 @@ describe('Async', () => { expect(asyncInstance._callback, 'to equal', null); }); }); + + describe('with pagination', () => { + it('should pass the page to loadOptions', () => { + createControl({ + pagination: true + }); + typeSearchText('a'); + expect(loadOptions, 'was called with', 'a', 1); + }); + + it('should not try to load a page it has cached', () => { + createControl({ + pagination: true, + cache: { + a: { options: [], page: 1 }, + } + }); + typeSearchText('a'); + expect(loadOptions, 'was not called'); + }); + + it('should load the next a page it on scroll to bottom', () => { + createControl({ + pagination: true, + cache: { + a: { options: [], page: 1 }, + } + }); + typeSearchText('a'); + expect(loadOptions, 'was not called'); + asyncInstance.onMenuScrollToBottom('a'); + expect(loadOptions, 'was called with', 'a', 2); + }); + + it('should not load the next a page it on scroll to bottom when pagination is false', () => { + createControl({ + pagination: false, + cache: { + a: { options: [], page: 1 }, + } + }); + typeSearchText('a'); + expect(loadOptions, 'was not called'); + asyncInstance.onMenuScrollToBottom('a'); + expect(loadOptions, 'was not called'); + }); + + it('should combine the existing options with the additional options', () => { + createControl({ + pagination: true, + loadOptions: (value, page, cb) => { + cb(null, createOptionsResponse(['bar'])); + }, + cache: { + a: { options: createOptionsResponse(['foo']).options, page: 1 }, + } + }); + asyncInstance.onMenuScrollToBottom('a'); + expect(asyncNode.querySelectorAll('[role=option]').length, 'to equal', 2); + expect(asyncNode.querySelectorAll('[role=option]')[0].textContent, 'to equal', 'foo'); + expect(asyncNode.querySelectorAll('[role=option]')[1].textContent, 'to equal', 'bar'); + }); + }); });