Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(pagination): add async pagination #1237

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
});
}

<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 @@ -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

Expand All @@ -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 `<Select>` elements should be considered private.
Expand Down
4 changes: 3 additions & 1 deletion examples/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import './example.less';
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,7 +23,8 @@ ReactDOM.render(
<Multiselect label="Multiselect" />
<Virtualized label="Virtualized" />
<Contributors label="Contributors (Async)" />
<GithubUsers label="GitHub users (Async with fetch.js)" />
<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 @@ -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
Expand All @@ -46,6 +47,7 @@ const defaultProps = {
ignoreCase: true,
loadingPlaceholder: 'Loading...',
options: [],
pagination: false,
searchPromptText: 'Type to search',
};

Expand All @@ -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 () {
Expand All @@ -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 (
Expand All @@ -95,34 +100,55 @@ 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) {
this._callback = null;

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 @@ -135,7 +161,8 @@ export default class Async extends Component {
!this.state.isLoading
) {
this.setState({
isLoading: true
isLoading: true,
isLoadingPage: page > this.state.page,
});
}
}
Expand Down Expand Up @@ -186,22 +213,29 @@ 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),
};

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 @@ -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);
}
}

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