Home Reference Source

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};