Moved autocomplete components into web/react/components/suggestion

Этот коммит содержится в:
hmhealey
2015-12-01 15:24:53 -05:00
родитель a5080ed6b1
Коммит 06e0919bca
9 изменённых файлов: 29 добавлений и 29 удалений

Просмотреть файл

@@ -0,0 +1,100 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import SuggestionStore from '../../stores/suggestion_store.jsx';
import UserStore from '../../stores/user_store.jsx';
import * as Utils from '../../utils/utils.jsx';
class AtMentionSuggestion extends React.Component {
render() {
const {item, isSelection, onClick} = this.props;
let username;
let description;
let icon;
if (item.username === 'all') {
username = 'all';
description = 'Notifies everyone in the team';
icon = <i className='mention-img fa fa-users fa-2x' />;
} else if (item.username === 'channel') {
username = 'channel';
description = 'Notifies everyone in the channel';
icon = <i className='mention-img fa fa-users fa-2x' />;
} else {
username = item.username;
description = Utils.getFullName(item);
icon = (
<img
className='mention-img'
src={'/api/v1/users/' + item.id + '/image?time=' + item.update_at + '&' + Utils.getSessionIndex()}
/>
);
}
let className = 'mentions-name';
if (isSelection) {
className += ' suggestion--selected';
}
return (
<div
className={className}
onClick={onClick}
>
<div className='pull-left'>
{icon}
</div>
<div className='pull-left mention-align'>
<span>
{'@' + username}
</span>
<span className='mention-fullname'>
{description}
</span>
</div>
</div>
);
}
}
AtMentionSuggestion.propTypes = {
item: React.PropTypes.object.isRequired,
isSelection: React.PropTypes.bool,
onClick: React.PropTypes.func
};
export default class AtMentionProvider {
handlePretextChanged(suggestionId, pretext) {
const captured = (/@([a-z0-9\-\._]*)$/i).exec(pretext);
if (captured) {
const usernamePrefix = captured[1];
const users = UserStore.getProfiles();
let filtered = [];
for (const id of Object.keys(users)) {
const user = users[id];
if (user.username.startsWith(usernamePrefix)) {
filtered.push(user);
}
}
// add dummy users to represent the @all and @channel special mentions
if ('all'.startsWith(usernamePrefix)) {
filtered.push({username: 'all'});
}
if ('channel'.startsWith(usernamePrefix)) {
filtered.push({username: 'channel'});
}
filtered = filtered.sort((a, b) => a.username.localeCompare(b.username));
const mentions = filtered.map((user) => '@' + user.username);
SuggestionStore.setMatchedPretext(suggestionId, captured[0]);
SuggestionStore.addSuggestions(suggestionId, mentions, filtered, AtMentionSuggestion);
}
}
}

Просмотреть файл

@@ -0,0 +1,68 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import AppDispatcher from '../../dispatcher/app_dispatcher.jsx';
import * as Client from '../../utils/client.jsx';
import Constants from '../../utils/constants.jsx';
import SuggestionStore from '../../stores/suggestion_store.jsx';
class CommandSuggestion extends React.Component {
render() {
const {item, isSelection, onClick} = this.props;
let className = 'command-name';
if (isSelection) {
className += ' suggestion--selected';
}
return (
<div
className={className}
onClick={onClick}
>
<div className='command__title'>
<string>{item.suggestion}</string>
</div>
<div className='command__desc'>
{item.description}
</div>
</div>
);
}
}
CommandSuggestion.propTypes = {
item: React.PropTypes.object.isRequired,
isSelection: React.PropTypes.bool,
onClick: React.PropTypes.func
};
export default class CommandProvider {
handlePretextChanged(suggestionId, pretext) {
if (pretext.startsWith('/')) {
SuggestionStore.setMatchedPretext(suggestionId, pretext);
Client.executeCommand(
'',
pretext,
true,
(data) => {
this.handleCommandsReceived(suggestionId, pretext, data.suggestions);
}
);
}
}
handleCommandsReceived(suggestionId, matchedPretext, commandSuggestions) {
const terms = commandSuggestions.map(({suggestion}) => suggestion);
AppDispatcher.handleServerAction({
type: Constants.ActionTypes.SUGGESTION_RECEIVED_SUGGESTIONS,
id: suggestionId,
matchedPretext,
terms,
items: commandSuggestions,
component: CommandSuggestion
});
}
}

