PLT-2643 Fixed asynchronous autocomplete incorrectly replacing text (#3167)

* Allowed different suggestions to match different text. Added a Suggestion base component. Improved text replacement used when filling in suggestions

* Fixed formatting
Этот коммит содержится в:
Harrison Healey
2016-05-31 16:14:28 -04:00
коммит произвёл Joram Wilander
родитель 6e6257fcca
Коммит 12f6593727
11 изменённых файлов: 168 добавлений и 113 удалений

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

@@ -1,20 +1,21 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React from 'react';
import SuggestionStore from 'stores/suggestion_store.jsx';
import UserStore from 'stores/user_store.jsx';
import * as Utils from 'utils/utils.jsx';
import Client from 'utils/web_client.jsx';
import {FormattedMessage} from 'react-intl';
import Suggestion from './suggestion.jsx';
const MaxUserSuggestions = 40;
import React from 'react';
class AtMentionSuggestion extends React.Component {
class AtMentionSuggestion extends Suggestion {
render() {
const {item, isSelection, onClick} = this.props;
const {item, isSelection} = this.props;
let username;
let description;
@@ -56,7 +57,7 @@ class AtMentionSuggestion extends React.Component {
return (
<div
className={className}
onClick={onClick}
onClick={this.handleClick}
>
<div className='pull-left'>
{icon}
@@ -74,12 +75,6 @@ class AtMentionSuggestion extends React.Component {
}
}
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);
@@ -112,8 +107,7 @@ export default class AtMentionProvider {
const mentions = filtered.map((user) => '@' + user.username);
SuggestionStore.setMatchedPretext(suggestionId, captured[0]);
SuggestionStore.addSuggestions(suggestionId, mentions, filtered, AtMentionSuggestion);
SuggestionStore.addSuggestions(suggestionId, mentions, filtered, AtMentionSuggestion, captured[0]);
}
}
}

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

@@ -1,11 +1,13 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import * as AsyncClient from 'utils/async_client.jsx';
import React from 'react';
class CommandSuggestion extends React.Component {
import * as AsyncClient from 'utils/async_client.jsx';
import Suggestion from './suggestion.jsx';
class CommandSuggestion extends Suggestion {
render() {
const {item, isSelection, onClick} = this.props;
@@ -30,16 +32,10 @@ class CommandSuggestion extends React.Component {
}
}
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('/')) {
AsyncClient.getSuggestedCommands(pretext, suggestionId, CommandSuggestion);
AsyncClient.getSuggestedCommands(pretext, suggestionId, CommandSuggestion, pretext);
}
}
}

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

@@ -1,14 +1,16 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import SuggestionStore from 'stores/suggestion_store.jsx';
import React from 'react';
import * as Emoticons from 'utils/emoticons.jsx';
import SuggestionStore from 'stores/suggestion_store.jsx';
import Suggestion from './suggestion.jsx';
const MAX_EMOTICON_SUGGESTIONS = 40;
import React from 'react';
class EmoticonSuggestion extends React.Component {
class EmoticonSuggestion extends Suggestion {
render() {
const text = this.props.term;
const emoticon = this.props.item;
@@ -39,13 +41,6 @@ class EmoticonSuggestion extends React.Component {
}
}
EmoticonSuggestion.propTypes = {
item: React.PropTypes.object.isRequired,
term: React.PropTypes.string.isRequired,
isSelection: React.PropTypes.bool,
onClick: React.PropTypes.func
};
export default class EmoticonProvider {
handlePretextChanged(suggestionId, pretext) {
const captured = (/(?:^|\s)(:([a-zA-Z0-9_+\-]*))$/g).exec(pretext);
@@ -82,8 +77,7 @@ export default class EmoticonProvider {
const terms = matched.map((emoticon) => ':' + emoticon.alias + ':');
if (terms.length > 0) {
SuggestionStore.setMatchedPretext(suggestionId, text);
SuggestionStore.addSuggestions(suggestionId, terms, matched, EmoticonSuggestion);
SuggestionStore.addSuggestions(suggestionId, terms, matched, EmoticonSuggestion, text);
// force the selection to be cleared since the order of elements may have changed
SuggestionStore.clearSelection(suggestionId);

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

@@ -1,13 +1,15 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React from 'react';
import ChannelStore from 'stores/channel_store.jsx';
import Constants from 'utils/constants.jsx';
import SuggestionStore from 'stores/suggestion_store.jsx';
import React from 'react';
import Suggestion from './suggestion.jsx';
class SearchChannelSuggestion extends React.Component {
class SearchChannelSuggestion extends Suggestion {
render() {
const {item, isSelection, onClick} = this.props;
@@ -27,12 +29,6 @@ class SearchChannelSuggestion extends React.Component {
}
}
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);
@@ -62,10 +58,8 @@ export default class SearchChannelProvider {
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);
SuggestionStore.addSuggestions(suggestionId, publicChannelNames, publicChannels, SearchChannelSuggestion, channelPrefix);
SuggestionStore.addSuggestions(suggestionId, privateChannelNames, privateChannels, SearchChannelSuggestion, channelPrefix);
}
}
}

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

@@ -72,8 +72,10 @@ export default class SearchSuggestionList extends SuggestionList {
key={term}
ref={term}
item={item}
term={term}
matchedPretext={this.state.matchedPretext[i]}
isSelection={isSelection}
onClick={this.handleItemClick.bind(this, term)}
onClick={this.handleItemClick}
/>
);
}

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

@@ -1,13 +1,15 @@
// 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 Client from 'utils/web_client.jsx';
import React from 'react';
class SearchUserSuggestion extends React.Component {
import Client from 'utils/web_client.jsx';
import SuggestionStore from 'stores/suggestion_store.jsx';
import UserStore from 'stores/user_store.jsx';
import Suggestion from './suggestion.jsx';
class SearchUserSuggestion extends Suggestion {
render() {
const {item, isSelection, onClick} = this.props;
@@ -31,12 +33,6 @@ class SearchUserSuggestion extends React.Component {
}
}
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);
@@ -58,8 +54,7 @@ export default class SearchUserProvider {
const usernames = filtered.map((user) => user.username);
SuggestionStore.setMatchedPretext(suggestionId, usernamePrefix);
SuggestionStore.addSuggestions(suggestionId, usernames, filtered, SearchUserSuggestion);
SuggestionStore.addSuggestions(suggestionId, usernames, filtered, SearchUserSuggestion, usernamePrefix);
}
}
}

28
webapp/components/suggestion/suggestion.jsx Обычный файл
Просмотреть файл

@@ -0,0 +1,28 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React from 'react';
export default class Suggestion extends React.Component {
static get propTypes() {
return {
item: React.PropTypes.object.isRequired,
term: React.PropTypes.string.isRequired,
matchedPretext: React.PropTypes.string.isRequired,
isSelection: React.PropTypes.bool,
onClick: React.PropTypes.func
};
}
constructor(props) {
super(props);
this.handleClick = this.handleClick.bind(this);
}
handleClick(e) {
e.preventDefault();
this.props.onClick(this.props.term, this.props.matchedPretext);
}
}

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

@@ -27,10 +27,10 @@ export default class SuggestionBox extends React.Component {
this.handlePretextChanged = this.handlePretextChanged.bind(this);
this.suggestionId = Utils.generateId();
SuggestionStore.registerSuggestionBox(this.suggestionId);
}
componentDidMount() {
SuggestionStore.registerSuggestionBox(this.suggestionId);
$(document).on('click', this.handleDocumentClick);
SuggestionStore.addCompleteWordListener(this.suggestionId, this.handleCompleteWord);
@@ -81,12 +81,24 @@ export default class SuggestionBox extends React.Component {
}
}
handleCompleteWord(term) {
handleCompleteWord(term, matchedPretext) {
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 pretext = text.substring(0, caret);
let prefix;
if (pretext.endsWith(matchedPretext)) {
prefix = pretext.substring(0, pretext.length - matchedPretext.length);
} else {
// the pretext has changed since we got a term to complete so see if the term still fits the pretext
const termWithoutMatched = term.substring(matchedPretext.length);
const overlap = SuggestionBox.findOverlap(pretext, termWithoutMatched);
prefix = pretext.substring(0, pretext.length - overlap.length - matchedPretext.length);
}
const suffix = text.substring(caret);
if (this.props.onUserInput) {
@@ -168,6 +180,20 @@ export default class SuggestionBox extends React.Component {
</div>
);
}
// Finds the longest substring that's at both the end of b and the start of a. For example,
// if a = "firepit" and b = "pitbull", findOverlap would return "pit".
static findOverlap(a, b) {
for (let i = b.length; i > 0; i--) {
const substring = b.substring(0, i);
if (a.endsWith(substring)) {
return substring;
}
}
return '';
}
}
SuggestionBox.defaultProps = {

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

@@ -12,6 +12,8 @@ export default class SuggestionList extends React.Component {
constructor(props) {
super(props);
this.getStateFromStores = this.getStateFromStores.bind(this);
this.getContent = this.getContent.bind(this);
this.handleItemClick = this.handleItemClick.bind(this);
@@ -19,11 +21,18 @@ export default class SuggestionList extends React.Component {
this.scrollToItem = this.scrollToItem.bind(this);
this.state = {
items: [],
terms: [],
components: [],
selection: ''
this.state = this.getStateFromStores(props.suggestionId);
}
getStateFromStores(suggestionId) {
const suggestions = SuggestionStore.getSuggestions(suggestionId || this.props.suggestionId);
return {
matchedPretext: suggestions.matchedPretext,
items: suggestions.items,
terms: suggestions.terms,
components: suggestions.components,
selection: suggestions.selection
};
}
@@ -31,6 +40,12 @@ export default class SuggestionList extends React.Component {
SuggestionStore.addSuggestionsChangedListener(this.props.suggestionId, this.handleSuggestionsChanged);
}
componentDidUpdate(prevProps, prevState) {
if (this.state.selection !== prevState.selection && this.state.selection) {
this.scrollToItem(this.state.selection);
}
}
componentWillUnmount() {
SuggestionStore.removeSuggestionsChangedListener(this.props.suggestionId, this.handleSuggestionsChanged);
}
@@ -39,25 +54,12 @@ export default class SuggestionList extends React.Component {
return $(ReactDOM.findDOMNode(this.refs.content));
}
handleItemClick(term, e) {
GlobalActions.emitCompleteWordSuggestion(this.props.suggestionId, term);
e.preventDefault();
handleItemClick(term, matchedPretext) {
GlobalActions.emitCompleteWordSuggestion(this.props.suggestionId, term, matchedPretext);
}
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));
}
this.setState(this.getStateFromStores());
}
scrollToItem(term) {
@@ -96,7 +98,6 @@ export default class SuggestionList extends React.Component {
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;
@@ -107,10 +108,11 @@ export default class SuggestionList extends React.Component {
<Component
key={term}
ref={term}
item={item}
item={this.state.items[i]}
term={term}
matchedPretext={this.state.matchedPretext[i]}
isSelection={isSelection}
onClick={this.handleItemClick.bind(this, term)}
onClick={this.handleItemClick}
/>
);
}