From 12f659372786c9b9dd3261b4663a4e840da64372 Mon Sep 17 00:00:00 2001 From: Harrison Healey Date: Tue, 31 May 2016 16:14:28 -0400 Subject: 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 --- .../components/suggestion/at_mention_provider.jsx | 20 +++------ webapp/components/suggestion/command_provider.jsx | 14 +++--- webapp/components/suggestion/emoticon_provider.jsx | 20 +++------ .../suggestion/search_channel_provider.jsx | 18 +++----- .../suggestion/search_suggestion_list.jsx | 4 +- .../components/suggestion/search_user_provider.jsx | 17 +++----- webapp/components/suggestion/suggestion.jsx | 28 ++++++++++++ webapp/components/suggestion/suggestion_box.jsx | 32 ++++++++++++-- webapp/components/suggestion/suggestion_list.jsx | 50 +++++++++++----------- 9 files changed, 117 insertions(+), 86 deletions(-) create mode 100644 webapp/components/suggestion/suggestion.jsx (limited to 'webapp/components') diff --git a/webapp/components/suggestion/at_mention_provider.jsx b/webapp/components/suggestion/at_mention_provider.jsx index 760f048bd..2e297a175 100644 --- a/webapp/components/suggestion/at_mention_provider.jsx +++ b/webapp/components/suggestion/at_mention_provider.jsx @@ -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 (
{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]); } } } diff --git a/webapp/components/suggestion/command_provider.jsx b/webapp/components/suggestion/command_provider.jsx index 36860fa66..73ae4deaa 100644 --- a/webapp/components/suggestion/command_provider.jsx +++ b/webapp/components/suggestion/command_provider.jsx @@ -1,11 +1,13 @@ // 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); } } } diff --git a/webapp/components/suggestion/emoticon_provider.jsx b/webapp/components/suggestion/emoticon_provider.jsx index b7f4cd513..a110796e1 100644 --- a/webapp/components/suggestion/emoticon_provider.jsx +++ b/webapp/components/suggestion/emoticon_provider.jsx @@ -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'; -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); diff --git a/webapp/components/suggestion/search_channel_provider.jsx b/webapp/components/suggestion/search_channel_provider.jsx index 2e3195c1d..2b8005204 100644 --- a/webapp/components/suggestion/search_channel_provider.jsx +++ b/webapp/components/suggestion/search_channel_provider.jsx @@ -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); } } } diff --git a/webapp/components/suggestion/search_suggestion_list.jsx b/webapp/components/suggestion/search_suggestion_list.jsx index 57aaee8ff..628f93af0 100644 --- a/webapp/components/suggestion/search_suggestion_list.jsx +++ b/webapp/components/suggestion/search_suggestion_list.jsx @@ -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} /> ); } diff --git a/webapp/components/suggestion/search_user_provider.jsx b/webapp/components/suggestion/search_user_provider.jsx index b7234469a..5a9e70d0f 100644 --- a/webapp/components/suggestion/search_user_provider.jsx +++ b/webapp/components/suggestion/search_user_provider.jsx @@ -1,13 +1,15 @@ // 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); } } } diff --git a/webapp/components/suggestion/suggestion.jsx b/webapp/components/suggestion/suggestion.jsx new file mode 100644 index 000000000..8547d50d0 --- /dev/null +++ b/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); + } +} \ No newline at end of file diff --git a/webapp/components/suggestion/suggestion_box.jsx b/webapp/components/suggestion/suggestion_box.jsx index 86d349a1a..f81cc6765 100644 --- a/webapp/components/suggestion/suggestion_box.jsx +++ b/webapp/components/suggestion/suggestion_box.jsx @@ -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 {
); } + + // 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 = { diff --git a/webapp/components/suggestion/suggestion_list.jsx b/webapp/components/suggestion/suggestion_list.jsx index 91f7443cb..7774f9a7d 100644 --- a/webapp/components/suggestion/suggestion_list.jsx +++ b/webapp/components/suggestion/suggestion_list.jsx @@ -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 { ); } -- cgit v1.2.3-1-g7c22