application/components/common/user-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 myalt from './../../myalt';
import {info} from './../../util/console';
import UserFlux from './../../services/user-service';
import BaseView from './../base-view';
/**
* 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 = myalt.createActions(UnwrappedSelectionActions);
class UnwrappedSelectionStore {
constructor() {
this.state = {
selection: false
};
this.bindListeners({
'handleUpdateSelection': actions.UPDATE_SELECTION
})
}
handleUpdateSelection(id) {
this.setState({'selection': id});
}
}
const store = myalt.createStore(UnwrappedSelectionStore);
const KEYMAP = {
ARROWDOWN: 40,
ARROWUP: 38,
ENTER: 13,
ESCAPE: 27,
TAB: 9
};
/**
* UserSearchBox controls the handling of user attachment searches.
*
* Users property is any existing users you want pushed into the stack
* on mount.
*
* <code>
* <UserSearchBox
* onSelectUser={...}
* onRemoveUser={...}
* users={...}
* />
* </code>
*
* The onSelectUser and onRemoveUser callbacks will receive two parameters, the user
* that was added/removed and the current full user state. Both are immutable maps.
*/
class UserSearchBox extends BaseView {
constructor(props) {
super(props);
this.isRunning = false;
this.service = new UserFlux;
this.onSearchUpdate = this.onSearchUpdate.bind(this);
this.onKeywordChange = this.onKeywordChange.bind(this);
this.state = this.service.stores.users.getState();
this.handleSelectUser = this.handleSelectUser.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.users.listen(this.onSearchUpdate);
}
componentWillUnmount() {
this.service.stores.users.unlisten(this.onSearchUpdate);
}
onSearchUpdate(state) {
// whenever we search, we automatically select the first result
// as the default, so the user can just hit return
if (state.users && state.users.size) {
// We're using an internal store here so the components can
// communicate sans wacky callbacks and/or events
actions.setSelection(state.users.first().get('id'));
}
this.setState(state, () => {
this.isRunning = false;
});
}
onKeywordChange(e) {
const val = e.target.value;
if (this.isRunning) {
info('UserSearch is pending...');
return;
}
if (!val.length) {
info('No search value');
return;
}
if (val.length < 3) {
return;
}
this.isRunning = true;
this.service.actions.users.search({keyword: val});
}
handleSelectUser(user) {
this.service.actions.users.reset();
this.props.onSelectUser(user, this.state.selectedUsers);
ReactDOM.findDOMNode(this.refs.textField).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.users.map((user, i) => {
return user.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.users.map((user, i) => {
return user.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.users.map((user, i) => {
if (store.state.selection == user.get('id')) {
this.handleSelectUser(user);
}
});
}
handleSelectTab(e) {
if (this.state.users.size) {
e.preventDefault();
this.state.users.map((user, i) => {
if (store.state.selection == user.get('id')) {
this.handleSelectUser(user);
}
});
} else {
this.refs.input.value = '';
this.service.actions.users.reset();
}
}
handleClear(e) {
e.preventDefault();
// this.refs.input.value = '';
this.service.actions.users.reset();
}
render() {
let className = '';
return (
<div className={className}>
<TextField
fullWidth={true}
floatingLabelText='Search Users'
ref='textField'
disabled={this.props.disabled}
onChange={this.onKeywordChange}
onKeyDown={this.onKeyPress}
/>
<UserSearchMenu users={this.state.users} onSelectUser={this.handleSelectUser} />
</div>
);
}
}
UserSearchBox.propTypes = {
onSelectUser: PropTypes.func,
onRemoveUser: PropTypes.func,
users: PropTypes.any
};
/**
* Single selected user item. Selected users are users that
* have already been added to the parent's internal state.
*/
class UserSearchItem extends BaseView {
constructor(props) {
super(props);
this.handleRemove = this.handleRemove.bind(this);
}
handleRemove(e) {
e.stopPropagation();
e.preventDefault();
if (this.props.onRemoveUser) {
this.props.onRemoveUser(this.props.user);
}
}
render() {
return (
<Chip
onRequestDelete={this.handleRemove}
style={{margin: 4}}
>
{this.props.user.get('name')}
</Chip>
);
}
}
/**
* Floating search result menu. Selecting a user will append
* to parent state and fire onSelectUser handler
*/
class UserSearchMenu extends BaseView {
constructor(props) {
super(props);
this.state = {
users: this.props.users
};
}
componentWillReceiveProps({users}) {
this.setState({users: users});
}
render() {
if (!this.state.users.size) {
return <span></span>;
}
let items = [];
if (this.state.users.size) {
items = this.state.users.map((user, i) => {
return <UserSearchMenuItem user={user} key={i} onSelectUser={this.props.onSelectUser}/>
});
}
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='user-search-menu-root' key='search-dropdown'>
<div className='user-search-menu'>
{items}
</div>
</div>
</Transition>
);
}
}
/**
* Single menu item component.
* Controls selection of user
*/
class UserSearchMenuItem extends BaseView {
constructor(props) {
super(props);
this.state = store.getState();
this.state.user = this.props.user;
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.onSelectUser) {
this.props.onSelectUser(this.state.user);
}
}
render() {
let isSelected = false;
if (this.state.selection && this.state.selection == this.state.user.get('id')) {
isSelected = true;
}
return (
<div onClick={this.handleSelect} className={isSelected ? 'highlighted' : '' }>{this.state.user.get('name')}</div>
);
}
}
export {UserSearchBox as default, UserSearchItem, UserSearchMenu, UserSearchMenuItem};