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

Refactored Async. #1226

Merged
merged 7 commits into from
Sep 18, 2016
Merged
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
1 change: 1 addition & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
indent_style = tab
translate_tabs_to_spaces = false

[*.json]
indent_style = space
Expand Down
1 change: 0 additions & 1 deletion CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ Multiple values are now submitted in multiple form fields, which results in an a
## New Select.Async Component

`loadingPlaceholder` prop
`autoload` changed to `minimumInput` and now controls the minimum input to load options
`cacheAsyncResults` -> `cache` (new external cache support) - defaults to true

## Fixes & Other Changes
Expand Down
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -383,7 +383,6 @@ function onInputKeyDown(event) {
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
searchable | bool | true | whether to enable searching feature or not
searchingText | string | 'Searching...' | message to display whilst options are loading via asyncOptions, or when `isLoading` is true
searchPromptText | string\|node | 'Type to search' | label to prompt for search input
tabSelectsValue | bool | true | whether to select the currently focused value when the `[tab]` key is pressed
value | any | undefined | initial field value
Expand Down
16 changes: 15 additions & 1 deletion examples/src/components/GithubUsers.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const GithubUsers = React.createClass({
},
getInitialState () {
return {
backspaceRemoves: true,
multi: true
};
},
Expand All @@ -31,6 +32,10 @@ const GithubUsers = React.createClass({
});
},
getUsers (input) {
if (!input) {
return Promise.resolve({ options: [] });
}

return fetch(`https://api.github.com/search/users?q=${input}`)
.then((response) => response.json())
.then((json) => {
Expand All @@ -40,6 +45,11 @@ const GithubUsers = React.createClass({
gotoUser (value, event) {
window.open(value.html_url);
},
toggleBackspaceRemoves () {
this.setState({
backspaceRemoves: !this.state.backspaceRemoves
});
},
toggleCreatable () {
this.setState({
creatable: !this.state.creatable
Expand All @@ -53,7 +63,7 @@ const GithubUsers = React.createClass({
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} minimumInput={1} backspaceRemoves={false} />
<AsyncComponent multi={this.state.multi} value={this.state.value} onChange={this.onChange} onValueClick={this.gotoUser} valueKey="id" labelKey="login" loadOptions={this.getUsers} backspaceRemoves={this.state.backspaceRemoves} />
<div className="checkbox-list">
<label className="checkbox">
<input type="radio" className="checkbox-control" checked={this.state.multi} onChange={this.switchToMulti}/>
Expand All @@ -69,6 +79,10 @@ const GithubUsers = React.createClass({
<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>
Expand Down
277 changes: 128 additions & 149 deletions src/Async.js
Original file line number Diff line number Diff line change
@@ -1,181 +1,160 @@
import React from 'react';

import React, { Component, PropTypes } from 'react';
import Select from './Select';
import stripDiacritics from './utils/stripDiacritics';

let requestId = 0;
const propTypes = {
autoload: React.PropTypes.bool.isRequired, // automatically call the `loadOptions` prop on-mount; defaults to true
cache: React.PropTypes.any, // object to use to cache results; set to null/false to disable caching
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: PropTypes.string.isRequired, // replaces the placeholder while options are loading
loadOptions: React.PropTypes.func.isRequired, // callback to load options asynchronously; (inputValue: string, callback: Function): ?Promise
options: PropTypes.array.isRequired, // array of options
placeholder: React.PropTypes.oneOfType([ // field placeholder, displayed when there's no value (shared with Select)
React.PropTypes.string,
React.PropTypes.node
]),
searchPromptText: React.PropTypes.oneOfType([ // label to prompt for search input
React.PropTypes.string,
React.PropTypes.node
]),
};

const defaultProps = {
autoload: true,
cache: {},
children: defaultChildren,
ignoreAccents: true,
ignoreCase: true,
loadingPlaceholder: 'Loading...',
options: [],
searchPromptText: 'Type to search',
};

export default class Async extends Component {
constructor (props, context) {
super(props, context);

this.state = {
isLoading: false,
options: props.options,
};

function initCache (cache) {
if (cache && typeof cache !== 'object') {
cache = {};
this._onInputChange = this._onInputChange.bind(this);
}
return cache ? cache : null;
}

function updateCache (cache, input, data) {
if (!cache) return;
cache[input] = data;
}
componentDidMount () {
const { autoload } = this.props;

function getFromCache (cache, input) {
if (!cache) return;
for (let i = input.length; i >= 0; --i) {
let cacheKey = input.slice(0, i);
if (cache[cacheKey] && (input === cacheKey || cache[cacheKey].complete)) {
return cache[cacheKey];
if (autoload) {
this.loadOptions('');
}
}
}

function thenPromise (promise, callback) {
if (!promise || typeof promise.then !== 'function') return;
return promise.then((data) => {
callback(null, data);
}, (err) => {
callback(err);
});
}
componentWillUpdate (nextProps, nextState) {
const propertiesToSync = ['options'];
propertiesToSync.forEach((prop) => {
if (this.props[prop] !== nextProps[prop]) {
this.setState({
[prop]: nextProps[prop]
});
}
});
}

const stringOrNode = React.PropTypes.oneOfType([
React.PropTypes.string,
React.PropTypes.node
]);

const Async = React.createClass({
propTypes: {
cache: React.PropTypes.any, // object to use to cache results, can be null to disable cache
children: React.PropTypes.func, // Child function responsible for creating the inner Select component; (props: Object): PropTypes.element
ignoreAccents: React.PropTypes.bool, // whether to strip diacritics when filtering (shared with Select)
ignoreCase: React.PropTypes.bool, // whether to perform case-insensitive filtering (shared with Select)
isLoading: React.PropTypes.bool, // overrides the isLoading state when set to true
loadOptions: React.PropTypes.func.isRequired, // function to call to load options asynchronously
loadingPlaceholder: React.PropTypes.string, // replaces the placeholder while options are loading
minimumInput: React.PropTypes.number, // the minimum number of characters that trigger loadOptions
noResultsText: stringOrNode, // placeholder displayed when there are no matching search results (shared with Select)
onInputChange: React.PropTypes.func, // onInputChange handler: function (inputValue) {}
placeholder: stringOrNode, // field placeholder, displayed when there's no value (shared with Select)
searchPromptText: stringOrNode, // label to prompt for search input
searchingText: React.PropTypes.string, // message to display while options are loading
},
getDefaultProps () {
return {
cache: true,
ignoreAccents: true,
ignoreCase: true,
loadingPlaceholder: 'Loading...',
minimumInput: 0,
searchingText: 'Searching...',
searchPromptText: 'Type to search',
};
},
getInitialState () {
return {
cache: initCache(this.props.cache),
isLoading: false,
options: [],
};
},
componentWillMount () {
this._lastInput = '';
},
componentDidMount () {
this.loadOptions('');
},
componentWillReceiveProps (nextProps) {
if (nextProps.cache !== this.props.cache) {
loadOptions (inputValue) {
const { cache, loadOptions } = this.props;

if (
cache &&
cache.hasOwnProperty(inputValue)
) {
this.setState({
cache: initCache(nextProps.cache),
options: cache[inputValue]
});

return;
}
},
focus () {
this.select.focus();
},
resetState () {
this._currentRequestId = -1;
this.setState({
isLoading: false,
options: [],
});
},
getResponseHandler (input) {
let _requestId = this._currentRequestId = requestId++;
return (err, data) => {
if (err) throw err;
if (!this.isMounted()) return;
updateCache(this.state.cache, input, data);
if (_requestId !== this._currentRequestId) return;

const callback = (error, data) => {
if (callback === this._callback) {
this._callback = null;

const options = data && data.options || [];

if (cache) {
cache[inputValue] = options;
}

this.setState({
isLoading: false,
options
});
}
};

// Ignore all but the most recent request
this._callback = callback;

const promise = loadOptions(inputValue, callback);
if (promise) {
promise.then(
(data) => callback(null, data),
(error) => callback(error)
);
}

if (
this._callback &&
!this.state.isLoading
) {
this.setState({
isLoading: false,
options: data && data.options || [],
isLoading: true
});
};
},
loadOptions (input) {
if (this.props.onInputChange) {
let nextState = this.props.onInputChange(input);
// Note: != used deliberately here to catch undefined and null
if (nextState != null) {
input = '' + nextState;
}
}
if (this.props.ignoreAccents) input = stripDiacritics(input);
if (this.props.ignoreCase) input = input.toLowerCase();

this._lastInput = input;
if (input.length < this.props.minimumInput) {
return this.resetState();
return inputValue;
}

_onInputChange (inputValue) {
const { ignoreAccents, ignoreCase } = this.props;

if (ignoreAccents) {
inputValue = stripDiacritics(inputValue);
}
let cacheResult = getFromCache(this.state.cache, input);
if (cacheResult && Array.isArray(cacheResult.options)) {
return this.setState({
options: cacheResult.options,
});

if (ignoreCase) {
inputValue = inputValue.toLowerCase();
}
this.setState({
isLoading: true,
});
let responseHandler = this.getResponseHandler(input);
let inputPromise = thenPromise(this.props.loadOptions(input, responseHandler), responseHandler);
return inputPromise ? inputPromise.then(() => {
return input;
}) : input;
},

return this.loadOptions(inputValue);
}

render () {
let {
children = defaultChildren,
noResultsText,
...restProps
} = this.props;
let { isLoading, options } = this.state;
if (this.props.isLoading) isLoading = true;
let placeholder = isLoading ? this.props.loadingPlaceholder : this.props.placeholder;
if (isLoading) {
noResultsText = this.props.searchingText;
} else if (!options.length && this._lastInput.length < this.props.minimumInput) {
noResultsText = this.props.searchPromptText;
}
const { children, loadingPlaceholder, placeholder, searchPromptText } = this.props;
const { isLoading, options } = this.state;

const props = {
...restProps,
isLoading,
noResultsText,
onInputChange: this.loadOptions,
options,
placeholder,
ref: (ref) => {
this.select = ref;
}
noResultsText: isLoading ? loadingPlaceholder : searchPromptText,
placeholder: isLoading ? loadingPlaceholder : placeholder,
options: isLoading ? [] : options
};

return children(props);
return children({
...this.props,
...props,
isLoading,
onInputChange: this._onInputChange
});
}
});
}

Async.propTypes = propTypes;
Async.defaultProps = defaultProps;

function defaultChildren (props) {
return (
<Select {...props} />
);
};

module.exports = Async;
4 changes: 0 additions & 4 deletions src/AsyncCreatable.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,6 @@ const AsyncCreatable = React.createClass({
creatableProps.onInputChange(input);
return asyncProps.onInputChange(input);
}}
ref={(ref) => {
creatableProps.ref(ref);
asyncProps.ref(ref);
}}
/>
)}
</Select.Creatable>
Expand Down
Loading