Merge pull request #1540 from hmhealey/plt1297
PLT-1297 Replaced SearchAutocomplete with new suggestion components
Этот коммит содержится в:
@@ -47,7 +47,7 @@
|
|||||||
"no-irregular-whitespace": 2,
|
"no-irregular-whitespace": 2,
|
||||||
"no-unexpected-multiline": 2,
|
"no-unexpected-multiline": 2,
|
||||||
"no-unreachable": 2,
|
"no-unreachable": 2,
|
||||||
"no-magic-numbers": [1, { "enforceConst": true, "detectObjects": true } ],
|
"no-magic-numbers": [1, { "ignore": [-1, 0, 1, 2], "enforceConst": true, "detectObjects": true } ],
|
||||||
"valid-typeof": 2,
|
"valid-typeof": 2,
|
||||||
|
|
||||||
"block-scoped-var": 2,
|
"block-scoped-var": 2,
|
||||||
|
|||||||
@@ -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
|
|
||||||
};
|
|
||||||
@@ -5,11 +5,13 @@ import * as client from '../utils/client.jsx';
|
|||||||
import * as AsyncClient from '../utils/async_client.jsx';
|
import * as AsyncClient from '../utils/async_client.jsx';
|
||||||
import SearchStore from '../stores/search_store.jsx';
|
import SearchStore from '../stores/search_store.jsx';
|
||||||
import AppDispatcher from '../dispatcher/app_dispatcher.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 * as utils from '../utils/utils.jsx';
|
||||||
import Constants from '../utils/constants.jsx';
|
import Constants from '../utils/constants.jsx';
|
||||||
var ActionTypes = Constants.ActionTypes;
|
var ActionTypes = Constants.ActionTypes;
|
||||||
var Popover = ReactBootstrap.Popover;
|
var Popover = ReactBootstrap.Popover;
|
||||||
import SearchAutocomplete from './search_autocomplete.jsx';
|
|
||||||
|
|
||||||
export default class SearchBar extends React.Component {
|
export default class SearchBar extends React.Component {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -17,17 +19,17 @@ export default class SearchBar extends React.Component {
|
|||||||
this.mounted = false;
|
this.mounted = false;
|
||||||
|
|
||||||
this.onListenerChange = this.onListenerChange.bind(this);
|
this.onListenerChange = this.onListenerChange.bind(this);
|
||||||
this.handleKeyDown = this.handleKeyDown.bind(this);
|
|
||||||
this.handleUserInput = this.handleUserInput.bind(this);
|
this.handleUserInput = this.handleUserInput.bind(this);
|
||||||
this.handleUserFocus = this.handleUserFocus.bind(this);
|
this.handleUserFocus = this.handleUserFocus.bind(this);
|
||||||
this.handleUserBlur = this.handleUserBlur.bind(this);
|
this.handleUserBlur = this.handleUserBlur.bind(this);
|
||||||
this.performSearch = this.performSearch.bind(this);
|
this.performSearch = this.performSearch.bind(this);
|
||||||
this.handleSubmit = this.handleSubmit.bind(this);
|
this.handleSubmit = this.handleSubmit.bind(this);
|
||||||
this.completeWord = this.completeWord.bind(this);
|
|
||||||
|
|
||||||
const state = this.getSearchTermStateFromStores();
|
const state = this.getSearchTermStateFromStores();
|
||||||
state.focused = false;
|
state.focused = false;
|
||||||
this.state = state;
|
this.state = state;
|
||||||
|
|
||||||
|
this.suggestionProviders = [new SearchChannelProvider(), new SearchUserProvider()];
|
||||||
}
|
}
|
||||||
getSearchTermStateFromStores() {
|
getSearchTermStateFromStores() {
|
||||||
var term = SearchStore.getSearchTerm() || '';
|
var term = SearchStore.getSearchTerm() || '';
|
||||||
@@ -77,18 +79,11 @@ export default class SearchBar extends React.Component {
|
|||||||
results: null
|
results: null
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
handleKeyDown(e) {
|
handleUserInput(text) {
|
||||||
if (this.refs.autocomplete) {
|
var term = text;
|
||||||
this.refs.autocomplete.handleKeyDown(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
handleUserInput(e) {
|
|
||||||
var term = e.target.value;
|
|
||||||
SearchStore.storeSearchTerm(term);
|
SearchStore.storeSearchTerm(term);
|
||||||
SearchStore.emitSearchTermChange(false);
|
SearchStore.emitSearchTermChange(false);
|
||||||
this.setState({searchTerm: term});
|
this.setState({searchTerm: term});
|
||||||
|
|
||||||
this.refs.autocomplete.handleInputChange(e.target, term);
|
|
||||||
}
|
}
|
||||||
handleUserBlur() {
|
handleUserBlur() {
|
||||||
this.setState({focused: false});
|
this.setState({focused: false});
|
||||||
@@ -128,23 +123,6 @@ export default class SearchBar extends React.Component {
|
|||||||
this.performSearch(this.state.searchTerm.trim());
|
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() {
|
render() {
|
||||||
var isSearching = null;
|
var isSearching = null;
|
||||||
if (this.state.isSearching) {
|
if (this.state.isSearching) {
|
||||||
@@ -178,22 +156,17 @@ export default class SearchBar extends React.Component {
|
|||||||
autoComplete='off'
|
autoComplete='off'
|
||||||
>
|
>
|
||||||
<span className='glyphicon glyphicon-search sidebar__search-icon' />
|
<span className='glyphicon glyphicon-search sidebar__search-icon' />
|
||||||
<input
|
<SuggestionBox
|
||||||
type='text'
|
|
||||||
ref='search'
|
ref='search'
|
||||||
className='form-control search-bar'
|
className='form-control search-bar'
|
||||||
placeholder='Search'
|
placeholder='Search'
|
||||||
value={this.state.searchTerm}
|
value={this.state.searchTerm}
|
||||||
onFocus={this.handleUserFocus}
|
onFocus={this.handleUserFocus}
|
||||||
onBlur={this.handleUserBlur}
|
onBlur={this.handleUserBlur}
|
||||||
onChange={this.handleUserInput}
|
onUserInput={this.handleUserInput}
|
||||||
onKeyDown={this.handleKeyDown}
|
providers={this.suggestionProviders}
|
||||||
/>
|
/>
|
||||||
{isSearching}
|
{isSearching}
|
||||||
<SearchAutocomplete
|
|
||||||
ref='autocomplete'
|
|
||||||
completeWord={this.completeWord}
|
|
||||||
/>
|
|
||||||
<Popover
|
<Popover
|
||||||
id='searchbar-help-popup'
|
id='searchbar-help-popup'
|
||||||
placement='bottom'
|
placement='bottom'
|
||||||
|
|||||||
69
web/react/components/search_channel_provider.jsx
Обычный файл
69
web/react/components/search_channel_provider.jsx
Обычный файл
@@ -0,0 +1,69 @@
|
|||||||
|
// 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
|
||||||
|
};
|
||||||
|
|
||||||
|
export default 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
62
web/react/components/search_user_provider.jsx
Обычный файл
62
web/react/components/search_user_provider.jsx
Обычный файл
@@ -0,0 +1,62 @@
|
|||||||
|
// 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
|
||||||
|
};
|
||||||
|
|
||||||
|
export default 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
170
web/react/components/suggestion_box.jsx
Обычный файл
170
web/react/components/suggestion_box.jsx
Обычный файл
@@ -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
|
||||||
|
};
|
||||||
157
web/react/components/suggestion_list.jsx
Обычный файл
157
web/react/components/suggestion_list.jsx
Обычный файл
@@ -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
|
||||||
|
};
|
||||||
246
web/react/stores/suggestion_store.jsx
Обычный файл
246
web/react/stores/suggestion_store.jsx
Обычный файл
@@ -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();
|
||||||
@@ -24,6 +24,7 @@ export default {
|
|||||||
RECIEVED_POST: null,
|
RECIEVED_POST: null,
|
||||||
RECIEVED_EDIT_POST: null,
|
RECIEVED_EDIT_POST: null,
|
||||||
RECIEVED_SEARCH: null,
|
RECIEVED_SEARCH: null,
|
||||||
|
RECIEVED_SEARCH_TERM: null,
|
||||||
RECIEVED_POST_SELECTED: null,
|
RECIEVED_POST_SELECTED: null,
|
||||||
RECIEVED_MENTION_DATA: null,
|
RECIEVED_MENTION_DATA: null,
|
||||||
RECIEVED_ADD_MENTION: null,
|
RECIEVED_ADD_MENTION: null,
|
||||||
@@ -50,7 +51,13 @@ export default {
|
|||||||
TOGGLE_INVITE_MEMBER_MODAL: null,
|
TOGGLE_INVITE_MEMBER_MODAL: null,
|
||||||
TOGGLE_DELETE_POST_MODAL: null,
|
TOGGLE_DELETE_POST_MODAL: null,
|
||||||
TOGGLE_GET_TEAM_INVITE_LINK_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({
|
PayloadSources: keyMirror({
|
||||||
|
|||||||
@@ -94,6 +94,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.popover-content {
|
.popover-content {
|
||||||
|
max-height: 500px;
|
||||||
|
overflow: auto;
|
||||||
padding: 3px 13px;
|
padding: 3px 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Ссылка в новой задаче
Block a user