Moved findBar to components and decopuled it from redux as much as possible.

Removed stories from containers. These aren't possible to write since containers depend on redux.
This commit is contained in:
Andrey Okonetchnikov
2016-09-16 12:54:26 +02:00
parent ede273a732
commit 46667926b2
9 changed files with 31 additions and 26 deletions

View File

@ -3,7 +3,7 @@ import pluralize from 'pluralize';
import { IndexLink } from 'react-router';
import { Menu, MenuItem, Button } from 'react-toolbox';
import AppBar from 'react-toolbox/lib/app_bar';
import FindBar from '../../../containers/FindBar';
import FindBar from '../FindBar/FindBar';
import styles from './AppHeader.css';
export default class AppHeader extends React.Component {
@ -38,7 +38,7 @@ export default class AppHeader extends React.Component {
}
render() {
const { collections, commands, defaultCommands } = this.props;
const { collections, commands, defaultCommands, runCommand } = this.props;
const { createMenuActive } = this.state;
return (
@ -52,6 +52,7 @@ export default class AppHeader extends React.Component {
<FindBar
commands={commands}
defaultCommands={defaultCommands}
runCommand={runCommand}
/>
<Button
className={styles.createBtn}

View File

@ -0,0 +1,98 @@
:root {
--foregroundColor: #fff;
--backgroundColor: #272e30;
--textFieldBorderColor: #e7e7e7;
--highlightFGColor: #fff;
--highlightBGColor: #3ab7a5;
}
.root {
flex: 1;
position: relative;
background-color: var(--backgroundColor);
padding: 5px;
}
.inputArea {
display: table;
width: 100%;
color: var(--foregroundColor);
background-color: #fff;
border: 1px solid var(--textFieldBorderColor);
}
.inputScope {
display: table-cell;
width: 1%;
padding: 0 6px 0 8px;
color: #767676;
font-size: 16px;
white-space: nowrap;
vertical-align: middle;
border-right: 1px solid var(--textFieldBorderColor);
margin:0;
}
.inputField {
display: table-cell;
width: 99%;
padding: 6px;
font-size: 16px;
background: none transparent;
border: 0 none;
box-shadow: none;
outline: none;
font-size: inherit;
}
.menu {
position: absolute;
top: 44px;
left: 0;
z-index: 9999;
display: table;
width: 100%;
background: var(--backgroundColor);
font-size: 13px;
font-weight: 400;
padding: 5px;
height: 100%;
}
.suggestions {
display: table-cell;
width: 50%;
padding-right: 10px;
}
.history {
display: table-cell;
width: 50%;
padding-left: 10px;
}
.command {
padding: 6px;
cursor: default;
color: var(--foregroundColor);
}
.command strong, .highlightedCommand strong {
color: #000;
}
.command .faded {
font-weight: 300;
color: #888;
}
.highlightedCommand {
color: var(--highlightFGColor);
background: var(--highlightBGColor);
padding: 6px;
cursor: default;
}
.highlightedCommand .faded {
font-weight: 300;
color: #282c34;
}

View File

@ -0,0 +1,379 @@
import React, { Component, PropTypes } from 'react';
import fuzzy from 'fuzzy';
import _ from 'lodash';
import { Icon } from '../index';
import styles from './FindBar.css';
export const SEARCH = 'SEARCH';
const PLACEHOLDER = 'Search or enter a command';
class FindBar extends Component {
constructor(props) {
super(props);
this._compiledCommands = [];
this._searchCommand = {
search: true,
regexp: `(?:${SEARCH})?(.*)`,
param: { name: 'searchTerm', display: '' },
token: SEARCH
};
this.state = {
value: '',
placeholder: PLACEHOLDER,
activeScope: null,
isOpen: false,
highlightedIndex: 0,
};
this._getSuggestions = _.memoize(this._getSuggestions, (value, activeScope) => value + activeScope);
this.compileCommand = this.compileCommand.bind(this);
this.matchCommand = this.matchCommand.bind(this);
this.maybeRemoveActiveScope = this.maybeRemoveActiveScope.bind(this);
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);
this.getSuggestions = this.getSuggestions.bind(this);
this.highlightCommandFromMouse = this.highlightCommandFromMouse.bind(this);
this.selectCommandFromMouse = this.selectCommandFromMouse.bind(this);
this.setIgnoreBlur = this.setIgnoreBlur.bind(this);
}
componentWillMount() {
this._ignoreBlur = false;
}
componentDidMount() {
this._compiledCommands = this.props.commands.map(this.compileCommand);
}
_escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
_camelCaseToSpace(string) {
const result = string.replace(/([A-Z])/g, ' $1');
return result.charAt(0).toUpperCase() + result.slice(1);
}
// Generates a regexp and splits a token and param details for a command
compileCommand(command) {
let regexp = '';
let param = null;
const matcher = /\(:([a-zA-Z_$][a-zA-Z0-9_$]*)(?:(?: as )(.*))?\)/g;
const match = matcher.exec(command.pattern);
const matchIndex = match ? match.index : command.pattern.length;
const token = command.pattern.slice(0, matchIndex) || command.token;
regexp += this._escapeRegExp(command.pattern.slice(0, matchIndex));
if (match && match[1]) {
regexp += '(.*)';
param = { name: match[1], display: match[2] || this._camelCaseToSpace(match[1]) };
}
return Object.assign({}, command, {
regexp,
token,
param
});
}
// Check if the entered string matches any command.
// adds a scope (so user can type param value) and dispatches action for fully matched commands
matchCommand() {
const string = this.state.activeScope ? this.state.activeScope + this.state.value : this.state.value;
let match;
let command = this._compiledCommands.find(command => {
match = string.match(RegExp(`^${command.regexp}`, 'i'));
return match;
});
// If no command was found, trigger a search command
if (!command) {
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;
console.log(this.props.runCommand);
if (command.search) {
this.setState({
activeScope: SEARCH,
placeholder: ''
});
enteredParamValue && this.props.runCommand(SEARCH, { searchTerm: enteredParamValue });
} 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
this.setState({
value: '',
activeScope: command.token,
placeholder: command.param.display
});
} else {
// Match
// Command was matched and either it doesn't require a param or it's required param was entered
// Dispatch action
this.setState({
value: '',
placeholder: PLACEHOLDER,
activeScope: null
}, () => {
this._input.blur();
});
const payload = command.payload || {};
if (paramName) {
payload[paramName] = enteredParamValue;
}
this.props.runCommand(command.type, payload);
}
}
maybeRemoveActiveScope() {
if (this.state.value.length === 0 && this.state.activeScope) {
this.setState({
activeScope: null,
placeholder: PLACEHOLDER
});
}
}
getSuggestions() {
return this._getSuggestions(this.state.value, this.state.activeScope, this._compiledCommands, this.props.defaultCommands);
}
// Memoized version
_getSuggestions(value, scope, commands, defaultCommands) {
if (scope) return []; // No autocomplete for scoped input
if (value.length === 0 && defaultCommands) {
return commands
.filter(command => defaultCommands.indexOf(command.id) !== -1)
.map(result => (
Object.assign({}, result, { string: result.token }
)));
}
const results = fuzzy.filter(value, commands, {
pre: '<strong>',
post: '</strong>',
extract: el => el.token
});
const returnResults = results.slice(0, 4).map(result => (
Object.assign({}, result.original, { string: result.string }
)));
returnResults.push(this._searchCommand);
return returnResults;
}
handleKeyDown(event) {
let highlightedIndex, index;
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
highlightedIndex = this.state.highlightedIndex;
index = (
highlightedIndex === this.getSuggestions().length - 1 ||
this.state.isOpen === false
) ? 0 : highlightedIndex + 1;
this.setState({
highlightedIndex: index,
isOpen: true,
});
break;
case 'ArrowUp':
event.preventDefault();
highlightedIndex = this.state.highlightedIndex;
index = (
highlightedIndex === 0
) ? this.getSuggestions().length - 1 : highlightedIndex - 1;
this.setState({
highlightedIndex: index,
isOpen: true,
});
break;
case 'Enter':
if (this.state.isOpen) {
const command = this.getSuggestions()[this.state.highlightedIndex];
const newState = {
isOpen: false,
highlightedIndex: 0
};
if (command && !command.search) {
newState.value = command.token;
}
this.setState(newState, () => {
this._input.focus();
this._input.setSelectionRange(
this.state.value.length,
this.state.value.length
);
this.matchCommand();
});
}
break;
case 'Escape':
this.setState({
value: '',
highlightedIndex: 0,
isOpen: false,
activeScope: null,
placeholder: PLACEHOLDER
});
break;
case 'Backspace':
this.setState({
highlightedIndex: 0,
isOpen: true
}, this.maybeRemoveActiveScope);
break;
default:
this.setState({
highlightedIndex: 0,
isOpen: true
});
}
}
handleChange(event) {
this.setState({
value: event.target.value,
});
}
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 });
}
selectCommandFromMouse(command) {
const newState = {
isOpen: false,
highlightedIndex: 0
};
if (command && !command.search) {
newState.value = command.token;
}
this.setState(newState, () => {
this.matchCommand();
this._input.focus();
this.setIgnoreBlur(false);
});
}
setIgnoreBlur(ignore) {
this._ignoreBlur = ignore;
}
renderMenu() {
const commands = this.getSuggestions().map((command, index) => {
let children;
if (!command.search) {
children = (
<span><span dangerouslySetInnerHTML={{ __html: command.string }}/></span>
);
} else {
children = (
<span>
{this.state.value.length === 0 ?
<span><Icon type="search"/>Search... </span> :
<span className={styles.faded}><Icon type="search"/>Search for: </span>
}
<strong>{this.state.value}</strong>
</span>
);
}
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>
);
});
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>
);
}
renderActiveScope() {
if (this.state.activeScope === SEARCH) {
return <div className={styles.inputScope}><Icon type="search"/></div>;
} else {
return <div className={styles.inputScope}>{this.state.activeScope}</div>;
}
}
render() {
const menu = this.state.isOpen && this.renderMenu();
const scope = this.state.activeScope && this.renderActiveScope();
return (
<div className={styles.root}>
<label className={styles.inputArea}>
{scope}
<input
className={styles.inputField}
ref={(c) => this._input = c}
onFocus={this.handleInputFocus}
onBlur={this.handleInputBlur}
onChange={this.handleChange}
onKeyDown={this.handleKeyDown}
onClick={this.handleInputClick}
placeholder={this.state.placeholder}
value={this.state.value}
/>
</label>
{menu}
</div>
);
}
}
FindBar.propTypes = {
commands: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.string.isRequired,
type: PropTypes.string.isRequired,
pattern: PropTypes.string.isRequired
})).isRequired,
defaultCommands: PropTypes.arrayOf(PropTypes.string),
runCommand: PropTypes.func.isRequired,
};
export default FindBar;

