Commit 12f65937 authored by Harrison Healey's avatar Harrison Healey Committed by Joram Wilander
Browse files

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
parent 6e6257fc
// 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]);
}
}
}
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React from 'react';
import * as AsyncClient from 'utils/async_client.jsx';
import React from 'react';
import Suggestion from './suggestion.jsx';
class CommandSuggestion extends React.Component {
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);
}
}
}
// 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';
const MAX_EMOTICON_SUGGESTIONS = 40;
import Suggestion from './suggestion.jsx';
import React from 'react';
const MAX_EMOTICON_SUGGESTIONS = 40;
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);
......
// 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}
/>
);
}
......
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React from 'react';
import Client from 'utils/web_client.jsx';
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';
import Suggestion from './suggestion.jsx';
class SearchUserSuggestion extends React.Component {
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);
}
}
}
// 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);
}
}
\ No newline at end of file
......@@ -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}
/>
);
}
......
......@@ -33,7 +33,7 @@ class SuggestionStore extends EventEmitter {
// this.suggestions stores the state of all SuggestionBoxes by mapping their unique identifier to an
// object with the following fields:
// pretext: the text before the cursor
// matchedPretext: the text before the cursor that will be replaced if an autocomplete term is selected
// matchedPretext: a list of the text before the cursor that will be replaced if the corresponding autocomplete term is selected
// terms: a list of strings which the previously typed text may be replaced by
// items: a list of objects backing the terms which may be used in rendering
// components: a list of react components that can be used to render their corresponding item
......@@ -67,14 +67,14 @@ class SuggestionStore extends EventEmitter {
removeCompleteWordListener(id, callback) {
this.removeListener(COMPLETE_WORD_EVENT + id, callback);
}
emitCompleteWord(id, term) {
this.emit(COMPLETE_WORD_EVENT + id, term);
emitCompleteWord(id, term, matchedPretext) {
this.emit(COMPLETE_WORD_EVENT + id, term, matchedPretext);
}
registerSuggestionBox(id) {
this.suggestions.set(id, {
pretext: '',
matchedPretext: '',
matchedPretext: [],
terms: [],
items: [],
components: [],
......@@ -89,7 +89,7 @@ class SuggestionStore extends EventEmitter {
clearSuggestions(id) {
const suggestion = this.suggestions.get(id);
suggestion.matchedPretext = '';
suggestion.matchedPretext = [];
suggestion.terms = [];
suggestion.items = [];
suggestion.components = [];
......@@ -111,21 +111,16 @@ class SuggestionStore extends EventEmitter {
suggestion.pretext = pretext;
}
setMatchedPretext(id, matchedPretext) {
const suggestion = this.suggestions.get(id);
suggestion.matchedPretext = matchedPretext;
}
addSuggestion(id, term, item, component) {
addSuggestion(id, term, item, component, matchedPretext) {
const suggestion = this.suggestions.get(id);
suggestion.terms.push(term);
suggestion.items.push(item);
suggestion.components.push(component);
suggestion.matchedPretext.push(matchedPretext);
}
addSuggestions(id, terms, items, component) {
addSuggestions(id, terms, items, component, matchedPretext) {
const suggestion = this.suggestions.get(id);
suggestion.terms.push(...terms);
......@@ -133,6 +128,7 @@ class SuggestionStore extends EventEmitter {
for (let i = 0; i < terms.length; i++) {
suggestion.components.push(component);
suggestion.matchedPretext.push(matchedPretext);
}
}
......@@ -160,8 +156,16 @@ class SuggestionStore extends EventEmitter {
return this.suggestions.get(id).pretext;
}
getMatchedPretext(id) {
return this.suggestions.get(id).matchedPretext;
getSelectedMatchedPretext(id) {
const suggestion = this.suggestions.get(id);
for (let i = 0; i < suggestion.terms.length; i++) {
if (suggestion.terms[i] === suggestion.selection) {
return suggestion.matchedPretext[i];
}
}
return '';
}
getItems(id) {
......@@ -176,6 +180,10 @@ class SuggestionStore extends EventEmitter {
return this.suggestions.get(id).components;
}
getSuggestions(id) {
return this.suggestions.get(id);
}
getSelection(id) {
return this.suggestions.get(id).selection;
}
......@@ -223,15 +231,11 @@ class SuggestionStore extends EventEmitter {
this.emitSuggestionsChanged(id);
break;
case ActionTypes.SUGGESTION_RECEIVED_SUGGESTIONS:
if (this.getMatchedPretext(id) === '') {
this.setMatchedPretext(id, other.matchedPretext);
// ensure the matched pretext hasn't changed so that we don't receive suggestions for outdated pretext
this.addSuggestions(id, other.terms, other.items, other.component);
// ensure the matched pretext hasn't changed so that we don't receive suggestions for outdated pretext
this.addSuggestions(id, other.terms, other.items, other.component, other.matchedPretext);
this.ensureSelectionExists(id);
this.emitSuggestionsChanged(id);
}
this.ensureSelectionExists(id);
this.emitSuggestionsChanged(id);
break;
case ActionTypes.SUGGESTION_CLEAR_SUGGESTIONS:
this.clearSuggestions(id);
......@@ -247,7 +251,7 @@ class SuggestionStore extends EventEmitter {
this.emitSuggestionsChanged(id);
break;
case ActionTypes.SUGGESTION_COMPLETE_WORD:
this.emitCompleteWord(id, other.term || this.getSelection(id), this.getMatchedPretext(id));
this.emitCompleteWord(id, other.term || this.getSelection(id), other.matchedPretext || this.getSelectedMatchedPretext(id));
this.setPretext(id, '');
this.clearSuggestions(id);
......
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import assert from 'assert';
import SuggestionBox from 'components/suggestion/suggestion_box.jsx';
describe('SuggestionBox', function() {
it('findOverlap', function(done) {
assert.equal(SuggestionBox.findOverlap('', 'blue'), '');
assert.equal(SuggestionBox.findOverlap('red', ''), '');
assert.equal(SuggestionBox.findOverlap('red', 'blue'), '');
assert.equal(SuggestionBox.findOverlap('red', 'dog'), 'd');
assert.equal(SuggestionBox.findOverlap('red', 'education'), 'ed');
assert.equal(SuggestionBox.findOverlap('red', 'reduce'), 'red');
assert.equal(SuggestionBox.findOverlap('black', 'ack'), 'ack');
done();
});
});
\ No newline at end of file
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment