2016-07-06 15:18:01 -03:00
|
|
|
import React, { Component, PropTypes } from 'react';
|
2016-06-30 18:12:23 -03:00
|
|
|
import fuzzy from 'fuzzy';
|
2016-07-05 13:48:52 -03:00
|
|
|
import _ from 'lodash';
|
2016-07-06 18:41:57 -03:00
|
|
|
import { runCommand } from '../actions/findbar';
|
2016-06-30 18:12:23 -03:00
|
|
|
import { connect } from 'react-redux';
|
2016-07-07 16:56:12 -03:00
|
|
|
import { Icon } from '../components/UI';
|
2016-07-06 15:18:01 -03:00
|
|
|
import styles from './FindBar.css';
|
2016-06-30 18:12:23 -03:00
|
|
|
|
2016-07-08 07:20:07 -03:00
|
|
|
export const SEARCH = 'SEARCH';
|
2016-07-08 11:19:43 -03:00
|
|
|
const PLACEHOLDER = 'Search or enter a command';
|
2016-07-07 16:56:12 -03:00
|
|
|
|
2016-07-06 15:18:01 -03:00
|
|
|
class FindBar extends Component {
|
2016-07-05 13:48:52 -03:00
|
|
|
constructor(props) {
|
|
|
|
super(props);
|
2016-07-07 16:56:12 -03:00
|
|
|
this._compiledCommands = [];
|
2016-07-07 19:21:10 -03:00
|
|
|
this._searchCommand = { search: true, regexp:`(?:${SEARCH})?(.*)`, param:{ name:'searchTerm', display:'' }, token: SEARCH };
|
2016-06-30 18:12:23 -03:00
|
|
|
this.state = {
|
2016-07-06 18:10:13 -03:00
|
|
|
value: '',
|
2016-07-07 17:02:00 -03:00
|
|
|
placeholder: PLACEHOLDER,
|
2016-07-06 18:10:13 -03:00
|
|
|
activeScope: null,
|
2016-07-06 15:18:01 -03:00
|
|
|
isOpen: false,
|
|
|
|
highlightedIndex: 0,
|
2016-06-30 18:12:23 -03:00
|
|
|
};
|
|
|
|
|
2016-07-06 18:10:13 -03:00
|
|
|
this._getSuggestions = _.memoize(this._getSuggestions, (value, activeScope) => value + activeScope);
|
2016-06-30 18:12:23 -03:00
|
|
|
this.compileCommand = this.compileCommand.bind(this);
|
|
|
|
this.matchCommand = this.matchCommand.bind(this);
|
2016-07-07 11:26:34 -03:00
|
|
|
this.maybeRemoveActiveScope = this.maybeRemoveActiveScope.bind(this);
|
2016-07-06 15:18:01 -03:00
|
|
|
this.handleChange = this.handleChange.bind(this);
|
|
|
|
this.handleKeyDown = this.handleKeyDown.bind(this);
|
|
|
|
this.handleInputBlur = this.handleInputBlur.bind(this);
|
|
|
|
this.handleInputFocus = this.handleInputFocus.bind(this);
|
|
|
|
this.handleInputClick = this.handleInputClick.bind(this);
|
2016-07-06 18:10:13 -03:00
|
|
|
this.getSuggestions = this.getSuggestions.bind(this);
|
2016-07-06 15:18:01 -03:00
|
|
|
this.highlightCommandFromMouse = this.highlightCommandFromMouse.bind(this);
|
|
|
|
this.selectCommandFromMouse = this.selectCommandFromMouse.bind(this);
|
|
|
|
this.setIgnoreBlur = this.setIgnoreBlur.bind(this);
|
|
|
|
}
|
|
|
|
|
|
|
|
componentWillMount() {
|
|
|
|
this._ignoreBlur = false;
|
2016-06-30 18:12:23 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
componentDidMount() {
|
2016-07-06 15:18:01 -03:00
|
|
|
this._compiledCommands = this.props.commands.map(this.compileCommand);
|
2016-06-30 18:12:23 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
_escapeRegExp(string) {
|
|
|
|
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
|
|
}
|
|
|
|
|
|
|
|
_camelCaseToSpace(string) {
|
2016-07-06 15:18:01 -03:00
|
|
|
const result = string.replace(/([A-Z])/g, ' $1');
|
2016-06-30 18:12:23 -03:00
|
|
|
return result.charAt(0).toUpperCase() + result.slice(1);
|
|
|
|
}
|
|
|
|
|
2016-07-07 12:04:19 -03:00
|
|
|
// Generates a regexp and splits a token and param details for a command
|
2016-06-30 18:12:23 -03:00
|
|
|
compileCommand(command) {
|
|
|
|
let regexp = '';
|
2016-07-07 12:04:19 -03:00
|
|
|
let param = null;
|
2016-06-30 18:12:23 -03:00
|
|
|
|
|
|
|
const matcher = /\(:([a-zA-Z_$][a-zA-Z0-9_$]*)(?:(?: as )(.*))?\)/g;
|
|
|
|
const match = matcher.exec(command.pattern);
|
2016-07-07 12:04:19 -03:00
|
|
|
const matchIndex = match ? match.index : command.pattern.length;
|
2016-06-30 18:12:23 -03:00
|
|
|
|
2016-07-07 12:04:19 -03:00
|
|
|
const token = command.pattern.slice(0, matchIndex) || command.token;
|
|
|
|
regexp += this._escapeRegExp(command.pattern.slice(0, matchIndex));
|
|
|
|
|
|
|
|
if (match && match[1]) {
|
2016-06-30 18:12:23 -03:00
|
|
|
regexp += '(.*)';
|
|
|
|
param = { name:match[1], display:match[2] || this._camelCaseToSpace(match[1]) };
|
|
|
|
}
|
|
|
|
|
|
|
|
return Object.assign({}, command, {
|
|
|
|
regexp,
|
|
|
|
token,
|
|
|
|
param
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2016-07-07 12:04:19 -03:00
|
|
|
// Check if the entered string matches any command.
|
|
|
|
// adds a scope (so user can type param value) and dispatches action for fully matched commands
|
2016-07-06 18:10:13 -03:00
|
|
|
matchCommand() {
|
|
|
|
const string = this.state.activeScope ? this.state.activeScope + this.state.value : this.state.value;
|
2016-06-30 18:12:23 -03:00
|
|
|
let match;
|
2016-07-07 16:56:12 -03:00
|
|
|
let command = this._compiledCommands.find(command => {
|
2016-06-30 18:12:23 -03:00
|
|
|
match = string.match(RegExp(`^${command.regexp}`, 'i'));
|
|
|
|
return match;
|
|
|
|
});
|
|
|
|
|
2016-07-07 16:56:12 -03:00
|
|
|
// If no command was found, trigger a search command
|
2016-07-06 18:10:13 -03:00
|
|
|
if (!command) {
|
2016-07-07 16:56:12 -03:00
|
|
|
command = this._searchCommand;
|
|
|
|
match = string.match(RegExp(`^${this._searchCommand.regexp}`, 'i'));
|
|
|
|
}
|
|
|
|
|
|
|
|
const paramName = command && command.param ? command.param.name : null;
|
|
|
|
const enteredParamValue = command && command.param && match[1] ? match[1].trim() : null;
|
|
|
|
|
|
|
|
if (command.search) {
|
|
|
|
this.setState({
|
2016-07-07 17:02:00 -03:00
|
|
|
activeScope: SEARCH,
|
|
|
|
placeholder: ''
|
2016-07-07 16:56:12 -03:00
|
|
|
});
|
2016-07-07 19:21:10 -03:00
|
|
|
|
2016-07-08 07:20:07 -03:00
|
|
|
enteredParamValue && this.props.dispatch(runCommand(SEARCH, { searchTerm: enteredParamValue }));
|
2016-07-07 12:04:19 -03:00
|
|
|
} else if (command.param && !enteredParamValue) {
|
|
|
|
// Partial Match
|
|
|
|
// Command was partially matched: It requires a param, but param wasn't entered
|
|
|
|
// Set a scope so user can fill the param
|
2016-07-06 18:10:13 -03:00
|
|
|
this.setState({
|
|
|
|
value: '',
|
|
|
|
activeScope: command.token,
|
|
|
|
placeholder: command.param.display
|
|
|
|
});
|
2016-07-06 18:41:57 -03:00
|
|
|
} else {
|
2016-07-07 12:04:19 -03:00
|
|
|
// Match
|
|
|
|
// Command was matched and either it doesn't require a param or it's required param was entered
|
|
|
|
// Dispatch action
|
2016-07-08 07:20:07 -03:00
|
|
|
this.setState({
|
|
|
|
value: '',
|
|
|
|
placeholder: PLACEHOLDER,
|
|
|
|
activeScope: null
|
2016-07-08 08:58:08 -03:00
|
|
|
}, () => {
|
|
|
|
this._input.blur();
|
2016-07-08 07:20:07 -03:00
|
|
|
});
|
2016-07-20 12:15:29 -03:00
|
|
|
const payload = command.payload || {};
|
|
|
|
if (paramName) {
|
|
|
|
payload[paramName] = enteredParamValue;
|
|
|
|
}
|
|
|
|
this.props.dispatch(runCommand(command.type, payload));
|
2016-07-06 18:10:13 -03:00
|
|
|
}
|
2016-06-30 18:12:23 -03:00
|
|
|
}
|
|
|
|
|
2016-07-07 11:26:34 -03:00
|
|
|
maybeRemoveActiveScope() {
|
|
|
|
if (this.state.value.length === 0 && this.state.activeScope) {
|
|
|
|
this.setState({
|
|
|
|
activeScope: null,
|
2016-07-07 17:02:00 -03:00
|
|
|
placeholder: PLACEHOLDER
|
2016-07-07 11:26:34 -03:00
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2016-07-07 12:04:19 -03:00
|
|
|
getSuggestions() {
|
2016-07-08 08:58:08 -03:00
|
|
|
return this._getSuggestions(this.state.value, this.state.activeScope, this._compiledCommands, this.props.defaultCommands);
|
2016-07-07 12:04:19 -03:00
|
|
|
}
|
|
|
|
// Memoized version
|
2016-07-08 08:58:08 -03:00
|
|
|
_getSuggestions(value, scope, commands, defaultCommands) {
|
2016-07-07 12:04:19 -03:00
|
|
|
if (scope) return []; // No autocomplete for scoped input
|
2016-07-08 08:58:08 -03:00
|
|
|
if (value.length === 0 && defaultCommands) {
|
|
|
|
return commands
|
|
|
|
.filter(command => defaultCommands.indexOf(command.id) !== -1)
|
|
|
|
.map(result => (
|
|
|
|
Object.assign({}, result, { string: result.token }
|
|
|
|
)));
|
|
|
|
}
|
2016-07-07 16:56:12 -03:00
|
|
|
|
2016-07-07 12:04:19 -03:00
|
|
|
const results = fuzzy.filter(value, commands, {
|
2016-07-07 16:56:12 -03:00
|
|
|
pre: '<strong>',
|
|
|
|
post: '</strong>',
|
2016-07-07 12:04:19 -03:00
|
|
|
extract: el => el.token
|
2016-07-06 15:18:01 -03:00
|
|
|
});
|
2016-07-07 16:56:12 -03:00
|
|
|
|
2016-07-07 19:21:10 -03:00
|
|
|
const returnResults = results.slice(0, 4).map(result => (
|
|
|
|
Object.assign({}, result.original, { string:result.string }
|
|
|
|
)));
|
|
|
|
returnResults.push(this._searchCommand);
|
2016-07-07 16:56:12 -03:00
|
|
|
|
|
|
|
return returnResults;
|
2016-07-06 15:18:01 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
handleKeyDown(event) {
|
2016-07-06 18:10:13 -03:00
|
|
|
let highlightedIndex, index;
|
2016-07-06 15:18:01 -03:00
|
|
|
switch (event.key) {
|
|
|
|
case 'ArrowDown':
|
|
|
|
event.preventDefault();
|
2016-07-06 18:10:13 -03:00
|
|
|
highlightedIndex = this.state.highlightedIndex;
|
|
|
|
index = (
|
|
|
|
highlightedIndex === this.getSuggestions().length - 1 ||
|
2016-07-06 15:18:01 -03:00
|
|
|
this.state.isOpen === false
|
|
|
|
) ? 0 : highlightedIndex + 1;
|
|
|
|
this.setState({
|
|
|
|
highlightedIndex: index,
|
|
|
|
isOpen: true,
|
|
|
|
});
|
|
|
|
break;
|
|
|
|
case 'ArrowUp':
|
|
|
|
event.preventDefault();
|
2016-07-06 18:10:13 -03:00
|
|
|
highlightedIndex = this.state.highlightedIndex;
|
|
|
|
index = (
|
2016-07-06 15:18:01 -03:00
|
|
|
highlightedIndex === 0
|
2016-07-06 18:10:13 -03:00
|
|
|
) ? this.getSuggestions().length - 1 : highlightedIndex - 1;
|
2016-07-06 15:18:01 -03:00
|
|
|
this.setState({
|
|
|
|
highlightedIndex: index,
|
|
|
|
isOpen: true,
|
|
|
|
});
|
|
|
|
break;
|
|
|
|
case 'Enter':
|
|
|
|
if (this.state.isOpen) {
|
2016-07-06 18:10:13 -03:00
|
|
|
const command = this.getSuggestions()[this.state.highlightedIndex];
|
|
|
|
const newState = {
|
2016-07-06 15:18:01 -03:00
|
|
|
isOpen: false,
|
|
|
|
highlightedIndex: 0
|
2016-07-06 18:10:13 -03:00
|
|
|
};
|
2016-07-07 16:56:12 -03:00
|
|
|
if (command && !command.search) {
|
2016-07-06 18:10:13 -03:00
|
|
|
newState.value = command.token;
|
|
|
|
}
|
|
|
|
this.setState(newState, () => {
|
|
|
|
this._input.focus();
|
|
|
|
this._input.setSelectionRange(
|
2016-07-06 15:18:01 -03:00
|
|
|
this.state.value.length,
|
|
|
|
this.state.value.length
|
|
|
|
);
|
2016-07-06 18:10:13 -03:00
|
|
|
this.matchCommand();
|
2016-07-06 15:18:01 -03:00
|
|
|
});
|
|
|
|
}
|
|
|
|
break;
|
|
|
|
case 'Escape':
|
|
|
|
this.setState({
|
2016-07-08 08:58:08 -03:00
|
|
|
value: '',
|
2016-07-06 15:18:01 -03:00
|
|
|
highlightedIndex: 0,
|
2016-07-08 08:58:08 -03:00
|
|
|
isOpen: false,
|
|
|
|
activeScope: null,
|
|
|
|
placeholder: PLACEHOLDER
|
|
|
|
});
|
2016-07-06 15:18:01 -03:00
|
|
|
break;
|
2016-07-07 17:14:09 -03:00
|
|
|
case 'Backspace':
|
|
|
|
this.setState({
|
|
|
|
highlightedIndex: 0,
|
|
|
|
isOpen: true
|
|
|
|
}, this.maybeRemoveActiveScope);
|
|
|
|
break;
|
2016-07-06 15:18:01 -03:00
|
|
|
default:
|
|
|
|
this.setState({
|
|
|
|
highlightedIndex: 0,
|
|
|
|
isOpen: true
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2016-07-07 12:04:19 -03:00
|
|
|
handleChange(event) {
|
|
|
|
this.setState({
|
|
|
|
value: event.target.value,
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2016-07-06 15:18:01 -03:00
|
|
|
handleInputBlur() {
|
|
|
|
if (this._ignoreBlur) return;
|
|
|
|
this.setState({
|
|
|
|
isOpen: false,
|
|
|
|
highlightedIndex: 0
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
handleInputFocus() {
|
|
|
|
if (this._ignoreBlur) return;
|
|
|
|
this.setState({ isOpen: true });
|
|
|
|
}
|
|
|
|
|
|
|
|
handleInputClick() {
|
|
|
|
if (this.state.isOpen === false)
|
|
|
|
this.setState({ isOpen: true });
|
|
|
|
}
|
|
|
|
|
|
|
|
highlightCommandFromMouse(index) {
|
|
|
|
this.setState({ highlightedIndex: index });
|
2016-06-30 18:12:23 -03:00
|
|
|
}
|
|
|
|
|
2016-07-06 15:18:01 -03:00
|
|
|
selectCommandFromMouse(command) {
|
2016-07-07 16:56:12 -03:00
|
|
|
const newState = {
|
2016-07-06 15:18:01 -03:00
|
|
|
isOpen: false,
|
|
|
|
highlightedIndex: 0
|
2016-07-07 16:56:12 -03:00
|
|
|
};
|
|
|
|
if (command && !command.search) {
|
|
|
|
newState.value = command.token;
|
|
|
|
}
|
|
|
|
this.setState(newState, () => {
|
2016-07-06 18:10:13 -03:00
|
|
|
this.matchCommand();
|
|
|
|
this._input.focus();
|
2016-07-06 15:18:01 -03:00
|
|
|
this.setIgnoreBlur(false);
|
2016-06-30 18:12:23 -03:00
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2016-07-06 15:18:01 -03:00
|
|
|
setIgnoreBlur(ignore) {
|
|
|
|
this._ignoreBlur = ignore;
|
|
|
|
}
|
|
|
|
|
|
|
|
renderMenu() {
|
2016-07-06 18:10:13 -03:00
|
|
|
const commands = this.getSuggestions().map((command, index) => {
|
2016-07-07 19:21:10 -03:00
|
|
|
let children;
|
2016-07-07 16:56:12 -03:00
|
|
|
if (!command.search) {
|
2016-07-07 19:21:10 -03:00
|
|
|
children = (
|
2016-07-08 11:19:43 -03:00
|
|
|
<span><span dangerouslySetInnerHTML={{__html: command.string}} /></span>
|
2016-07-07 16:56:12 -03:00
|
|
|
);
|
|
|
|
} else {
|
2016-07-07 19:21:10 -03:00
|
|
|
children = (
|
|
|
|
<span>
|
|
|
|
{this.state.value.length === 0 ?
|
|
|
|
<span><Icon type="search"/>Search... </span> :
|
|
|
|
<span className={styles.faded}><Icon type="search"/>Search for: </span>
|
|
|
|
}
|
2016-07-08 14:11:53 -03:00
|
|
|
<strong>{this.state.value}</strong></span>
|
2016-07-07 16:56:12 -03:00
|
|
|
);
|
|
|
|
}
|
2016-07-07 19:21:10 -03:00
|
|
|
return (
|
|
|
|
<div
|
|
|
|
className={this.state.highlightedIndex === index ? styles.highlightedCommand : styles.command}
|
|
|
|
key={command.token.trim().replace(/[^a-z0-9]+/gi, '-')}
|
|
|
|
onMouseDown={() => this.setIgnoreBlur(true)}
|
|
|
|
onMouseEnter={() => this.highlightCommandFromMouse(index)}
|
|
|
|
onClick={() => this.selectCommandFromMouse(command)}
|
|
|
|
>
|
|
|
|
{children}
|
|
|
|
</div>
|
|
|
|
);
|
2016-07-06 15:18:01 -03:00
|
|
|
});
|
2016-07-07 19:21:10 -03:00
|
|
|
return commands.length === 0 ? null : (
|
|
|
|
<div className={styles.menu}>
|
|
|
|
<div className={styles.suggestions}>
|
|
|
|
{ commands }
|
|
|
|
</div>
|
|
|
|
<div className={styles.history}>
|
|
|
|
Your past searches and commands
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
);
|
2016-07-06 18:10:13 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
renderActiveScope() {
|
2016-07-07 16:56:12 -03:00
|
|
|
if (this.state.activeScope === SEARCH) {
|
|
|
|
return <div className={styles.inputScope}><Icon type="search"/> </div>;
|
|
|
|
} else {
|
|
|
|
return <div className={styles.inputScope}>{this.state.activeScope}</div>;
|
|
|
|
}
|
2016-07-06 15:18:01 -03:00
|
|
|
}
|
|
|
|
|
2016-06-30 18:12:23 -03:00
|
|
|
render() {
|
2016-07-06 18:10:13 -03:00
|
|
|
const menu = this.state.isOpen && this.renderMenu();
|
|
|
|
const scope = this.state.activeScope && this.renderActiveScope();
|
2016-06-30 18:12:23 -03:00
|
|
|
return (
|
2016-07-08 05:58:06 -03:00
|
|
|
<div className={styles.root}>
|
2016-07-06 18:10:13 -03:00
|
|
|
<label className={styles.inputArea}>
|
|
|
|
{scope}
|
2016-07-06 15:18:01 -03:00
|
|
|
<input
|
2016-07-06 18:10:13 -03:00
|
|
|
className={styles.inputField}
|
|
|
|
ref={(c) => this._input = c}
|
2016-07-06 15:18:01 -03:00
|
|
|
onFocus={this.handleInputFocus}
|
|
|
|
onBlur={this.handleInputBlur}
|
2016-07-07 11:26:34 -03:00
|
|
|
onChange={this.handleChange}
|
|
|
|
onKeyDown={this.handleKeyDown}
|
2016-07-06 15:18:01 -03:00
|
|
|
onClick={this.handleInputClick}
|
2016-07-06 18:10:13 -03:00
|
|
|
placeholder={this.state.placeholder}
|
2016-07-06 15:18:01 -03:00
|
|
|
value={this.state.value}
|
|
|
|
/>
|
2016-07-06 18:10:13 -03:00
|
|
|
</label>
|
|
|
|
{menu}
|
2016-07-06 15:18:01 -03:00
|
|
|
</div>
|
2016-06-30 18:12:23 -03:00
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
FindBar.propTypes = {
|
2016-07-08 07:20:07 -03:00
|
|
|
commands: PropTypes.arrayOf(PropTypes.shape({
|
|
|
|
id: PropTypes.string.isRequired,
|
2016-07-20 12:15:29 -03:00
|
|
|
type: PropTypes.string.isRequired,
|
2016-07-08 07:20:07 -03:00
|
|
|
pattern: PropTypes.string.isRequired
|
|
|
|
})).isRequired,
|
2016-07-08 08:58:08 -03:00
|
|
|
defaultCommands: PropTypes.arrayOf(PropTypes.string),
|
2016-07-06 18:41:57 -03:00
|
|
|
dispatch: PropTypes.func.isRequired,
|
2016-07-06 15:18:01 -03:00
|
|
|
};
|
|
|
|
|
2016-07-06 20:01:50 -03:00
|
|
|
export { FindBar };
|
2016-07-06 19:07:42 -03:00
|
|
|
export default connect()(FindBar);
|