Просмотреть файл

@@ -0,0 +1,69 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import ChannelStore from '../../stores/channel_store.jsx';
import Constants from '../../utils/constants.jsx';
import SuggestionStore from '../../stores/suggestion_store.jsx';
class SearchChannelSuggestion extends React.Component {
render() {
const {item, isSelection, onClick} = this.props;
let className = 'search-autocomplete__item';
if (isSelection) {
className += ' selected';
}
return (
<div
onClick={onClick}
className={className}
>
{item.name}
</div>
);
}
}
SearchChannelSuggestion.propTypes = {
item: React.PropTypes.object.isRequired,
isSelection: React.PropTypes.bool,
onClick: React.PropTypes.func
};
export default class SearchChannelProvider {
handlePretextChanged(suggestionId, pretext) {
const captured = (/\b(?:in|channel):\s*(\S*)$/i).exec(pretext);
if (captured) {
const channelPrefix = captured[1];
const channels = ChannelStore.getAll();
const publicChannels = [];
const privateChannels = [];
for (const id of Object.keys(channels)) {
const channel = channels[id];
// don't show direct channels
if (channel.type !== Constants.DM_CHANNEL && channel.name.startsWith(channelPrefix)) {
if (channel.type === Constants.OPEN_CHANNEL) {
publicChannels.push(channel);
} else {
privateChannels.push(channel);
}
}
}
publicChannels.sort((a, b) => a.name.localeCompare(b.name));
const publicChannelNames = publicChannels.map((channel) => channel.name);
privateChannels.sort((a, b) => a.name.localeCompare(b.name));
const privateChannelNames = privateChannels.map((channel) => channel.name);
SuggestionStore.setMatchedPretext(suggestionId, channelPrefix);
SuggestionStore.addSuggestions(suggestionId, publicChannelNames, publicChannels, SearchChannelSuggestion);
SuggestionStore.addSuggestions(suggestionId, privateChannelNames, privateChannels, SearchChannelSuggestion);
}
}
}

Просмотреть файл

@@ -0,0 +1,86 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import Constants from '../../utils/constants.jsx';
import SuggestionList from './suggestion_list.jsx';
import * as Utils from '../../utils/utils.jsx';
export default class SearchSuggestionList extends SuggestionList {
componentDidUpdate(prevProps, prevState) {
if (this.state.items.length > 0 && prevState.items.length === 0) {
this.getContent().perfectScrollbar();
}
}
getContent() {
return $(ReactDOM.findDOMNode(this.refs.popover)).find('.popover-content');
}
renderChannelDivider(type) {
let text;
if (type === Constants.OPEN_CHANNEL) {
text = 'Public ' + Utils.getChannelTerm(type) + 's';
} else {
text = 'Private ' + Utils.getChannelTerm(type) + 's';
}
return (
<div
key={type + '-divider'}
className='search-autocomplete__divider'
>
<span>{text}</span>
</div>
);
}
render() {
if (this.state.items.length === 0 || !this.props.show) {
return null;
}
const items = [];
for (let i = 0; i < this.state.items.length; i++) {
const item = this.state.items[i];
const term = this.state.terms[i];
const isSelection = term === this.state.selection;
// ReactComponent names need to be upper case when used in JSX
const Component = this.state.components[i];
// temporary hack to add dividers between public and private channels in the search suggestion list
if (i === 0 || item.type !== this.state.items[i - 1].type) {
if (item.type === Constants.OPEN_CHANNEL) {
items.push(this.renderChannelDivider(Constants.OPEN_CHANNEL));
} else if (item.type === Constants.PRIVATE_CHANNEL) {
items.push(this.renderChannelDivider(Constants.PRIVATE_CHANNEL));
}
}
items.push(
<Component
key={term}
ref={term}
item={item}
isSelection={isSelection}
onClick={this.handleItemClick.bind(this, term)}
/>
);
}
return (
<ReactBootstrap.Popover
ref='popover'
id='search-autocomplete__popover'
className='search-help-popover autocomplete visible'
placement='bottom'
>
{items}
</ReactBootstrap.Popover>
);
}
}
SearchSuggestionList.propTypes = {
...SuggestionList.propTypes
};

