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

[v2] feat(pagination): add async pagination #2464

Closed
wants to merge 2 commits into from
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
393 changes: 393 additions & 0 deletions docs/data.js

Large diffs are not rendered by default.

43 changes: 43 additions & 0 deletions docs/examples/AsyncPagination.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import React, { Component } from 'react';

import AsyncSelect from '../../src/Async';
import { carOptions } from '../data';

type State = {
inputValue: string,
};

const filterCars = (inputValue: string, page: number, itemsPerPage: number) =>
(console.log(inputValue),
carOptions.filter(i =>
i.label.toLowerCase().includes(inputValue.toLowerCase())
).slice((page-1) * itemsPerPage, page * itemsPerPage));

const loadOptions = (inputValue, page, callback) => {
setTimeout(() => {
callback(filterCars(inputValue, page, 10));
}, 1000);
};

export default class WithPagination extends Component<*, State> {
state = { inputValue: '' };
handleInputChange = (newValue: string) => {
const inputValue = newValue.replace(/\W/g, '');
this.setState({ inputValue });
return inputValue;
};
render() {
return (
<div>
<pre>inputValue: "{this.state.inputValue}"</pre>
<AsyncSelect
cacheOptions
loadOptions={loadOptions}
defaultOptions
onInputChange={this.handleInputChange}
pagination
/>
</div>
);
}
}
1 change: 1 addition & 0 deletions docs/examples/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export { default as ControlledMenu } from './ControlledMenu';
export { default as AnimatedMulti } from './AnimatedMulti';
export { default as AsyncCallbacks } from './AsyncCallbacks';
export { default as AsyncCreatable } from './AsyncCreatable';
export { default as AsyncPagination } from './AsyncPagination';
export { default as AsyncPromises } from './AsyncPromises';
export { default as BasicGrouped } from './BasicGrouped';
export { default as BasicMulti } from './BasicMulti';
Expand Down
11 changes: 11 additions & 0 deletions docs/pages/async/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import md from '../../markdown/renderer';
import {
AsyncCallbacks,
AsyncMulti,
AsyncPagination,
AsyncPromises,
} from '../../examples';

Expand Down Expand Up @@ -55,6 +56,16 @@ export default function Async() {
</ExampleWrapper>
)}

${(
<ExampleWrapper
label="Pagination"
urlPath="docs/examples/AsyncPagination.js"
raw={require('!!raw-loader!../../examples/AsyncPagination.js')}
>
<AsyncPagination />
</ExampleWrapper>
)}

${(
<ExampleWrapper
label="Async MultiSelect"
Expand Down
148 changes: 93 additions & 55 deletions src/Async.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,14 @@ export type AsyncProps = {
defaultOptions: OptionsType | boolean,
/* Function that returns a promise, which is the set of options to be used
once the promise resolves. */
loadOptions: (string, (OptionsType) => void) => Promise<*> | void,
loadOptions: ((string, (OptionsType) => void) => Promise<*> | void) &
((string, number, (OptionsType) => void) => Promise<*> | void),
/* If cacheOptions is truthy, then the loaded data will be cached. The cache
will remain until `cacheOptions` changes value. */
cacheOptions: any,
/* Indicate that loadOptions should be called with page
and to also trigger it when the user scrolls to the bottom of the options. */
pagination: boolean,
};

export type Props = SelectProps & AsyncProps;
Expand All @@ -29,6 +33,7 @@ type State = {
defaultOptions?: OptionsType,
inputValue: string,
isLoading: boolean,
page: number,
loadedInputValue?: string,
loadedOptions: OptionsType,
passEmptyOptions: boolean,
Expand All @@ -40,7 +45,13 @@ export const makeAsyncSelect = (SelectComponent: ComponentType<*>) =>
select: ElementRef<*>;
lastRequest: {};
mounted: boolean = false;
optionsCache: { [string]: OptionsType } = {};
optionsCache: {
[string]: {
options: OptionsType,
page: number,
hasReachedLastPage: boolean,
},
} = {};
constructor(props: Props) {
super();
this.state = {
Expand All @@ -49,6 +60,7 @@ export const makeAsyncSelect = (SelectComponent: ComponentType<*>) =>
: undefined,
inputValue: '',
isLoading: props.defaultOptions === true ? true : false,
page: 0,
loadedOptions: [],
passEmptyOptions: false,
};
Expand All @@ -57,11 +69,7 @@ export const makeAsyncSelect = (SelectComponent: ComponentType<*>) =>
this.mounted = true;
const { defaultOptions } = this.props;
if (defaultOptions === true) {
this.loadOptions('', options => {
if (!this.mounted) return;
const isLoading = !!this.lastRequest;
this.setState({ defaultOptions: options || [], isLoading });
});
this.optionsFromCacheOrLoad('');
}
}
componentWillReceiveProps(nextProps: Props) {
Expand All @@ -79,78 +87,107 @@ export const makeAsyncSelect = (SelectComponent: ComponentType<*>) =>
blur() {
this.select.blur();
}
loadOptions(inputValue: string, callback: (?Array<*>) => void) {
const { loadOptions } = this.props;
loadOptions(
inputValue: string,
page: number,
callback: (?Array<*>) => void
) {
const { loadOptions, pagination } = this.props;
if (!loadOptions) return callback();
const loader = loadOptions(inputValue, callback);
const loader = pagination
? loadOptions(inputValue, page, callback)
: loadOptions(inputValue, callback);
if (loader && typeof loader.then === 'function') {
loader.then(callback, () => callback());
}
}
handleInputChange = (newValue: string, actionMeta: InputActionMeta) => {
const { cacheOptions, onInputChange } = this.props;
// TODO
const inputValue = handleInputChange(newValue, actionMeta, onInputChange);
if (!inputValue) {
delete this.lastRequest;
this.setState({
inputValue: '',
loadedInputValue: '',
loadedOptions: [],
isLoading: false,
passEmptyOptions: false,
});
return;
}
if (cacheOptions && this.optionsCache[inputValue]) {
optionsFromCacheOrLoad(inputValue: string, page: number = 1) {
const { cacheOptions, pagination } = this.props;
const cache = this.optionsCache[inputValue];
if (cacheOptions && cache && cache.options) {
this.setState({
inputValue,
loadedInputValue: inputValue,
loadedOptions: this.optionsCache[inputValue],
loadedOptions: cache.options,
isLoading: false,
page: cache.page,
passEmptyOptions: false,
});
} else {
const request = (this.lastRequest = {});
this.setState(
{
inputValue,
isLoading: true,
passEmptyOptions: !this.state.loadedInputValue,
},
() => {
this.loadOptions(inputValue, options => {
if (!this.mounted) return;
if (options) {
this.optionsCache[inputValue] = options;
if (
!pagination ||
(pagination && (cache.page >= page || cache.hasReachedLastPage))
) {
return;
}
}
const request = (this.lastRequest = {});
this.setState(
{
inputValue,
isLoading: true,
passEmptyOptions: !this.state.loadedInputValue,
},
() => {
this.loadOptions(inputValue, page, options => {
if (!this.mounted) return;
if (options) {
const hasReachedLastPage = pagination && options.length === 0;
if (page > 1) {
options = this.state.loadedOptions.concat(options);
}
if (request !== this.lastRequest) return;
delete this.lastRequest;
this.setState({
isLoading: false,
loadedInputValue: inputValue,
loadedOptions: options || [],
passEmptyOptions: false,
});
this.optionsCache[inputValue] = {
options,
hasReachedLastPage,
page,
};
}
if (request !== this.lastRequest) return;
delete this.lastRequest;
this.setState({
isLoading: false,
page,
loadedInputValue: inputValue,
loadedOptions: options || [],
passEmptyOptions: false,
defaultOptions:
page === 1 && !inputValue
? options || this.state.defaultOptions
: [],
});
}
);
}
});
}
);
}
handleInputChange = (newValue: string, actionMeta: InputActionMeta) => {
const inputValue = handleInputChange(
newValue,
actionMeta,
this.props.onInputChange
);
this.optionsFromCacheOrLoad(inputValue);
return inputValue;
};
handleMenuScrollToBottom = () => {
if (!this.props.pagination || this.state.isLoading) return;
this.optionsFromCacheOrLoad(this.state.inputValue, this.state.page + 1);
};
render() {
const { loadOptions, ...props } = this.props;
const { loadOptions, pagination, ...props } = this.props;
const {
defaultOptions,
inputValue,
isLoading,
loadedInputValue,
loadedOptions,
passEmptyOptions,
page,
} = this.state;
const options = passEmptyOptions
? []
: inputValue && loadedInputValue ? loadedOptions : defaultOptions || [];
const options =
!pagination && passEmptyOptions
? []
: (inputValue && loadedInputValue) || page > 1
? loadedOptions
: defaultOptions || [];
return (
// $FlowFixMe
<SelectComponent
Expand All @@ -162,6 +199,7 @@ export const makeAsyncSelect = (SelectComponent: ComponentType<*>) =>
filterOption={null}
isLoading={isLoading}
onInputChange={this.handleInputChange}
onMenuScrollToBottom={this.handleMenuScrollToBottom}
/>
);
}
Expand Down
67 changes: 67 additions & 0 deletions src/__tests__/Async.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,73 @@ test.skip('to not call loadOptions again for same value when cacheOptions is tru
expect(loadOptionsSpy).toHaveBeenCalledTimes(2);
});

test('to call loadOptions with page when pagination is true', () => {
const loadOptionsSpy = jest.fn((inputValue, page, callback) => callback(OPTIONS));
mount(<Async loadOptions={loadOptionsSpy} pagination defaultOptions />);

expect(loadOptionsSpy).toHaveBeenCalledTimes(1);
expect(loadOptionsSpy.mock.calls[0].length).toBe(3);
expect(loadOptionsSpy.mock.calls[0][1]).toBe(1);
});

test('to call loadOptions with incremented page when pagination is true and the user scrolls to the bottom of the options', () => {
const loadOptionsSpy = jest.fn((inputValue, page, callback) => callback(OPTIONS));
const asyncSelectWrapper = mount(
<Async loadOptions={loadOptionsSpy} pagination menuIsOpen defaultOptions />
);
const instanceOne = asyncSelectWrapper.instance();

instanceOne.handleMenuScrollToBottom();

expect(loadOptionsSpy).toHaveBeenCalledTimes(2);
expect(loadOptionsSpy.mock.calls[0][1]).toBe(1);
expect(loadOptionsSpy.mock.calls[1][1]).toBe(2);
});

test('to call loadOptions with page 1 when pagination is true and the user changes input to a new, non-cached, value', () => {
const loadOptionsSpy = jest.fn((inputValue, page, callback) => callback(OPTIONS));
const asyncSelectWrapper = mount(
<Async className="react-select" loadOptions={loadOptionsSpy} pagination menuIsOpen defaultOptions />
);
const inputValueWrapper = asyncSelectWrapper.find(
'div.react-select__input input'
);
const instanceOne = asyncSelectWrapper.instance();

instanceOne.handleMenuScrollToBottom();
asyncSelectWrapper.setProps({ inputValue: 'a' });
inputValueWrapper.simulate('change', { currentTarget: { value: 'a' } });

expect(loadOptionsSpy).toHaveBeenCalledTimes(3);
expect(loadOptionsSpy.mock.calls[0][1]).toBe(1);
expect(loadOptionsSpy.mock.calls[1][1]).toBe(2);
expect(loadOptionsSpy.mock.calls[2][1]).toBe(1);
});

test('to to load all of the cached pages from cache when pagination is true', () => {
const loadOptionsSpy = jest.fn((inputValue, page, callback) => callback(OPTIONS));
const asyncSelectWrapper = mount(
<Async className="react-select" loadOptions={loadOptionsSpy} pagination menuIsOpen defaultOptions cacheOptions />
);
const inputValueWrapper = asyncSelectWrapper.find(
'div.react-select__input input'
);
const instanceOne = asyncSelectWrapper.instance();

instanceOne.handleMenuScrollToBottom();
asyncSelectWrapper.setProps({ inputValue: 'a' });
inputValueWrapper.simulate('change', { currentTarget: { value: 'a' } });

asyncSelectWrapper.setProps({ inputValue: '' });
inputValueWrapper.simulate('change', { currentTarget: { value: '' } });

expect(loadOptionsSpy).toHaveBeenCalledTimes(3);
expect(loadOptionsSpy.mock.calls[0][1]).toBe(1);
expect(loadOptionsSpy.mock.calls[1][1]).toBe(2);
expect(loadOptionsSpy.mock.calls[2][1]).toBe(1);
expect(asyncSelectWrapper.find(Option).length).toBe(OPTIONS.length * 2);
});

test('to create new cache for each instance', () => {
const asyncSelectWrapper = mount(<Async cacheOptions />);
const instanceOne = asyncSelectWrapper.instance();
Expand Down
Loading