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
Этот коммит содержится в:
коммит произвёл
Joram Wilander
родитель
6e6257fcca
Коммит
12f6593727
@@ -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
Обычный файл
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Ссылка в новой задаче
Block a user