Replaced SearchAutocomplete with new suggestion components

This commit is contained in:
hmhealey
2015-11-24 12:30:12 -05:00
parent c8f642a499
commit ac762f2277
9 changed files with 726 additions and 379 deletions

View File

@@ -1,341 +0,0 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import ChannelStore from '../stores/channel_store.jsx';
import Constants from '../utils/constants.jsx';
const KeyCodes = Constants.KeyCodes;
const Popover = ReactBootstrap.Popover;
import UserStore from '../stores/user_store.jsx';
import * as Utils from '../utils/utils.jsx';
const patterns = new Map([
['channels', /\b(?:in|channel):\s*(\S*)$/i],
['users', /\bfrom:\s*(\S*)$/i]
]);
export default class SearchAutocomplete extends React.Component {
constructor(props) {
super(props);
this.handleClick = this.handleClick.bind(this);
this.handleDocumentClick = this.handleDocumentClick.bind(this);
this.handleInputChange = this.handleInputChange.bind(this);
this.handleKeyDown = this.handleKeyDown.bind(this);
this.completeWord = this.completeWord.bind(this);
this.getSelection = this.getSelection.bind(this);
this.scrollToItem = this.scrollToItem.bind(this);
this.updateSuggestions = this.updateSuggestions.bind(this);
this.renderChannelSuggestion = this.renderChannelSuggestion.bind(this);
this.renderUserSuggestion = this.renderUserSuggestion.bind(this);
this.state = {
show: false,
mode: '',
filter: '',
selection: 0,
suggestions: new Map()
};
}
componentDidMount() {
$(document).on('click', this.handleDocumentClick);
}
componentDidUpdate(prevProps, prevState) {
const content = $(ReactDOM.findDOMNode(this.refs.searchPopover)).find('.popover-content');
if (this.state.show && this.state.suggestions.length > 0) {
if (!prevState.show) {
content.perfectScrollbar();
content.css('max-height', $(window).height() - 200);
}
// keep the keyboard selection visible when scrolling
this.scrollToItem(this.getSelection());
}
}
componentWillUnmount() {
$(document).off('click', this.handleDocumentClick);
}
handleClick(value) {
this.completeWord(value);
}
handleDocumentClick(e) {
const container = $(ReactDOM.findDOMNode(this.refs.searchPopover));
if (!(container.is(e.target) || container.has(e.target).length > 0)) {
this.setState({
show: false
});
}
}
handleInputChange(textbox, text) {
const caret = Utils.getCaretPosition(textbox);
const preText = text.substring(0, caret);
let mode = '';
let filter = '';
for (const [modeForPattern, pattern] of patterns) {
const result = pattern.exec(preText);
if (result) {
mode = modeForPattern;
filter = result[1];
break;
}
}
if (mode !== this.state.mode || filter !== this.state.filter) {
this.updateSuggestions(mode, filter);
}
this.setState({
mode,
filter,
show: mode || filter
});
}
handleKeyDown(e) {
if (!this.state.show || this.state.suggestions.length === 0) {
return;
}
if (e.which === KeyCodes.UP || e.which === KeyCodes.DOWN) {
e.preventDefault();
let selection = this.state.selection;
if (e.which === KeyCodes.UP) {
selection -= 1;
} else {
selection += 1;
}
if (selection >= 0 && selection < this.state.suggestions.length) {
this.setState({
selection
});
}
} else if (e.which === KeyCodes.ENTER || e.which === KeyCodes.SPACE) {
e.preventDefault();
this.completeWord(this.getSelection());
}
}
completeWord(value) {
// add a space so that anything else typed doesn't interfere with the search flag
this.props.completeWord(this.state.filter, value + ' ');
this.setState({
show: false,
mode: '',
filter: '',
selection: 0
});
}
getSelection() {
if (this.state.suggestions.length > 0) {
if (this.state.mode === 'channels') {
return this.state.suggestions[this.state.selection].name;
} else if (this.state.mode === 'users') {
return this.state.suggestions[this.state.selection].username;
}
}
return '';
}
scrollToItem(itemName) {
const content = $(ReactDOM.findDOMNode(this.refs.searchPopover)).find('.popover-content');
const visibleContentHeight = content[0].clientHeight;
const actualContentHeight = content[0].scrollHeight;
if (this.state.suggestions.length > 0 && visibleContentHeight < actualContentHeight) {
const contentTop = content.scrollTop();
const contentTopPadding = parseInt(content.css('padding-top'), 10);
const contentBottomPadding = parseInt(content.css('padding-top'), 10);
const item = $(this.refs[itemName]);
const itemTop = item[0].offsetTop - parseInt(item.css('margin-top'), 10);
const itemBottom = item[0].offsetTop + item.height() + parseInt(item.css('margin-bottom'), 10);
if (itemTop - contentTopPadding < contentTop) {
// the item is off the top of the visible space
content.scrollTop(itemTop - contentTopPadding);
} else if (itemBottom + contentTopPadding + contentBottomPadding > contentTop + visibleContentHeight) {
// the item has gone off the bottom of the visible space
content.scrollTop(itemBottom - visibleContentHeight + contentTopPadding + contentBottomPadding);
}
}
}
updateSuggestions(mode, filter) {
let suggestions = [];
if (mode === 'channels') {
let channels = ChannelStore.getAll();
if (filter) {
channels = channels.filter((channel) => channel.name.startsWith(filter) && channel.type !== 'D');
} else {
// don't show direct channels
channels = channels.filter((channel) => channel.type !== 'D');
}
channels.sort((a, b) => {
// put public channels first and then sort alphabebetically
if (a.type === b.type) {
return a.name.localeCompare(b.name);
} else if (a.type === Constants.OPEN_CHANNEL) {
return -1;
}
return 1;
});
suggestions = channels;
} else if (mode === 'users') {
let users = UserStore.getActiveOnlyProfileList();
if (filter) {
users = users.filter((user) => user.username.startsWith(filter));
}
users.sort((a, b) => a.username.localeCompare(b.username));
suggestions = users;
}
let selection = this.state.selection;
// keep the same user/channel selected if it's still visible as a suggestion
if (selection > 0 && this.state.suggestions.length > 0) {
// we can't just use indexOf to find if the selection is still in the list since they are different javascript objects
const currentSelectionId = this.state.suggestions[selection].id;
let found = false;
for (let i = 0; i < suggestions.length; i++) {
if (suggestions[i].id === currentSelectionId) {
selection = i;
found = true;
break;
}
}
if (!found) {
selection = 0;
}
} else {
selection = 0;
}
this.setState({
suggestions,
selection
});
}
renderChannelSuggestion(channel) {
let className = 'search-autocomplete__item';
if (channel.name === this.getSelection()) {
className += ' selected';
}
return (
<div
key={channel.name}
ref={channel.name}
onClick={this.handleClick.bind(this, channel.name)}
className={className}
>
{channel.name}
</div>
);
}
renderUserSuggestion(user) {
let className = 'search-autocomplete__item';
if (user.username === this.getSelection()) {
className += ' selected';
}
return (
<div
key={user.username}
ref={user.username}
onClick={this.handleClick.bind(this, user.username)}
className={className}
>
<img
className='profile-img rounded'
src={'/api/v1/users/' + user.id + '/image?time=' + user.update_at}
/>
{user.username}
</div>
);
}
render() {
if (!this.state.show || this.state.suggestions.length === 0) {
return null;
}
let suggestions = [];
if (this.state.mode === 'channels') {
const publicChannels = this.state.suggestions.filter((channel) => channel.type === Constants.OPEN_CHANNEL);
if (publicChannels.length > 0) {
suggestions.push(
<div
key='public-channel-divider'
className='search-autocomplete__divider'
>
<span>{'Public ' + Utils.getChannelTerm(Constants.OPEN_CHANNEL) + 's'}</span>
</div>
);
suggestions = suggestions.concat(publicChannels.map(this.renderChannelSuggestion));
}
const privateChannels = this.state.suggestions.filter((channel) => channel.type === Constants.PRIVATE_CHANNEL);
if (privateChannels.length > 0) {
suggestions.push(
<div
key='private-channel-divider'
className='search-autocomplete__divider'
>
<span>{'Private ' + Utils.getChannelTerm(Constants.PRIVATE_CHANNEL) + 's'}</span>
</div>
);
suggestions = suggestions.concat(privateChannels.map(this.renderChannelSuggestion));
}
} else if (this.state.mode === 'users') {
suggestions = this.state.suggestions.map(this.renderUserSuggestion);
}
return (
<Popover
ref='searchPopover'
onShow={this.componentDidMount}
id='search-autocomplete__popover'
className='search-help-popover autocomplete visible'
placement='bottom'
>
{suggestions}
</Popover>
);
}
}
SearchAutocomplete.propTypes = {
completeWord: React.PropTypes.func.isRequired
};

