Home Reference Source

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