summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--webapp/components/suggestion/at_mention_provider.jsx20
-rw-r--r--webapp/components/suggestion/command_provider.jsx14
-rw-r--r--webapp/components/suggestion/emoticon_provider.jsx20
-rw-r--r--webapp/components/suggestion/search_channel_provider.jsx18
-rw-r--r--webapp/components/suggestion/search_suggestion_list.jsx4
-rw-r--r--webapp/components/suggestion/search_user_provider.jsx17
-rw-r--r--webapp/components/suggestion/suggestion.jsx28
-rw-r--r--webapp/components/suggestion/suggestion_box.jsx32
-rw-r--r--webapp/components/suggestion/suggestion_list.jsx50
-rw-r--r--webapp/stores/suggestion_store.jsx52
-rw-r--r--webapp/tests/suggestion_box.test.jsx20
11 files changed, 165 insertions, 110 deletions
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 (
<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]);
}
}
}
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 {
</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 = {
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 {
<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}
/>
);
}
diff --git a/webapp/stores/suggestion_store.jsx b/webapp/stores/suggestion_store.jsx
index eeb09fa9e..c59c26a66 100644
--- a/webapp/stores/suggestion_store.jsx
+++ b/webapp/stores/suggestion_store.jsx
@@ -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);
diff --git a/webapp/tests/suggestion_box.test.jsx b/webapp/tests/suggestion_box.test.jsx
new file mode 100644
index 000000000..6301482c2
--- /dev/null
+++ b/webapp/tests/suggestion_box.test.jsx
@@ -0,0 +1,20 @@
+// 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