application/components/common/author-search-box.js
import React from 'react';
import ReactDOM from 'react-dom';
import Immutable from 'immutable';
import Transition from 'react-motion-ui-pack'
import {spring} from 'react-motion';
import TextField from 'material-ui/TextField';
import Chip from 'material-ui/Chip';
import alt from './../../alt';
import {info} from './../../util/console';
import AuthorFlux from './../../services/author-service';
const KEYMAP = {
ARROWDOWN: 40,
ARROWUP: 38,
ENTER: 13,
ESCAPE: 27,
TAB: 9
};
/**
* AuthorSearchBox controls the handling of author attachment searches.
*
* Authors property is any existing authors you want pushed into the stack
* on mount.
*
* <code>
* <AuthorSearchBox
* onSelectAuthor={...}
* onRemoveAuthor={...}
* authors={...}
* />
* </code>
*
* The onSelectAuthor and onRemoveAuthor callbacks will receive two parameters, the author
* that was added/removed and the current full author state. Both are immutable maps.
*/
class AuthorSearchBox extends React.Component {
constructor(props) {
super(props);
this.isRunning = false;
this.service = new AuthorFlux;
this.onSearchUpdate = this.onSearchUpdate.bind(this);
this.onKeywordChange = this.onKeywordChange.bind(this);
this.state = this.service.stores.authors.getState();
this.handleSelectAuthor = this.handleSelectAuthor.bind(this);
this.onKeyPress = this.onKeyPress.bind(this);
this.handleSelectUp = this.handleSelectUp.bind(this);
this.handleSelectDown = this.handleSelectDown.bind(this);
this.handleSelectEnter = this.handleSelectEnter.bind(this);
this.handleSelectTab = this.handleSelectTab.bind(this);
this.handleClear = this.handleClear.bind(this);
}
componentWillMount() {
this.service.stores.authors.listen(this.onSearchUpdate);
}
componentWillUnmount() {
this.service.stores.authors.unlisten(this.onSearchUpdate);
}
onSearchUpdate(state) {
// whenever we search, we automatically select the first result
// as the default, so the author can just hit return
if (state.authors && state.authors.size) {
// We're using an internal store here so the components can
// communicate sans wacky callbacks and/or events
actions.setSelection(state.authors.first().get('id'));
}
this.setState(state, () => {
this.isRunning = false;
});
}
onKeywordChange(e) {
const val = e.target.value;
if (this.isRunning) {
info('AuthorSearch is pending...');
return;
}
if (!val.length) {
info('No search value');
return;
}
if (val.length <= 2) {
info('Value is too short');
return;
}
this.isRunning = true;
this.service.actions.authors.search({keyword: val});
}
handleSelectAuthor(author) {
this.service.actions.authors.reset();
this.props.onSelectAuthor(author);
ReactDOM.findDOMNode(this.refs.input).querySelector('input').value = '';
}
onKeyPress(e) {
e.stopPropagation();
switch(e.which) {
case KEYMAP.ARROWUP:
this.handleSelectUp(e);
break;
case KEYMAP.ARROWDOWN:
this.handleSelectDown(e);
break;
case KEYMAP.ENTER:
this.handleSelectEnter(e);
break;
case KEYMAP.ESCAPE:
this.handleClear(e);
break;
case KEYMAP.TAB:
this.handleSelectTab(e);
break;
}
return;
}
handleSelectUp(e) {
e.preventDefault();
const items = this.state.authors.map((author, i) => {
return author.get('id');
}).toArray();
const current = store.state.selection;
const currentIndex = items.indexOf(current);
if (currentIndex - 1 < 0) {
return;
}
actions.setSelection(items[(currentIndex-1)]);
}
handleSelectDown(e) {
e.preventDefault();
const items = this.state.authors.map((author, i) => {
return author.get('id');
}).toArray();
const current = store.state.selection;
const currentIndex = items.indexOf(current);
if (currentIndex + 1 >= items.length) {
return;
}
actions.setSelection(items[(currentIndex+1)]);
}
handleSelectEnter(e) {
e.preventDefault();
this.state.authors.map((author, i) => {
if (store.state.selection == author.get('id')) {
this.handleSelectAuthor(author);
}
});
}
handleSelectTab(e) {
if (this.state.authors.size) {
e.preventDefault();
this.state.authors.map((author, i) => {
if (store.state.selection == author.get('id')) {
this.handleSelectAuthor(author);
}
});
} else {
this.refs.input.value = '';
this.service.actions.authors.reset();
}
}
handleClear(e) {
e.preventDefault();
ReactDOM.findDOMNode(this.refs.input).querySelector('input').value = '';
this.service.actions.authors.reset();
}
render() {
let className = '';
return (
<div className={className}>
<TextField
fullWidth={true}
floatingLabelText='Search Authors'
ref="input"
onChange={this.onKeywordChange}
onKeyDown={this.onKeyPress}
disabled={this.props.disabled}
/>
<AuthorSearchMenu authors={this.state.authors} onSelectAuthor={this.handleSelectAuthor} />
</div>
);
}
}
/**
* Floating search result menu. Selecting a author will append
* to parent state and fire onSelectAuthor handler
*/
class AuthorSearchMenu extends React.Component {
constructor(props) {
super(props);
this.state = {
authors: this.props.authors
};
}
componentWillReceiveProps({authors}) {
this.setState({authors: authors});
}
render() {
if (!this.state.authors.size) {
return <span></span>;
}
let items = [];
if (this.state.authors.size) {
items = this.state.authors.map((author, i) => {
return <AuthorSearchMenuItem author={author} key={i} onSelectAuthor={this.props.onSelectAuthor}/>
});
}
return (
<Transition
component={false} // don't use a wrapping component
enter={{
opacity: 1,
translateY: spring(0, {stiffness: 200, damping: 10})
}}
leave={{
opacity: 0,
translateY: -50
}}
>
<div className='author-search-menu-root' key='search-dropdown'>
<div className='author-search-menu'>
{items}
</div>
</div>
</Transition>
);
}
}
/**
* Single menu item component.
* Controls selection of author
*/
class AuthorSearchMenuItem extends React.Component {
constructor(props) {
super(props);
this.state = store.getState();
this.state.author = this.props.author;
this.handleSelect = this.handleSelect.bind(this);
this.onChange = this.onChange.bind(this);
}
componentWillMount() {
store.listen(this.onChange);
}
componentWillUnmount() {
store.unlisten(this.onChange);
}
componentWillReceiveProps(newProps) {
this.setState(newProps);
}
onChange(state) {
this.setState(state);
}
handleSelect(e) {
e.stopPropagation();
e.preventDefault();
if (this.props.onSelectAuthor) {
this.props.onSelectAuthor(this.state.author);
}
}
render() {
let isSelected = false;
if (this.state.selection && this.state.selection == this.state.author.get('id')) {
isSelected = true;
}
return (
<div onClick={this.handleSelect} className={isSelected ? 'highlighted' : '' }>{this.state.author.get('name')}</div>
);
}
}
/**
* The internal action and store are used to allow the components
* to communicate without having to resort to crazy callbacks or events.
*/
class UnwrappedSelectionActions {
updateSelection(id) {
return id;
}
setSelection(id) {
return (dispatch) => {
this.updateSelection(id);
}
}
}
const actions = alt.createActions(UnwrappedSelectionActions);
class UnwrappedSelectionStore {
constructor() {
this.state = {
selection: false
};
this.bindListeners({
'handleUpdateSelection': actions.UPDATE_SELECTION
})
}
handleUpdateSelection(id) {
this.setState({'selection': id});
}
}
const store = alt.createStore(UnwrappedSelectionStore);
export {AuthorSearchBox as default, AuthorSearchMenu, AuthorSearchMenuItem};