Home Reference Source

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