Merge pull request #1133 from hmhealey/plt718
PLT-718 Add an autocomplete when searching with in:, channel:, or from:
Этот коммит содержится в:
249
web/react/components/search_autocomplete.jsx
Обычный файл
249
web/react/components/search_autocomplete.jsx
Обычный файл
@@ -0,0 +1,249 @@
|
|||||||
|
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See License.txt for license information.
|
||||||
|
|
||||||
|
const ChannelStore = require('../stores/channel_store.jsx');
|
||||||
|
const KeyCodes = require('../utils/constants.jsx').KeyCodes;
|
||||||
|
const UserStore = require('../stores/user_store.jsx');
|
||||||
|
const Utils = require('../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.updateSuggestions = this.updateSuggestions.bind(this);
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
show: false,
|
||||||
|
mode: '',
|
||||||
|
filter: '',
|
||||||
|
selection: 0,
|
||||||
|
suggestions: new Map()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
$(document).on('click', this.handleDocumentClick);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
$(document).off('click', this.handleDocumentClick);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleClick(value) {
|
||||||
|
this.completeWord(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleDocumentClick(e) {
|
||||||
|
const container = $(ReactDOM.findDOMNode(this.refs.container));
|
||||||
|
|
||||||
|
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.completeSelectedWord();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
completeSelectedWord() {
|
||||||
|
if (this.state.mode === 'channels') {
|
||||||
|
this.completeWord(this.state.suggestions[this.state.selection].name);
|
||||||
|
} else if (this.state.mode === 'users') {
|
||||||
|
this.completeWord(this.state.suggestions[this.state.selection].username);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSuggestions(mode, filter) {
|
||||||
|
let suggestions = [];
|
||||||
|
|
||||||
|
if (mode === 'channels') {
|
||||||
|
let channels = ChannelStore.getAll();
|
||||||
|
|
||||||
|
if (filter) {
|
||||||
|
channels = channels.filter((channel) => channel.name.startsWith(filter));
|
||||||
|
}
|
||||||
|
|
||||||
|
channels.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
|
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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (!this.state.show || this.state.suggestions.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let suggestions = [];
|
||||||
|
|
||||||
|
if (this.state.mode === 'channels') {
|
||||||
|
suggestions = this.state.suggestions.map((channel, index) => {
|
||||||
|
let className = 'search-autocomplete__channel';
|
||||||
|
if (this.state.selection === index) {
|
||||||
|
className += ' selected';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={channel.name}
|
||||||
|
ref={channel.name}
|
||||||
|
onClick={this.handleClick.bind(this, channel.name)}
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
{channel.name}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} else if (this.state.mode === 'users') {
|
||||||
|
suggestions = this.state.suggestions.map((user, index) => {
|
||||||
|
let className = 'search-autocomplete__user';
|
||||||
|
if (this.state.selection === index) {
|
||||||
|
className += ' selected';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={user.username}
|
||||||
|
ref={user.username}
|
||||||
|
onClick={this.handleClick.bind(this, user.username)}
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
className='profile-img'
|
||||||
|
src={'/api/v1/users/' + user.id + '/image?time=' + user.update_at}
|
||||||
|
/>
|
||||||
|
{user.username}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref='container'
|
||||||
|
className='search-autocomplete'
|
||||||
|
>
|
||||||
|
{suggestions}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SearchAutocomplete.propTypes = {
|
||||||
|
completeWord: React.PropTypes.func.isRequired
|
||||||
|
};
|
||||||
@@ -9,6 +9,7 @@ var utils = require('../utils/utils.jsx');
|
|||||||
var Constants = require('../utils/constants.jsx');
|
var Constants = require('../utils/constants.jsx');
|
||||||
var ActionTypes = Constants.ActionTypes;
|
var ActionTypes = Constants.ActionTypes;
|
||||||
var Popover = ReactBootstrap.Popover;
|
var Popover = ReactBootstrap.Popover;
|
||||||
|
var SearchAutocomplete = require('./search_autocomplete.jsx');
|
||||||
|
|
||||||
export default class SearchBar extends React.Component {
|
export default class SearchBar extends React.Component {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -16,11 +17,13 @@ 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;
|
||||||
@@ -74,11 +77,18 @@ export default class SearchBar extends React.Component {
|
|||||||
results: null
|
results: null
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
handleKeyDown(e) {
|
||||||
|
if (this.refs.autocomplete) {
|
||||||
|
this.refs.autocomplete.handleKeyDown(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
handleUserInput(e) {
|
handleUserInput(e) {
|
||||||
var term = e.target.value;
|
var term = e.target.value;
|
||||||
PostStore.storeSearchTerm(term);
|
PostStore.storeSearchTerm(term);
|
||||||
PostStore.emitSearchTermChange(false);
|
PostStore.emitSearchTermChange(false);
|
||||||
this.setState({searchTerm: term});
|
this.setState({searchTerm: term});
|
||||||
|
|
||||||
|
this.refs.autocomplete.handleInputChange(e.target, term);
|
||||||
}
|
}
|
||||||
handleMouseInput(e) {
|
handleMouseInput(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -97,7 +107,7 @@ export default class SearchBar extends React.Component {
|
|||||||
this.setState({isSearching: true});
|
this.setState({isSearching: true});
|
||||||
client.search(
|
client.search(
|
||||||
terms,
|
terms,
|
||||||
function success(data) {
|
(data) => {
|
||||||
this.setState({isSearching: false});
|
this.setState({isSearching: false});
|
||||||
if (utils.isMobile()) {
|
if (utils.isMobile()) {
|
||||||
ReactDOM.findDOMNode(this.refs.search).value = '';
|
ReactDOM.findDOMNode(this.refs.search).value = '';
|
||||||
@@ -108,11 +118,11 @@ export default class SearchBar extends React.Component {
|
|||||||
results: data,
|
results: data,
|
||||||
is_mention_search: isMentionSearch
|
is_mention_search: isMentionSearch
|
||||||
});
|
});
|
||||||
}.bind(this),
|
},
|
||||||
function error(err) {
|
(err) => {
|
||||||
this.setState({isSearching: false});
|
this.setState({isSearching: false});
|
||||||
AsyncClient.dispatchError(err, 'search');
|
AsyncClient.dispatchError(err, 'search');
|
||||||
}.bind(this)
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -120,6 +130,24 @@ export default class SearchBar extends React.Component {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
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);
|
||||||
|
|
||||||
|
PostStore.storeSearchTerm(text);
|
||||||
|
PostStore.emitSearchTermChange(false);
|
||||||
|
this.setState({searchTerm: text});
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
var isSearching = null;
|
var isSearching = null;
|
||||||
if (this.state.isSearching) {
|
if (this.state.isSearching) {
|
||||||
@@ -143,12 +171,13 @@ export default class SearchBar extends React.Component {
|
|||||||
className='search__clear'
|
className='search__clear'
|
||||||
onClick={this.clearFocus}
|
onClick={this.clearFocus}
|
||||||
>
|
>
|
||||||
Cancel
|
{'Cancel'}
|
||||||
</span>
|
</span>
|
||||||
<form
|
<form
|
||||||
role='form'
|
role='form'
|
||||||
className='search__form relative-div'
|
className='search__form relative-div'
|
||||||
onSubmit={this.handleSubmit}
|
onSubmit={this.handleSubmit}
|
||||||
|
style={{overflow: 'visible'}}
|
||||||
>
|
>
|
||||||
<span className='glyphicon glyphicon-search sidebar__search-icon' />
|
<span className='glyphicon glyphicon-search sidebar__search-icon' />
|
||||||
<input
|
<input
|
||||||
@@ -160,9 +189,14 @@ export default class SearchBar extends React.Component {
|
|||||||
onFocus={this.handleUserFocus}
|
onFocus={this.handleUserFocus}
|
||||||
onBlur={this.handleUserBlur}
|
onBlur={this.handleUserBlur}
|
||||||
onChange={this.handleUserInput}
|
onChange={this.handleUserInput}
|
||||||
|
onKeyDown={this.handleKeyDown}
|
||||||
onMouseUp={this.handleMouseInput}
|
onMouseUp={this.handleMouseInput}
|
||||||
/>
|
/>
|
||||||
{isSearching}
|
{isSearching}
|
||||||
|
<SearchAutocomplete
|
||||||
|
ref='autocomplete'
|
||||||
|
completeWord={this.completeWord}
|
||||||
|
/>
|
||||||
<Popover
|
<Popover
|
||||||
placement='bottom'
|
placement='bottom'
|
||||||
className={helpClass}
|
className={helpClass}
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ class UserStoreClass extends EventEmitter {
|
|||||||
this.getProfilesUsernameMap = this.getProfilesUsernameMap.bind(this);
|
this.getProfilesUsernameMap = this.getProfilesUsernameMap.bind(this);
|
||||||
this.getProfiles = this.getProfiles.bind(this);
|
this.getProfiles = this.getProfiles.bind(this);
|
||||||
this.getActiveOnlyProfiles = this.getActiveOnlyProfiles.bind(this);
|
this.getActiveOnlyProfiles = this.getActiveOnlyProfiles.bind(this);
|
||||||
|
this.getActiveOnlyProfileList = this.getActiveOnlyProfileList.bind(this);
|
||||||
this.saveProfile = this.saveProfile.bind(this);
|
this.saveProfile = this.saveProfile.bind(this);
|
||||||
this.setSessions = this.setSessions.bind(this);
|
this.setSessions = this.setSessions.bind(this);
|
||||||
this.getSessions = this.getSessions.bind(this);
|
this.getSessions = this.getSessions.bind(this);
|
||||||
@@ -215,6 +216,19 @@ class UserStoreClass extends EventEmitter {
|
|||||||
return active;
|
return active;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getActiveOnlyProfileList() {
|
||||||
|
const profileMap = this.getActiveOnlyProfiles();
|
||||||
|
const profiles = [];
|
||||||
|
|
||||||
|
for (const id in profileMap) {
|
||||||
|
if (profileMap.hasOwnProperty(id)) {
|
||||||
|
profiles.push(profileMap[id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return profiles;
|
||||||
|
}
|
||||||
|
|
||||||
saveProfile(profile) {
|
saveProfile(profile) {
|
||||||
var ps = this.getProfiles();
|
var ps = this.getProfiles();
|
||||||
ps[profile.id] = profile;
|
ps[profile.id] = profile;
|
||||||
|
|||||||
@@ -311,6 +311,7 @@ module.exports = {
|
|||||||
RIGHT: 39,
|
RIGHT: 39,
|
||||||
BACKSPACE: 8,
|
BACKSPACE: 8,
|
||||||
ENTER: 13,
|
ENTER: 13,
|
||||||
ESCAPE: 27
|
ESCAPE: 27,
|
||||||
|
SPACE: 32
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -109,3 +109,43 @@
|
|||||||
.search-highlight {
|
.search-highlight {
|
||||||
background-color: #FFF2BB;
|
background-color: #FFF2BB;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.search-autocomplete {
|
||||||
|
background-color: #fff;
|
||||||
|
border: $border-gray;
|
||||||
|
line-height: 36px;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: scroll;
|
||||||
|
position: absolute;
|
||||||
|
text-align: left;
|
||||||
|
width: 100%;
|
||||||
|
z-index: 100;
|
||||||
|
@extend %popover-box-shadow;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-autocomplete__channel {
|
||||||
|
cursor: pointer;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0px 6px;
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
background-color:rgba(51, 51, 51, 0.15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-autocomplete__user {
|
||||||
|
cursor: pointer;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0px;
|
||||||
|
|
||||||
|
.profile-img {
|
||||||
|
height: 32px;
|
||||||
|
margin-right: 6px;
|
||||||
|
width: 32px;
|
||||||
|
@include border-radius(16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
background-color:rgba(51, 51, 51, 0.15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Ссылка в новой задаче
Block a user