Просмотреть файл

@@ -0,0 +1,62 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import SuggestionStore from '../../stores/suggestion_store.jsx';
import UserStore from '../../stores/user_store.jsx';
class SearchUserSuggestion extends React.Component {
render() {
const {item, isSelection, onClick} = this.props;
let className = 'search-autocomplete__item';
if (isSelection) {
className += ' selected';
}
return (
<div
className={className}
onClick={onClick}
>
<img
className='profile-img rounded'
src={'/api/v1/users/' + item.id + '/image?time=' + item.update_at}
/>
{item.username}
</div>
);
}
}
SearchUserSuggestion.propTypes = {
item: React.PropTypes.object.isRequired,
isSelection: React.PropTypes.bool,
onClick: React.PropTypes.func
};
export default class SearchUserProvider {
handlePretextChanged(suggestionId, pretext) {
const captured = (/\bfrom:\s*(\S*)$/i).exec(pretext);
if (captured) {
const usernamePrefix = captured[1];
const users = UserStore.getProfiles();
let filtered = [];
for (const id of Object.keys(users)) {
const user = users[id];
if (user.username.startsWith(usernamePrefix)) {
filtered.push(user);
}
}
filtered = filtered.sort((a, b) => a.username.localeCompare(b.username));
const usernames = filtered.map((user) => user.username);
SuggestionStore.setMatchedPretext(suggestionId, usernamePrefix);
SuggestionStore.addSuggestions(suggestionId, usernames, filtered, SearchUserSuggestion);
}
}
}

Просмотреть файл

@@ -0,0 +1,203 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import AppDispatcher from '../../dispatcher/app_dispatcher.jsx';
import Constants from '../../utils/constants.jsx';
import SuggestionStore from '../../stores/suggestion_store.jsx';
import * as Utils from '../../utils/utils.jsx';
const ActionTypes = Constants.ActionTypes;
const KeyCodes = Constants.KeyCodes;
export default class SuggestionBox extends React.Component {
constructor(props) {
super(props);
this.handleDocumentClick = this.handleDocumentClick.bind(this);
this.handleFocus = this.handleFocus.bind(this);
this.handleChange = this.handleChange.bind(this);
this.handleCompleteWord = this.handleCompleteWord.bind(this);
this.handleKeyDown = this.handleKeyDown.bind(this);
this.handlePretextChanged = this.handlePretextChanged.bind(this);
this.suggestionId = Utils.generateId();
this.state = {
focused: false
};
}
componentDidMount() {
SuggestionStore.registerSuggestionBox(this.suggestionId);
$(document).on('click', this.handleDocumentClick);
SuggestionStore.addCompleteWordListener(this.suggestionId, this.handleCompleteWord);
SuggestionStore.addPretextChangedListener(this.suggestionId, this.handlePretextChanged);
}
componentWillUnmount() {
SuggestionStore.removeCompleteWordListener(this.suggestionId, this.handleCompleteWord);
SuggestionStore.removePretextChangedListener(this.suggestionId, this.handlePretextChanged);
SuggestionStore.unregisterSuggestionBox(this.suggestionId);
$(document).off('click', this.handleDocumentClick);
}
getTextbox() {
// this is to support old code that looks at the input/textarea DOM nodes
return ReactDOM.findDOMNode(this.refs.textbox);
}
handleDocumentClick(e) {
if (!this.state.focused) {
return;
}
const container = $(ReactDOM.findDOMNode(this));
if (!(container.is(e.target) || container.has(e.target).length > 0)) {
// we can't just use blur for this because it fires and hides the children before
// their click handlers can be called
this.setState({
focused: false
});
}
}
handleFocus() {
this.setState({
focused: true
});
if (this.props.onFocus) {
this.props.onFocus();
}
}
handleChange(e) {
const textbox = ReactDOM.findDOMNode(this.refs.textbox);
const caret = Utils.getCaretPosition(textbox);
const pretext = textbox.value.substring(0, caret);
AppDispatcher.handleViewAction({
type: ActionTypes.SUGGESTION_PRETEXT_CHANGED,
id: this.suggestionId,
pretext
});
if (this.props.onUserInput) {
this.props.onUserInput(textbox.value);
}
if (this.props.onChange) {
this.props.onChange(e);
}
}
handleCompleteWord(term) {
const textbox = ReactDOM.findDOMNode(this.refs.textbox);
const caret = Utils.getCaretPosition(textbox);
const text = this.props.value;
const prefix = text.substring(0, caret - SuggestionStore.getMatchedPretext(this.suggestionId).length);
const suffix = text.substring(caret);
if (this.props.onUserInput) {
this.props.onUserInput(prefix + term + ' ' + suffix);
}
// set the caret position after the next rendering
window.requestAnimationFrame(() => {
Utils.setCaretPosition(textbox, prefix.length + term.length + 1);
});
}
handleKeyDown(e) {
if (SuggestionStore.hasSuggestions(this.suggestionId)) {
if (e.which === KeyCodes.UP) {
AppDispatcher.handleViewAction({
type: ActionTypes.SUGGESTION_SELECT_PREVIOUS,
id: this.suggestionId
});
e.preventDefault();
} else if (e.which === KeyCodes.DOWN) {
AppDispatcher.handleViewAction({
type: ActionTypes.SUGGESTION_SELECT_NEXT,
id: this.suggestionId
});
e.preventDefault();
} else if (e.which === KeyCodes.SPACE || e.which === KeyCodes.ENTER) {
AppDispatcher.handleViewAction({
type: ActionTypes.SUGGESTION_COMPLETE_WORD,
id: this.suggestionId
});
e.preventDefault();
} else if (this.props.onKeyDown) {
this.props.onKeyDown(e);
}
} else if (this.props.onKeyDown) {
this.props.onKeyDown(e);
}
}
handlePretextChanged(pretext) {
for (const provider of this.props.providers) {
provider.handlePretextChanged(this.suggestionId, pretext);
}
}
render() {
const newProps = Object.assign({}, this.props, {
onFocus: this.handleFocus,
onChange: this.handleChange,
onKeyDown: this.handleKeyDown
});
let textbox = null;
if (this.props.type === 'input') {
textbox = (
<input
ref='textbox'
type='text'
{...newProps}
/>
);
} else if (this.props.type === 'textarea') {
textbox = (
<textarea
ref='textbox'
{...newProps}
/>
);
}
const SuggestionListComponent = this.props.listComponent;
return (
<div>
{textbox}
<SuggestionListComponent
suggestionId={this.suggestionId}
show={this.state.focused}
/>
</div>
);
}
}
SuggestionBox.defaultProps = {
type: 'input'
};
SuggestionBox.propTypes = {
listComponent: React.PropTypes.func.isRequired,
type: React.PropTypes.oneOf(['input', 'textarea']).isRequired,
value: React.PropTypes.string.isRequired,
onUserInput: React.PropTypes.func,
providers: React.PropTypes.arrayOf(React.PropTypes.object),
// explicitly name any input event handlers we override and need to manually call
onChange: React.PropTypes.func,
onKeyDown: React.PropTypes.func,
onFocus: React.PropTypes.func
};

