From f34d3e4cb9306d3da3c3d79f56d08fab335e4431 Mon Sep 17 00:00:00 2001 From: Evan Sharp Date: Wed, 21 Sep 2016 15:51:37 -0400 Subject: [PATCH] feat(pagination): add async pagination --- README.md | 29 ++++++ examples/src/app.js | 2 + .../src/components/GithubUsersPagination.js | 93 +++++++++++++++++++ src/Async.js | 60 +++++++++--- src/Select.js | 2 +- test/Async-test.js | 61 ++++++++++++ 6 files changed, 233 insertions(+), 14 deletions(-) create mode 100644 examples/src/components/GithubUsersPagination.js diff --git a/README.md b/README.md index c2ffd30a66..e36c375b3f 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,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`. @@ -378,6 +406,7 @@ function onInputKeyDown(event) { onValueClick | func | undefined | onClick handler for value labels: `function (value, event) {}` openOnFocus | bool | false | open the options menu when the input gets focus (requires searchable = true) optionRenderer | func | undefined | function which returns a custom way to render the options in the menu + pagination | bool | false | Load more options when the menu is scrolled to the bottom. `loadOptions` is given a page: `function(input, page, [callback])` options | array | undefined | array of options placeholder | string\|node | 'Select ...' | field placeholder, displayed when there's no value scrollMenuIntoView | bool | true | whether the viewport will shift to display the entire menu when engaged diff --git a/examples/src/app.js b/examples/src/app.js index d8c83d7ce2..9e79838cf6 100644 --- a/examples/src/app.js +++ b/examples/src/app.js @@ -7,6 +7,7 @@ import Select from 'react-select'; import Creatable from './components/Creatable'; import Contributors from './components/Contributors'; import GithubUsers from './components/GithubUsers'; +import GithubUsersPagination from './components/GithubUsersPagination'; import CustomComponents from './components/CustomComponents'; import CustomRender from './components/CustomRender'; import Multiselect from './components/Multiselect'; @@ -22,6 +23,7 @@ ReactDOM.render( + diff --git a/examples/src/components/GithubUsersPagination.js b/examples/src/components/GithubUsersPagination.js new file mode 100644 index 0000000000..33f5abed25 --- /dev/null +++ b/examples/src/components/GithubUsersPagination.js @@ -0,0 +1,93 @@ +import React from 'react'; +import Select from 'react-select'; +import fetch from 'isomorphic-fetch'; + + +const GithubUsersPagination = React.createClass({ + displayName: 'GithubUsersPagination', + propTypes: { + label: React.PropTypes.string, + }, + getInitialState () { + return { + backspaceRemoves: true, + multi: true + }; + }, + onChange (value) { + this.setState({ + value: value, + }); + }, + switchToMulti () { + this.setState({ + multi: true, + value: [this.state.value], + }); + }, + switchToSingle () { + this.setState({ + multi: false, + value: this.state.value ? this.state.value[0] : null + }); + }, + getUsers (input, page) { + if (!input) { + return Promise.resolve({ options: [] }); + } + + return fetch(`https://api.github.com/search/users?q=${input}&page=${page}`) + .then((response) => response.json()) + .then((json) => { + return { options: json.items }; + }); + }, + gotoUser (value, event) { + window.open(value.html_url); + }, + toggleBackspaceRemoves () { + this.setState({ + backspaceRemoves: !this.state.backspaceRemoves + }); + }, + toggleCreatable () { + this.setState({ + creatable: !this.state.creatable + }); + }, + render () { + const AsyncComponent = this.state.creatable + ? Select.AsyncCreatable + : Select.Async; + + return ( +
+

{this.props.label}

+ +
+ + +
+
+ + +
+
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 43798d2ae0..cd3f159d88 100644 --- a/src/Async.js +++ b/src/Async.js @@ -8,12 +8,13 @@ const propTypes = { 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: React.PropTypes.oneOfType([ // replaces the placeholder while options are loading + loadingPlaceholder: React.PropTypes.oneOfType([ // replaces the placeholder while options are loading React.PropTypes.string, React.PropTypes.node ]), loadOptions: React.PropTypes.func.isRequired, // callback to load options asynchronously; (inputValue: string, callback: Function): ?Promise 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: React.PropTypes.oneOfType([ // field placeholder, displayed when there's no value (shared with Select) React.PropTypes.string, React.PropTypes.node @@ -32,6 +33,7 @@ const defaultProps = { ignoreCase: true, loadingPlaceholder: 'Loading...', options: [], + pagination: false, searchPromptText: 'Type to search', }; @@ -41,10 +43,13 @@ export default class Async extends Component { this.state = { isLoading: false, + isLoadingPage: false, + page: 1, options: props.options, }; this._onInputChange = this._onInputChange.bind(this); + this._onMenuScrollToBottom = this._onMenuScrollToBottom.bind(this); } componentDidMount () { @@ -66,33 +71,47 @@ export default class Async extends Component { }); } - loadOptions (inputValue) { - const { cache, loadOptions } = this.props; + loadOptions (inputValue, page = 1) { + const { cache, loadOptions, pagination } = this.props; if ( cache && cache.hasOwnProperty(inputValue) ) { this.setState({ - options: cache[inputValue] + options: cache[inputValue].options, + page: cache[inputValue].page, }); - return; + if ( + !pagination || + (pagination && (cache[inputValue].page >= page || cache[inputValue].hasReachedLastPage)) + ) { + return; + } } const callback = (error, data) => { if (callback === this._callback) { this._callback = null; - 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 }; } this.setState({ isLoading: false, - options + isLoadingPage: false, + page, + options, }); } }; @@ -100,7 +119,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), @@ -113,7 +139,8 @@ export default class Async extends Component { !this.state.isLoading ) { this.setState({ - isLoading: true + isLoading: true, + isLoadingPage: page > this.state.page, }); } @@ -134,14 +161,20 @@ export default class Async extends Component { return this.loadOptions(inputValue); } + _onMenuScrollToBottom (inputValue) { + if (!this.props.pagination || this.state.isLoading) return; + + this.loadOptions(inputValue, this.state.page + 1); + } + render () { const { children, loadingPlaceholder, placeholder, searchPromptText } = this.props; - const { isLoading, options } = this.state; + const { isLoading, isLoadingPage, options } = this.state; const props = { noResultsText: isLoading ? loadingPlaceholder : searchPromptText, placeholder: isLoading ? loadingPlaceholder : placeholder, - options: isLoading ? [] : options, + options: isLoading && !isLoadingPage ? [] : options, ref: (ref) => (this.select = ref) }; @@ -149,7 +182,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 3d35504263..f51f5b2c5b 100644 --- a/src/Select.js +++ b/src/Select.js @@ -534,7 +534,7 @@ const Select = React.createClass({ if (!this.props.onMenuScrollToBottom) return; let { target } = event; if (target.scrollHeight > target.offsetHeight && !(target.scrollHeight - target.offsetHeight - target.scrollTop)) { - this.props.onMenuScrollToBottom(); + this.props.onMenuScrollToBottom(this.state.inputValue); } }, diff --git a/test/Async-test.js b/test/Async-test.js index 72368dec06..450aae6fe2 100644 --- a/test/Async-test.js +++ b/test/Async-test.js @@ -354,4 +354,65 @@ describe('Async', () => { expect(asyncNode.className, 'to contain', 'Select'); }); }); + + 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'); + }); + }); });