application/components/common/tag-search-box.js
import React from 'react';
import PropTypes from 'prop-types';
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 TagFlux from './../../services/tag-service';
const KEYMAP = {
ARROWDOWN: 40,
ARROWUP: 38,
ENTER: 13,
ESCAPE: 27,
TAB: 9
};
/**
* TagSearchBox controls the handling of tag attachment searches.
*
* Tags property is any existing tags you want pushed into the stack
* on mount.
*
* <code>
* <TagSearchBox
* onSelectTag={...}
* />
* </code>
*
* The onSelectTag and onRemoveTag callbacks will receive two parameters, the tag
* that was added/removed and the current full tag state. Both are immutable maps.
*/
class TagSearchBox extends React.Component {
constructor(props) {
super(props);
this.isRunning = false;
this.service = new TagFlux;
this.onSearchUpdate = this.onSearchUpdate.bind(this);
this.onKeywordChange = this.onKeywordChange.bind(this);
this.state = this.service.stores.tags.getState();
this.handleSelectTag = this.handleSelectTag.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.tags.listen(this.onSearchUpdate);
}
componentWillUnmount() {
this.service.stores.tags.unlisten(this.onSearchUpdate);
}
onSearchUpdate(state) {
// whenever we search, we automatically select the first result
// as the default, so the tag can just hit return
if (state.tags && state.tags.size) {
// We're using an internal store here so the components can
// communicate sans wacky callbacks and/or events
actions.setSelection(state.tags.first().get('id'));
}
this.setState(state, () => {
this.isRunning = false;
});
}
onKeywordChange(e) {
const val = e.target.value;
if (this.isRunning) {
info('TagSearch 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.tags.search({keyword: val});
}
handleSelectTag(tag) {
this.service.actions.tags.reset();
this.props.onSelectTag(tag);
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.tags.map((tag, i) => {
return tag.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.tags.map((tag, i) => {
return tag.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.tags.map((tag, i) => {
if (store.state.selection == tag.get('id')) {
this.handleSelectTag(tag);
}
});
}
handleClear(e) {
e.preventDefault();
this.refs.input.value = '';
this.service.actions.tags.reset();
}
handleSelectTab(e) {
if (this.state.tags.size) {
e.preventDefault();
this.state.tags.map((tag, i) => {
if (store.state.selection == tag.get('id')) {
this.handleSelectTag(tag);
}
});
} else {
this.refs.input.value = '';
this.service.actions.tags.reset();
}
}
render() {
let className = '';
return (
<div className={className}>
<TextField
fullWidth={true}
floatingLabelText='Search Tags'
ref="input"
onChange={this.onKeywordChange}
onKeyDown={this.onKeyPress}
disabled={this.props.disabled}
/>
<TagSearchMenu tags={this.state.tags} onSelectTag={this.handleSelectTag} />
</div>
);
}
}
TagSearchBox.propTypes = {
onSelectTag: PropTypes.func,
onRemoveTag: PropTypes.func,
tags: PropTypes.any
};
/**
* Floating search result menu. Selecting a tag will append
* to parent state and fire onSelectTag handler
*/
class TagSearchMenu extends React.Component {
constructor(props) {
super(props);
this.state = {
tags: this.props.tags
};
}
componentWillReceiveProps({tags}) {
this.setState({tags: tags});
}
render() {
if (!this.state.tags.size) {
return <span></span>;
}
let items = [];
if (this.state.tags.size) {
items = this.state.tags.map((tag, i) => {
return <TagSearchMenuItem tag={tag} key={i} onSelectTag={this.props.onSelectTag}/>
});
}
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='tag-search-menu-root' key='search-dropdown'>
<div className='tag-search-menu'>
{items}
</div>
</div>
</Transition>
);
}
}
/**
* Single menu item component.
* Controls selection of tag
*/
class TagSearchMenuItem extends React.Component {
constructor(props) {
super(props);
this.state = store.getState();
this.state.tag = this.props.tag;
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.onSelectTag) {
this.props.onSelectTag(this.state.tag);
}
}
render() {
let isSelected = false;
if (this.state.selection && this.state.selection == this.state.tag.get('id')) {
isSelected = true;
}
return (
<div onClick={this.handleSelect} className={isSelected ? 'highlighted' : '' }>{this.state.tag.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 {TagSearchBox as default, TagSearchMenu, TagSearchMenuItem};