View File

@ -0,0 +1,42 @@
import React from 'react';
import { storiesOf, action } from '@kadira/storybook';
import FindBar from '../UI/FindBar/FindBar';
const CREATE_COLLECTION = 'CREATE_COLLECTION';
const CREATE_POST = 'CREATE_POST';
const CREATE_ARTICLE = 'CREATE_ARTICLE';
const CREATE_FAQ = 'CREATE_FAQ';
const ADD_NEWS = 'ADD_NEWS';
const ADD_USER = 'ADD_USER';
const OPEN_SETTINGS = 'OPEN_SETTINGS';
const HELP = 'HELP';
const MORE_COMMANDS = 'MORE_COMMANDS';
const commands = [
{ id: CREATE_COLLECTION, pattern: 'Create new Collection(:collectionName)' },
{ id: CREATE_POST, pattern: 'Create new Post(:postName)' },
{ id: CREATE_ARTICLE, pattern: 'Create new Article(:articleName)' },
{ id: CREATE_FAQ, pattern: 'Create new FAQ item(:faqName as FAQ item name)' },
{ id: ADD_NEWS, pattern: 'Add news item(:headline)' },
{ id: ADD_USER, pattern: 'Add new User(:userName as User name)' },
{ id: OPEN_SETTINGS, pattern: 'Go to Settings' },
{ id: HELP, pattern: 'Help' },
{ id: MORE_COMMANDS, pattern: 'More Commands...' },
];
const style = {
width: 800,
margin: 20
};
storiesOf('FindBar', module)
.add('Default View', () => (
<div style={style}>
<FindBar
commands={commands}
defaultCommands={[CREATE_POST, CREATE_COLLECTION, OPEN_SETTINGS, HELP, MORE_COMMANDS]}
runCommand={action}
/>
</div>
));

View File

@ -1,3 +1,4 @@
import './Card';
import './Icon';
import './Toast';
import './FindBar';