Просмотреть файл

@@ -0,0 +1,130 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import AppDispatcher from '../../dispatcher/app_dispatcher.jsx';
import Constants from '../../utils/constants.jsx';
import SuggestionStore from '../../stores/suggestion_store.jsx';
export default class SuggestionList extends React.Component {
constructor(props) {
super(props);
this.getContent = this.getContent.bind(this);
this.handleItemClick = this.handleItemClick.bind(this);
this.handleSuggestionsChanged = this.handleSuggestionsChanged.bind(this);
this.scrollToItem = this.scrollToItem.bind(this);
this.state = {
items: [],
terms: [],
components: [],
selection: ''
};
}
componentDidMount() {
SuggestionStore.addSuggestionsChangedListener(this.props.suggestionId, this.handleSuggestionsChanged);
}
componentWillUnmount() {
SuggestionStore.removeSuggestionsChangedListener(this.props.suggestionId, this.handleSuggestionsChanged);
}
getContent() {
return $(ReactDOM.findDOMNode(this.refs.content));
}
handleItemClick(term, e) {
AppDispatcher.handleViewAction({
type: Constants.ActionTypes.SUGGESTION_COMPLETE_WORD,
id: this.props.suggestionId,
term
});
e.preventDefault();
}
handleSuggestionsChanged() {
const selection = SuggestionStore.getSelection(this.props.suggestionId);
this.setState({
items: SuggestionStore.getItems(this.props.suggestionId),
terms: SuggestionStore.getTerms(this.props.suggestionId),
components: SuggestionStore.getComponents(this.props.suggestionId),
selection
});
if (selection) {
window.requestAnimationFrame(() => this.scrollToItem(this.state.selection));
}
}
scrollToItem(term) {
const content = this.getContent();
const visibleContentHeight = content[0].clientHeight;
const actualContentHeight = content[0].scrollHeight;
if (visibleContentHeight < actualContentHeight) {
const contentTop = content.scrollTop();
const contentTopPadding = parseInt(content.css('padding-top'), 10);
const contentBottomPadding = parseInt(content.css('padding-top'), 10);
const item = $(ReactDOM.findDOMNode(this.refs[term]));
const itemTop = item[0].offsetTop - parseInt(item.css('margin-top'), 10);
const itemBottomMargin = parseInt(item.css('margin-bottom'), 10) + parseInt(item.css('padding-bottom'), 10);
const itemBottom = item[0].offsetTop + item.height() + itemBottomMargin;
if (itemTop - contentTopPadding < contentTop) {
// the item is off the top of the visible space
content.scrollTop(itemTop - contentTopPadding);
} else if (itemBottom + contentTopPadding + contentBottomPadding > contentTop + visibleContentHeight) {
// the item has gone off the bottom of the visible space
content.scrollTop(itemBottom - visibleContentHeight + contentTopPadding + contentBottomPadding);
}
}
}
render() {
if (this.state.items.length === 0 || !this.props.show) {
return null;
}
const items = [];
for (let i = 0; i < this.state.items.length; i++) {
const item = this.state.items[i];
const term = this.state.terms[i];
const isSelection = term === this.state.selection;
// ReactComponent names need to be upper case when used in JSX
const Component = this.state.components[i];
items.push(
<Component
key={term}
ref={term}
item={item}
isSelection={isSelection}
onClick={this.handleItemClick.bind(this, term)}
/>
);
}
return (
<div className='suggestion-list suggestion-list--top'>
<div
ref='content'
className='suggestion-content suggestion-content--top'
>
{items}
</div>
</div>
);
}
}
SuggestionList.propTypes = {
suggestionId: React.PropTypes.string.isRequired,
show: React.PropTypes.bool.isRequired
};