From f84e50121e19a5083f1996c975e4e2d16421aab1 Mon Sep 17 00:00:00 2001 From: Evan Sharp Date: Wed, 21 Sep 2016 15:51:37 -0400 Subject: [PATCH 1/4] 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 | 63 +++++++++++++ 6 files changed, 235 insertions(+), 14 deletions(-) create mode 100644 examples/src/components/GithubUsersPagination.js diff --git a/README.md b/README.md index 28637cfc72..19b2f44fef 100644 --- a/README.md +++ b/README.md @@ -189,6 +189,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`. @@ -392,6 +420,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 d69afbb331..b26133b0aa 100644 --- a/src/Async.js +++ b/src/Async.js @@ -14,6 +14,7 @@ const propTypes = { ]), 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 @@ -41,6 +42,7 @@ const defaultProps = { ignoreCase: true, loadingPlaceholder: 'Loading...', options: [], + pagination: false, searchPromptText: 'Type to search', }; @@ -52,10 +54,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 () { @@ -81,8 +86,8 @@ export default class Async extends Component { this.setState({ options: [] }); } - loadOptions (inputValue) { - const { loadOptions } = this.props; + loadOptions (inputValue, page = 1) { + const { loadOptions, pagination } = this.props; const cache = this._cache; if ( @@ -90,25 +95,39 @@ export default class Async extends Component { 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, }); } }; @@ -116,7 +135,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), @@ -129,7 +155,8 @@ export default class Async extends Component { !this.state.isLoading ) { this.setState({ - isLoading: true + isLoading: true, + isLoadingPage: page > this.state.page, }); } @@ -180,14 +207,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), onChange: (newValues) => { if (this.props.multi && this.props.value && (newValues.length > this.props.value.length)) { @@ -201,7 +234,8 @@ export default class Async extends Component { ...this.props, ...props, isLoading, - onInputChange: this._onInputChange + onInputChange: this._onInputChange, + onMenuScrollToBottom: this._onMenuScrollToBottom, }); } } @@ -213,4 +247,4 @@ function defaultChildren (props) { return (