View File

@@ -5,11 +5,13 @@ import * as client from '../utils/client.jsx';
import * as AsyncClient from '../utils/async_client.jsx';
import SearchStore from '../stores/search_store.jsx';
import AppDispatcher from '../dispatcher/app_dispatcher.jsx';
import SuggestionBox from '../components/suggestion_box.jsx';
import SearchChannelProvider from '../components/search_channel_provider.jsx';
import SearchUserProvider from '../components/search_user_provider.jsx';
import * as utils from '../utils/utils.jsx';
import Constants from '../utils/constants.jsx';
var ActionTypes = Constants.ActionTypes;
var Popover = ReactBootstrap.Popover;
import SearchAutocomplete from './search_autocomplete.jsx';
export default class SearchBar extends React.Component {
constructor() {
@@ -17,13 +19,11 @@ export default class SearchBar extends React.Component {
this.mounted = false;
this.onListenerChange = this.onListenerChange.bind(this);
this.handleKeyDown = this.handleKeyDown.bind(this);
this.handleUserInput = this.handleUserInput.bind(this);
this.handleUserFocus = this.handleUserFocus.bind(this);
this.handleUserBlur = this.handleUserBlur.bind(this);
this.performSearch = this.performSearch.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
this.completeWord = this.completeWord.bind(this);
const state = this.getSearchTermStateFromStores();
state.focused = false;
@@ -77,18 +77,11 @@ export default class SearchBar extends React.Component {
results: null
});
}
handleKeyDown(e) {
if (this.refs.autocomplete) {
this.refs.autocomplete.handleKeyDown(e);
}
}
handleUserInput(e) {
var term = e.target.value;
handleUserInput(text) {
var term = text;
SearchStore.storeSearchTerm(term);
SearchStore.emitSearchTermChange(false);
this.setState({searchTerm: term});
this.refs.autocomplete.handleInputChange(e.target, term);
}
handleUserBlur() {
this.setState({focused: false});
@@ -128,23 +121,6 @@ export default class SearchBar extends React.Component {
this.performSearch(this.state.searchTerm.trim());
}
completeWord(partialWord, word) {
const textbox = ReactDOM.findDOMNode(this.refs.search);
let text = textbox.value;
const caret = utils.getCaretPosition(textbox);
const preText = text.substring(0, caret - partialWord.length);
const postText = text.substring(caret);
text = preText + word + postText;
textbox.value = text;
utils.setCaretPosition(textbox, preText.length + word.length);
SearchStore.storeSearchTerm(text);
SearchStore.emitSearchTermChange(false);
this.setState({searchTerm: text});
}
render() {
var isSearching = null;
if (this.state.isSearching) {
@@ -178,22 +154,17 @@ export default class SearchBar extends React.Component {
autoComplete='off'
>
<span className='glyphicon glyphicon-search sidebar__search-icon' />
<input
type='text'
<SuggestionBox
ref='search'
className='form-control search-bar'
placeholder='Search'
value={this.state.searchTerm}
onFocus={this.handleUserFocus}
onBlur={this.handleUserBlur}
onChange={this.handleUserInput}
onKeyDown={this.handleKeyDown}
onUserInput={this.handleUserInput}
providers={[SearchChannelProvider, SearchUserProvider]}
/>
{isSearching}
<SearchAutocomplete
ref='autocomplete'
completeWord={this.completeWord}
/>
<Popover
id='searchbar-help-popup'
placement='bottom'

View File

@@ -0,0 +1,71 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import ChannelStore from '../stores/channel_store.jsx';
import Constants from '../utils/constants.jsx';
import SuggestionStore from '../stores/suggestion_store.jsx';
class SearchChannelSuggestion extends React.Component {
render() {
const {item, isSelection, onClick} = this.props;
let className = 'search-autocomplete__item';
if (isSelection) {
className += ' selected';
}
return (
<div
onClick={onClick}
className={className}
>
{item.name}
</div>
);
}
}
SearchChannelSuggestion.propTypes = {
item: React.PropTypes.object.isRequired,
isSelection: React.PropTypes.bool,
onClick: React.PropTypes.func
};
class SearchChannelProvider {
handlePretextChanged(suggestionId, pretext) {
const captured = (/\b(?:in|channel):\s*(\S*)$/i).exec(pretext);
if (captured) {
const channelPrefix = captured[1];
const channels = ChannelStore.getAll();
const publicChannels = [];
const privateChannels = [];
for (const id of Object.keys(channels)) {
const channel = channels[id];
// don't show direct channels
if (channel.type !== Constants.DM_CHANNEL && channel.name.startsWith(channelPrefix)) {
if (channel.type === Constants.OPEN_CHANNEL) {
publicChannels.push(channel);
} else {
privateChannels.push(channel);
}
}
}
publicChannels.sort((a, b) => a.name.localeCompare(b.name));
const publicChannelNames = publicChannels.map((channel) => channel.name);
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);
}
}
}
export default new SearchChannelProvider();

View File

@@ -0,0 +1,64 @@
// 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';
class SearchUserSuggestion extends React.Component {
render() {
const {item, isSelection, onClick} = this.props;
let className = 'search-autocomplete__item';
if (isSelection) {
className += ' selected';
}
return (
<div
className={className}
onClick={onClick}
>
<img
className='profile-img rounded'
src={'/api/v1/users/' + item.id + '/image?time=' + item.update_at}
/>
{item.username}
</div>
);
}
}
SearchUserSuggestion.propTypes = {
item: React.PropTypes.object.isRequired,
isSelection: React.PropTypes.bool,
onClick: React.PropTypes.func
};
class SearchUserProvider {
handlePretextChanged(suggestionId, pretext) {
const captured = (/\bfrom:\s*(\S*)$/i).exec(pretext);
if (captured) {
const usernamePrefix = captured[1];
const users = UserStore.getProfiles();
let filtered = [];
for (const id of Object.keys(users)) {
const user = users[id];
if (user.username.startsWith(usernamePrefix)) {
filtered.push(user);
}
}
filtered = filtered.sort((a, b) => a.username.localeCompare(b.username));
const usernames = filtered.map((user) => user.username);
SuggestionStore.setMatchedPretext(suggestionId, usernamePrefix);
SuggestionStore.addSuggestions(suggestionId, usernames, filtered, SearchUserSuggestion);
}
}
}
export default new SearchUserProvider();

View File

@@ -0,0 +1,170 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import AppDispatcher from '../dispatcher/app_dispatcher.jsx';
import Constants from '../utils/constants.jsx';
import SuggestionList from './suggestion_list.jsx';
import SuggestionStore from '../stores/suggestion_store.jsx';
import * as Utils from '../utils/utils.jsx';
const ActionTypes = Constants.ActionTypes;
const KeyCodes = Constants.KeyCodes;
export default class SuggestionBox extends React.Component {
constructor(props) {
super(props);
this.handleDocumentClick = this.handleDocumentClick.bind(this);
this.handleFocus = this.handleFocus.bind(this);
this.handleChange = this.handleChange.bind(this);
this.handleCompleteWord = this.handleCompleteWord.bind(this);
this.handleKeyDown = this.handleKeyDown.bind(this);
this.handlePretextChanged = this.handlePretextChanged.bind(this);
this.suggestionId = Utils.generateId();
this.state = {
focused: false
};
}
componentDidMount() {
SuggestionStore.registerSuggestionBox(this.suggestionId);
$(document).on('click', this.handleDocumentClick);
SuggestionStore.addCompleteWordListener(this.suggestionId, this.handleCompleteWord);
SuggestionStore.addPretextChangedListener(this.suggestionId, this.handlePretextChanged);
}
componentWillUnmount() {
SuggestionStore.removeCompleteWordListener(this.suggestionId, this.handleCompleteWord);
SuggestionStore.removePretextChangedListener(this.suggestionId, this.handlePretextChanged);
SuggestionStore.unregisterSuggestionBox(this.suggestionId);
$(document).off('click', this.handleDocumentClick);
}
handleDocumentClick(e) {
if (!this.state.focused) {
return;
}
const container = $(ReactDOM.findDOMNode(this));
if (!(container.is(e.target) || container.has(e.target).length > 0)) {
// we can't just use blur for this because it fires and hides the children before
// their click handlers can be called
this.setState({
focused: false
});
}
}
handleFocus() {
this.setState({
focused: true
});
if (this.props.onFocus) {
this.props.onFocus();
}
}
handleChange(e) {
const textbox = ReactDOM.findDOMNode(this.refs.textbox);
const caret = Utils.getCaretPosition(textbox);
const pretext = textbox.value.substring(0, caret);
AppDispatcher.handleViewAction({
type: ActionTypes.SUGGESTION_PRETEXT_CHANGED,
id: this.suggestionId,
pretext
});
if (this.props.onUserInput) {
this.props.onUserInput(textbox.value);
}
if (this.props.onChange) {
this.props.onChange(e);
}
}
handleCompleteWord(term) {
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 suffix = text.substring(caret);
if (this.props.onUserInput) {
this.props.onUserInput(prefix + term + ' ' + suffix);
}
// set the caret position after the next rendering
window.requestAnimationFrame(() => {
Utils.setCaretPosition(textbox, prefix.length + term.length + 1);
});
}
handleKeyDown(e) {
if (e.which === KeyCodes.UP) {
AppDispatcher.handleViewAction({
type: ActionTypes.SUGGESTION_SELECT_PREVIOUS,
id: this.suggestionId
});
e.preventDefault();
} else if (e.which === KeyCodes.DOWN) {
AppDispatcher.handleViewAction({
type: ActionTypes.SUGGESTION_SELECT_NEXT,
id: this.suggestionId
});
e.preventDefault();
} else if ((e.which === KeyCodes.SPACE || e.which === KeyCodes.ENTER) && SuggestionStore.hasSuggestions(this.suggestionId)) {
AppDispatcher.handleViewAction({
type: ActionTypes.SUGGESTION_COMPLETE_WORD,
id: this.suggestionId
});
e.preventDefault();
} else if (this.props.onKeyDown) {
this.props.onKeyDown(e);
}
}
handlePretextChanged(pretext) {
for (const provider of this.props.providers) {
provider.handlePretextChanged(this.suggestionId, pretext);
}
}
render() {
const newProps = Object.assign({}, this.props, {
onFocus: this.handleFocus,
onChange: this.handleChange,
onKeyDown: this.handleKeyDown
});
return (
<div>
<input
ref='textbox'
type='text'
{...newProps}
/>
<SuggestionList suggestionId={this.suggestionId} />
</div>
);
}
}
SuggestionBox.propTypes = {
value: React.PropTypes.string.isRequired,
onUserInput: React.PropTypes.func,
providers: React.PropTypes.arrayOf(React.PropTypes.object),
// explicitly name any input event handlers we override and need to manually call
onChange: React.PropTypes.func,
onKeyDown: React.PropTypes.func,
onFocus: React.PropTypes.func
};

View File

@@ -0,0 +1,157 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import AppDispatcher from '../dispatcher/app_dispatcher.jsx';
import Constants from '../utils/constants.jsx';
import SuggestionStore from '../stores/suggestion_store.jsx';
import * as Utils from '../utils/utils.jsx';
export default class SuggestionList extends React.Component {
constructor(props) {
super(props);
this.handleItemClick = this.handleItemClick.bind(this);
this.handleSuggestionsChanged = this.handleSuggestionsChanged.bind(this);
this.scrollToItem = this.scrollToItem.bind(this);
this.state = {
items: [],
terms: [],
components: [],
selection: ''
};
}
componentDidMount() {
SuggestionStore.addSuggestionsChangedListener(this.props.suggestionId, this.handleSuggestionsChanged);
}
componentDidUpdate(prevProps, prevState) {
if (this.state.items.length > 0 && prevState.items.length === 0) {
const content = $(ReactDOM.findDOMNode(this.refs.popover)).find('.popover-content');
content.perfectScrollbar();
}
}
componentWillUnmount() {
SuggestionStore.removeSuggestionsChangedListener(this.props.suggestionId, this.handleSuggestionsChanged);
}
handleItemClick(term, e) {
AppDispatcher.handleViewAction({
type: Constants.ActionTypes.SUGGESTION_COMPLETE_WORD,
id: this.props.suggestionId,
term
});
e.preventDefault();
}
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));
}
}
scrollToItem(term) {
const content = $(ReactDOM.findDOMNode(this.refs.popover)).find('.popover-content');
const visibleContentHeight = content[0].clientHeight;
const actualContentHeight = content[0].scrollHeight;
if (visibleContentHeight < actualContentHeight) {
const contentTop = content.scrollTop();
const contentTopPadding = parseInt(content.css('padding-top'), 10);
const contentBottomPadding = parseInt(content.css('padding-top'), 10);
const item = $(ReactDOM.findDOMNode(this.refs[term]));
const itemTop = item[0].offsetTop - parseInt(item.css('margin-top'), 10);
const itemBottom = item[0].offsetTop + item.height() + parseInt(item.css('margin-bottom'), 10);
if (itemTop - contentTopPadding < contentTop) {
// the item is off the top of the visible space
content.scrollTop(itemTop - contentTopPadding);
} else if (itemBottom + contentTopPadding + contentBottomPadding > contentTop + visibleContentHeight) {
// the item has gone off the bottom of the visible space
content.scrollTop(itemBottom - visibleContentHeight + contentTopPadding + contentBottomPadding);
}
}
}
renderChannelDivider(type) {
let text;
if (type === Constants.OPEN_CHANNEL) {
text = 'Public ' + Utils.getChannelTerm(type) + 's';
} else {
text = 'Private ' + Utils.getChannelTerm(type) + 's';
}
return (
<div
key={type + '-divider'}
className='search-autocomplete__divider'
>
<span>{text}</span>
</div>
);
}
render() {
if (this.state.items.length === 0) {
return null;
}
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;
// ReactComponent names need to be upper case when used in JSX
const Component = this.state.components[i];
// temporary hack to add dividers between public and private channels in the search suggestion list
if (i === 0 || item.type !== this.state.items[i - 1].type) {
if (item.type === Constants.OPEN_CHANNEL) {
items.push(this.renderChannelDivider(Constants.OPEN_CHANNEL));
} else if (item.type === Constants.PRIVATE_CHANNEL) {
items.push(this.renderChannelDivider(Constants.PRIVATE_CHANNEL));
}
}
items.push(
<Component
key={term}
ref={term}
item={item}
isSelection={isSelection}
onClick={this.handleItemClick.bind(this, term)}
/>
);
}
return (
<ReactBootstrap.Popover
ref='popover'
id='search-autocomplete__popover'
className='search-help-popover autocomplete visible'
placement='bottom'
>
{items}
</ReactBootstrap.Popover>
);
}
}
SuggestionList.propTypes = {
suggestionId: React.PropTypes.string.isRequired
};

