Skip to content

Commit

Permalink
feat(pagination): add async pagination
Browse files Browse the repository at this point in the history
  • Loading branch information
TheSharpieOne committed Oct 3, 2016
1 parent b3db918 commit f34d3e4
Show file tree
Hide file tree
Showing 6 changed files with 233 additions and 14 deletions.
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
});
}

<Select.Async
name="form-field-name"
value="one"
loadOptions={getOptions}
pagination
/>
```

### 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`.
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions examples/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -22,6 +23,7 @@ ReactDOM.render(
<Virtualized label="Virtualized" />
<Contributors label="Contributors (Async)" />
<GithubUsers label="Github users (Async with fetch.js)" />
<GithubUsersPagination label="Github users (Async Pagination with fetch.js)" />
<NumericSelect label="Numeric Values" />
<BooleanSelect label="Boolean Values" />
<CustomRender label="Custom Render Methods"/>
Expand Down
93 changes: 93 additions & 0 deletions examples/src/components/GithubUsersPagination.js
Original file line number Diff line number Diff line change
@@ -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 (
<div className="section">
<h3 className="section-heading">{this.props.label}</h3>
<AsyncComponent multi={this.state.multi} value={this.state.value} onChange={this.onChange} onValueClick={this.gotoUser} valueKey="id" labelKey="login" loadOptions={this.getUsers} pagination backspaceRemoves={this.state.backspaceRemoves} />
<div className="checkbox-list">
<label className="checkbox">
<input type="radio" className="checkbox-control" checked={this.state.multi} onChange={this.switchToMulti}/>
<span className="checkbox-label">Multiselect</span>
</label>
<label className="checkbox">
<input type="radio" className="checkbox-control" checked={!this.state.multi} onChange={this.switchToSingle}/>
<span className="checkbox-label">Single Value</span>
</label>
</div>
<div className="checkbox-list">
<label className="checkbox">
<input type="checkbox" className="checkbox-control" checked={this.state.creatable} onChange={this.toggleCreatable} />
<span className="checkbox-label">Creatable?</span>
</label>
<label className="checkbox">
<input type="checkbox" className="checkbox-control" checked={this.state.backspaceRemoves} onChange={this.toggleBackspaceRemoves} />
<span className="checkbox-label">Backspace Removes?</span>
</label>
</div>
<div className="hint">This example uses fetch.js for showing Async options with Promises</div>
</div>
);
}
});

module.exports = GithubUsersPagination;
60 changes: 47 additions & 13 deletions src/Async.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -32,6 +33,7 @@ const defaultProps = {
ignoreCase: true,
loadingPlaceholder: 'Loading...',
options: [],
pagination: false,
searchPromptText: 'Type to search',
};

Expand All @@ -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 () {
Expand All @@ -66,41 +71,62 @@ 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,
});
}
};

// 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),
Expand All @@ -113,7 +139,8 @@ export default class Async extends Component {
!this.state.isLoading
) {
this.setState({
isLoading: true
isLoading: true,
isLoadingPage: page > this.state.page,
});
}

Expand All @@ -134,22 +161,29 @@ 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)
};

return children({
...this.props,
...props,
isLoading,
onInputChange: this._onInputChange
onInputChange: this._onInputChange,
onMenuScrollToBottom: this._onMenuScrollToBottom,
});
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/Select.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
},

Expand Down
61 changes: 61 additions & 0 deletions test/Async-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
});

0 comments on commit f34d3e4

Please sign in to comment.