View File

@@ -0,0 +1,246 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import AppDispatcher from '../dispatcher/app_dispatcher.jsx';
import Constants from '../utils/constants.jsx';
import EventEmitter from 'events';
const ActionTypes = Constants.ActionTypes;
const COMPLETE_WORD_EVENT = 'complete_word';
const PRETEXT_CHANGED_EVENT = 'pretext_changed';
const SUGGESTIONS_CHANGED_EVENT = 'suggestions_changed';
class SuggestionStore extends EventEmitter {
constructor() {
super();
this.addSuggestionsChangedListener = this.addSuggestionsChangedListener.bind(this);
this.removeSuggestionsChangedListener = this.removeSuggestionsChangedListener.bind(this);
this.emitSuggestionsChanged = this.emitSuggestionsChanged.bind(this);
this.addPretextChangedListener = this.addPretextChangedListener.bind(this);
this.removePretextChangedListener = this.removePretextChangedListener.bind(this);
this.emitPretextChanged = this.emitPretextChanged.bind(this);
this.addCompleteWordListener = this.addCompleteWordListener.bind(this);
this.removeCompleteWordListener = this.removeCompleteWordListener.bind(this);
this.emitCompleteWord = this.emitCompleteWord.bind(this);
this.handleEventPayload = this.handleEventPayload.bind(this);
this.dispatchToken = AppDispatcher.register(this.handleEventPayload);
// 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
// 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
// selection: the term currently selected by the keyboard
this.suggestions = new Map();
}
addSuggestionsChangedListener(id, callback) {
this.on(SUGGESTIONS_CHANGED_EVENT + id, callback);
}
removeSuggestionsChangedListener(id, callback) {
this.removeListener(SUGGESTIONS_CHANGED_EVENT + id, callback);
}
emitSuggestionsChanged(id) {
this.emit(SUGGESTIONS_CHANGED_EVENT + id);
}
addPretextChangedListener(id, callback) {
this.on(PRETEXT_CHANGED_EVENT + id, callback);
}
removePretextChangedListener(id, callback) {
this.removeListener(PRETEXT_CHANGED_EVENT + id, callback);
}
emitPretextChanged(id, pretext) {
this.emit(PRETEXT_CHANGED_EVENT + id, pretext);
}
addCompleteWordListener(id, callback) {
this.on(COMPLETE_WORD_EVENT + id, callback);
}
removeCompleteWordListener(id, callback) {
this.removeListener(COMPLETE_WORD_EVENT + id, callback);
}
emitCompleteWord(id, term) {
this.emit(COMPLETE_WORD_EVENT + id, term);
}
registerSuggestionBox(id) {
this.suggestions.set(id, {
pretext: '',
matchedPretext: '',
terms: [],
items: [],
components: [],
selection: ''
});
}
unregisterSuggestionBox(id) {
this.suggestions.delete(id);
}
clearSuggestions(id) {
const suggestion = this.suggestions.get(id);
suggestion.matchedPretext = '';
suggestion.terms = [];
suggestion.items = [];
suggestion.components = [];
suggestion.selection = '';
}
hasSuggestions(id) {
return this.suggestions.get(id).terms.length > 0;
}
setPretext(id, pretext) {
const suggestion = this.suggestions.get(id);
suggestion.pretext = pretext;
}
setMatchedPretext(id, matchedPretext) {
const suggestion = this.suggestions.get(id);
suggestion.matchedPretext = matchedPretext;
}
addSuggestion(id, term, item, component) {
const suggestion = this.suggestions.get(id);
suggestion.terms.push(term);
suggestion.items.push(item);
suggestion.components.push(component);
}
addSuggestions(id, terms, items, component) {
const suggestion = this.suggestions.get(id);
suggestion.terms.push(...terms);
suggestion.items.push(...items);
for (let i = 0; i < terms.length; i++) {
suggestion.components.push(component);
}
}
// make sure that if suggestions exist, then one of them is selected. return true if the selection changes.
ensureSelectionExists(id) {
const suggestion = this.suggestions.get(id);
if (suggestion.terms.length > 0) {
// if the current selection is no longer in the map, select the first term in the list
if (!suggestion.selection || suggestion.terms.indexOf(suggestion.selection) === -1) {
suggestion.selection = suggestion.terms[0];
return true;
}
} else if (suggestion.selection) {
suggestion.selection = '';
return true;
}
return false;
}
getPretext(id) {
return this.suggestions.get(id).pretext;
}
getMatchedPretext(id) {
return this.suggestions.get(id).matchedPretext;
}
getItems(id) {
return this.suggestions.get(id).items;
}
getTerms(id) {
return this.suggestions.get(id).terms;
}
getComponents(id) {
return this.suggestions.get(id).components;
}
getSelection(id) {
return this.suggestions.get(id).selection;
}
selectNext(id) {
this.setSelectionByDelta(id, 1);
}
selectPrevious(id) {
this.setSelectionByDelta(id, -1);
}
setSelectionByDelta(id, delta) {
const suggestion = this.suggestions.get(id);
let selectionIndex = suggestion.terms.indexOf(suggestion.selection);
if (selectionIndex === -1) {
// this should never happen since selection should always be in terms
throw new Error('selection is not in terms');
}
selectionIndex += delta;
if (selectionIndex < 0) {
selectionIndex = 0;
} else if (selectionIndex > suggestion.terms.length - 1) {
selectionIndex = suggestion.terms.length - 1;
}
suggestion.selection = suggestion.terms[selectionIndex];
}
handleEventPayload(payload) {
const {type, id, ...other} = payload.action; // eslint-disable-line no-redeclare
switch (type) {
case ActionTypes.SUGGESTION_PRETEXT_CHANGED:
this.clearSuggestions(id);
this.setPretext(id, other.pretext);
this.emitPretextChanged(id, other.pretext);
this.ensureSelectionExists(id);
this.emitSuggestionsChanged(id);
break;
case ActionTypes.SUGGESTION_RECEIVED_SUGGESTIONS:
this.setMatchedPretext(id, other.matchedPretext);
this.addSuggestions(id, other.terms, other.items, other.componentType);
this.ensureSelectionExists(id);
this.emitSuggestionsChanged(id);
break;
case ActionTypes.SUGGESTION_SELECT_NEXT:
this.selectNext(id);
this.emitSuggestionsChanged(id);
break;
case ActionTypes.SUGGESTION_SELECT_PREVIOUS:
this.selectPrevious(id);
this.emitSuggestionsChanged(id);
break;
case ActionTypes.SUGGESTION_COMPLETE_WORD:
this.emitCompleteWord(id, other.term || this.getSelection(id), this.getMatchedPretext(id));
this.setPretext(id, '');
this.clearSuggestions(id);
this.emitSuggestionsChanged(id);
break;
}
}
}
export default new SuggestionStore();

View File

@@ -24,6 +24,7 @@ export default {
RECIEVED_POST: null,
RECIEVED_EDIT_POST: null,
RECIEVED_SEARCH: null,
RECIEVED_SEARCH_TERM: null,
RECIEVED_POST_SELECTED: null,
RECIEVED_MENTION_DATA: null,
RECIEVED_ADD_MENTION: null,
@@ -50,7 +51,13 @@ export default {
TOGGLE_INVITE_MEMBER_MODAL: null,
TOGGLE_DELETE_POST_MODAL: null,
TOGGLE_GET_TEAM_INVITE_LINK_MODAL: null,
TOGGLE_REGISTER_APP_MODAL: null
TOGGLE_REGISTER_APP_MODAL: null,
SUGGESTION_PRETEXT_CHANGED: null,
SUGGESTION_RECEIVED_SUGGESTIONS: null,
SUGGESTION_COMPLETE_WORD: null,
SUGGESTION_SELECT_NEXT: null,
SUGGESTION_SELECT_PREVIOUS: null
}),
PayloadSources: keyMirror({

View File

@@ -94,6 +94,8 @@
}
.popover-content {
max-height: 500px;
overflow: auto;
padding: 3px 13px;
}