PLT-4403 Add server-based channel autocomplete, search and paging (#4585)

* Add more channel paging API

* Add channel paging support to client

* Add DB channel search functions

* Add API for searching more channels

* Add more channel search functionality to client

* Add API for autocompleting channels

* Add channel autocomplete functionality to the client

* Move to be deprecated APIs to their own file

* Final clean-up

* Fixes related to feedback

* Localization changes

* Add unit as suffix to timeout constants
Этот коммит содержится в:
Joram Wilander
2016-11-24 09:35:09 -05:00
коммит произвёл Harrison Healey
родитель c96ecae6da
Коммит 981ea33b8e
27 изменённых файлов: 1337 добавлений и 381 удалений

Просмотреть файл

@@ -1,180 +0,0 @@
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import $ from 'jquery';
import ReactDOM from 'react-dom';
import * as UserAgent from 'utils/user_agent.jsx';
import {localizeMessage} from 'utils/utils.jsx';
import {FormattedMessage} from 'react-intl';
import React from 'react';
import loadingGif from 'images/load.gif';
export default class FilteredChannelList extends React.Component {
constructor(props) {
super(props);
this.handleFilterChange = this.handleFilterChange.bind(this);
this.createChannelRow = this.createChannelRow.bind(this);
this.filterChannels = this.filterChannels.bind(this);
this.state = {
filter: '',
joiningChannel: '',
channels: this.filterChannels(props.channels)
};
}
componentWillReceiveProps(nextProps) {
// assume the channel list is immutable
if (this.props.channels !== nextProps.channels) {
this.setState({
channels: this.filterChannels(nextProps.channels)
});
}
}
componentDidMount() {
// only focus the search box on desktop so that we don't cause the keyboard to open on mobile
if (!UserAgent.isMobile()) {
ReactDOM.findDOMNode(this.refs.filter).focus();
}
}
componentDidUpdate(prevProps, prevState) {
if (prevState.filter !== this.state.filter) {
$(ReactDOM.findDOMNode(this.refs.channelList)).scrollTop(0);
}
}
handleJoin(channel) {
this.setState({joiningChannel: channel.id});
this.props.handleJoin(
channel,
() => {
this.setState({joiningChannel: ''});
});
}
createChannelRow(channel) {
let joinButton;
if (this.state.joiningChannel === channel.id) {
joinButton = (
<img
className='join-channel-loading-gif'
src={loadingGif}
/>
);
} else {
joinButton = (
<button
onClick={this.handleJoin.bind(this, channel)}
className='btn btn-primary'
>
<FormattedMessage
id='more_channels.join'
defaultMessage='Join'
/>
</button>
);
}
return (
<div
className='more-modal__row'
key={channel.id}
>
<div className='more-modal__details'>
<p className='more-modal__name'>{channel.display_name}</p>
<p className='more-modal__description'>{channel.purpose}</p>
</div>
<div className='more-modal__actions'>
{joinButton}
</div>
</div>
);
}
filterChannels(channels) {
if (!this.state || !this.state.filter) {
return channels;
}
return channels.filter((chan) => {
const filter = this.state.filter.toLowerCase();
return Boolean((chan.name.toLowerCase().indexOf(filter) !== -1 || chan.display_name.toLowerCase().indexOf(filter) !== -1) && chan.delete_at === 0);
});
}
handleFilterChange(e) {
this.setState({
filter: e.target.value
});
}
render() {
let channels = this.state.channels;
if (this.state.filter && this.state.filter.length > 0) {
channels = this.filterChannels(channels);
}
let count;
if (channels.length === this.props.channels.length) {
count = (
<FormattedMessage
id='filtered_channels_list.count'
defaultMessage='{count} {count, plural, =0 {0 channels} one {channel} other {channels}}'
values={{
count: channels.length
}}
/>
);
} else {
count = (
<FormattedMessage
id='filtered_channels_list.countTotal'
defaultMessage='{count} {count, plural, =0 {0 channels} one {channel} other {channels}} of {total} Total'
values={{
count: channels.length,
total: this.props.channels.length
}}
/>
);
}
return (
<div className='filtered-user-list'>
<div className='filter-row'>
<div className='col-sm-6'>
<input
ref='filter'
className='form-control filter-textbox'
placeholder={localizeMessage('filtered_channels_list.search', 'Search channels')}
onInput={this.handleFilterChange}
/>
</div>
<div className='col-sm-12'>
<span className='channel-count pull-left'>{count}</span>
</div>
</div>
<div
ref='channelList'
className='more-modal__list'
>
{channels.map(this.createChannelRow)}
</div>
</div>
);
}
}
FilteredChannelList.defaultProps = {
channels: []
};
FilteredChannelList.propTypes = {
channels: React.PropTypes.arrayOf(React.PropTypes.object),
handleJoin: React.PropTypes.func.isRequired
};

Просмотреть файл

@@ -2,18 +2,16 @@
// See License.txt for license information.
import $ from 'jquery';
import LoadingScreen from './loading_screen.jsx';
import NewChannelFlow from './new_channel_flow.jsx';
import FilteredChannelList from './filtered_channel_list.jsx';
import SearchableChannelList from './searchable_channel_list.jsx';
import ChannelStore from 'stores/channel_store.jsx';
import UserStore from 'stores/user_store.jsx';
import TeamStore from 'stores/team_store.jsx';
import * as Utils from 'utils/utils.jsx';
import Constants from 'utils/constants.jsx';
import * as AsyncClient from 'utils/async_client.jsx';
import * as GlobalActions from 'actions/global_actions.jsx';
import {joinChannel, searchMoreChannels} from 'actions/channel_actions.jsx';
import {FormattedMessage} from 'react-intl';
import {browserHistory} from 'react-router/es6';
@@ -21,19 +19,28 @@ import {browserHistory} from 'react-router/es6';
import React from 'react';
import PureRenderMixin from 'react-addons-pure-render-mixin';
const CHANNELS_CHUNK_SIZE = 50;
const CHANNELS_PER_PAGE = 50;
const SEARCH_TIMEOUT_MILLISECONDS = 100;
export default class MoreChannels extends React.Component {
constructor(props) {
super(props);
this.onListenerChange = this.onListenerChange.bind(this);
this.onChange = this.onChange.bind(this);
this.handleJoin = this.handleJoin.bind(this);
this.handleNewChannel = this.handleNewChannel.bind(this);
this.nextPage = this.nextPage.bind(this);
this.search = this.search.bind(this);
this.shouldComponentUpdate = PureRenderMixin.shouldComponentUpdate.bind(this);
this.searchTimeoutId = 0;
this.state = {
channelType: '',
showNewChannelModal: false,
search: false,
channels: null,
serverError: null
};
@@ -41,10 +48,10 @@ export default class MoreChannels extends React.Component {
componentDidMount() {
const self = this;
ChannelStore.addChangeListener(this.onListenerChange);
ChannelStore.addChangeListener(this.onChange);
$(this.refs.modal).on('shown.bs.modal', () => {
AsyncClient.getMoreChannels(true);
AsyncClient.getMoreChannelsPage(0, CHANNELS_CHUNK_SIZE * 2);
});
$(this.refs.modal).on('show.bs.modal', (e) => {
@@ -54,25 +61,26 @@ export default class MoreChannels extends React.Component {
}
componentWillUnmount() {
ChannelStore.removeChangeListener(this.onListenerChange);
ChannelStore.removeChangeListener(this.onChange);
}
getStateFromStores() {
return {
channels: ChannelStore.getMoreAll(),
serverError: null
};
}
onListenerChange() {
const newState = this.getStateFromStores();
if (!Utils.areObjectsEqual(newState.channels, this.state.channels)) {
this.setState(newState);
onChange(force) {
if (this.state.search && !force) {
return;
}
this.setState({
channels: ChannelStore.getMoreChannelsList(),
serverError: null
});
}
nextPage(page) {
AsyncClient.getMoreChannelsPage((page + 1) * CHANNELS_PER_PAGE, CHANNELS_PER_PAGE);
}
handleJoin(channel, done) {
GlobalActions.emitJoinChannelEvent(
joinChannel(
channel,
() => {
$(this.refs.modal).modal('hide');
@@ -95,6 +103,28 @@ export default class MoreChannels extends React.Component {
this.setState({showNewChannelModal: true});
}
search(term) {
if (term === '') {
this.onChange(true);
this.setState({search: false});
return;
}
clearTimeout(this.searchTimeoutId);
this.searchTimeoutId = setTimeout(
() => {
searchMoreChannels(
term,
(channels) => {
this.setState({search: true, channels});
}
);
},
SEARCH_TIMEOUT_MILLISECONDS
);
}
render() {
let serverError;
if (this.state.serverError) {
@@ -136,31 +166,6 @@ export default class MoreChannels extends React.Component {
}
}
let moreChannels;
const channels = this.state.channels;
if (channels == null) {
moreChannels = <LoadingScreen/>;
} else if (channels.length) {
moreChannels = (
<FilteredChannelList
channels={channels}
handleJoin={this.handleJoin}
/>
);
} else {
moreChannels = (
<div className='no-channel-message'>
<p className='primary-message'>
<FormattedMessage
id='more_channels.noMore'
defaultMessage='No more channels to join'
/>
</p>
{createChannelHelpText}
</div>
);
}
return (
<div
className='modal fade more-channel__modal'
@@ -200,7 +205,14 @@ export default class MoreChannels extends React.Component {
/>
</div>
<div className='modal-body'>
{moreChannels}
<SearchableChannelList
channels={this.state.channels}
channelsPerPage={CHANNELS_PER_PAGE}
nextPage={this.nextPage}
search={this.search}
handleJoin={this.handleJoin}
noResultsText={createChannelHelpText}
/>
{serverError}
</div>
<div className='modal-footer'>

205
webapp/components/searchable_channel_list.jsx Обычный файл
Просмотреть файл

@@ -0,0 +1,205 @@
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import LoadingScreen from './loading_screen.jsx';
import * as UserAgent from 'utils/user_agent.jsx';
import $ from 'jquery';
import React from 'react';
import {localizeMessage} from 'utils/utils.jsx';
import {FormattedMessage} from 'react-intl';
import loadingGif from 'images/load.gif';
const NEXT_BUTTON_TIMEOUT_MILLISECONDS = 500;
export default class SearchableChannelList extends React.Component {
constructor(props) {
super(props);
this.createChannelRow = this.createChannelRow.bind(this);
this.nextPage = this.nextPage.bind(this);
this.previousPage = this.previousPage.bind(this);
this.doSearch = this.doSearch.bind(this);
this.nextTimeoutId = 0;
this.state = {
joiningChannel: '',
page: 0,
nextDisabled: false
};
}
componentDidMount() {
// only focus the search box on desktop so that we don't cause the keyboard to open on mobile
if (!UserAgent.isMobile()) {
this.refs.filter.focus();
}
}
componentDidUpdate(prevProps, prevState) {
if (prevState.page !== this.state.page) {
$(this.refs.channelList).scrollTop(0);
}
}
handleJoin(channel) {
this.setState({joiningChannel: channel.id});
this.props.handleJoin(
channel,
() => {
this.setState({joiningChannel: ''});
}
);
}
createChannelRow(channel) {
let joinButton;
if (this.state.joiningChannel === channel.id) {
joinButton = (
<img
className='join-channel-loading-gif'
src={loadingGif}
/>
);
} else {
joinButton = (
<button
onClick={this.handleJoin.bind(this, channel)}
className='btn btn-primary'
>
<FormattedMessage
id='more_channels.join'
defaultMessage='Join'
/>
</button>
);
}
return (
<div
className='more-modal__row'
key={channel.id}
>
<div className='more-modal__details'>
<p className='more-modal__name'>{channel.display_name}</p>
<p className='more-modal__description'>{channel.purpose}</p>
</div>
<div className='more-modal__actions'>
{joinButton}
</div>
</div>
);
}
nextPage(e) {
e.preventDefault();
this.setState({page: this.state.page + 1, nextDisabled: true});
this.nextTimeoutId = setTimeout(() => this.setState({nextDisabled: false}), NEXT_BUTTON_TIMEOUT_MILLISECONDS);
this.props.nextPage(this.state.page + 1);
}
previousPage(e) {
e.preventDefault();
this.setState({page: this.state.page - 1});
}
doSearch() {
const term = this.refs.filter.value;
this.props.search(term);
if (term === '') {
this.setState({page: 0});
}
}
render() {
const channels = this.props.channels;
let listContent;
let nextButton;
let previousButton;
if (channels == null) {
listContent = <LoadingScreen/>;
} else if (channels.length === 0) {
listContent = (
<div className='no-channel-message'>
<p className='primary-message'>
<FormattedMessage
id='more_channels.noMore'
defaultMessage='No more channels to join'
/>
</p>
{this.props.noResultsText}
</div>
);
} else {
const pageStart = this.state.page * this.props.channelsPerPage;
const pageEnd = pageStart + this.props.channelsPerPage;
const channelsToDisplay = this.props.channels.slice(pageStart, pageEnd);
listContent = channelsToDisplay.map(this.createChannelRow);
if (channelsToDisplay.length >= this.props.channelsPerPage) {
nextButton = (
<button
className='btn btn-default filter-control filter-control__next'
onClick={this.nextPage}
disabled={this.state.nextDisabled}
>
{'Next'}
</button>
);
}
if (this.state.page > 0) {
previousButton = (
<button
className='btn btn-default filter-control filter-control__prev'
onClick={this.previousPage}
>
{'Previous'}
</button>
);
}
}
return (
<div className='filtered-user-list'>
<div className='filter-row'>
<div className='col-sm-6'>
<input
ref='filter'
className='form-control filter-textbox'
placeholder={localizeMessage('filtered_channels_list.search', 'Search channels')}
onInput={this.doSearch}
/>
</div>
</div>
<div
ref='channelList'
className='more-modal__list'
>
{listContent}
</div>
<div className='filter-controls'>
{previousButton}
{nextButton}
</div>
</div>
);
}
}
SearchableChannelList.defaultProps = {
channels: []
};
SearchableChannelList.propTypes = {
channels: React.PropTypes.arrayOf(React.PropTypes.object),
channelsPerPage: React.PropTypes.number,
nextPage: React.PropTypes.func.isRequired,
search: React.PropTypes.func.isRequired,
handleJoin: React.PropTypes.func.isRequired,
noResultsText: React.PropTypes.object
};

Просмотреть файл

@@ -1,15 +1,16 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React from 'react';
import SuggestionStore from 'stores/suggestion_store.jsx';
import ChannelStore from 'stores/channel_store.jsx';
import Constants from 'utils/constants.jsx';
import Suggestion from './suggestion.jsx';
const MaxChannelSuggestions = 40;
import {autocompleteChannels} from 'actions/channel_actions.jsx';
import ChannelStore from 'stores/channel_store.jsx';
import AppDispatcher from 'dispatcher/app_dispatcher.jsx';
import {Constants, ActionTypes} from 'utils/constants.jsx';
import React from 'react';
class ChannelMentionSuggestion extends Suggestion {
render() {
@@ -48,84 +49,71 @@ class ChannelMentionSuggestion extends Suggestion {
}
}
function filterChannelsByPrefix(channels, prefix, limit) {
const filtered = [];
for (const id of Object.keys(channels)) {
if (filtered.length >= limit) {
break;
}
const channel = channels[id];
if (channel.delete_at > 0) {
continue;
}
if (channel.display_name.toLowerCase().startsWith(prefix) || channel.name.startsWith(prefix)) {
filtered.push(channel);
}
export default class ChannelMentionProvider {
constructor() {
this.timeoutId = '';
}
return filtered;
}
componentWillUnmount() {
clearTimeout(this.timeoutId);
}
export default class ChannelMentionProvider {
handlePretextChanged(suggestionId, pretext) {
const captured = (/(^|\s)(~([^~]*))$/i).exec(pretext.toLowerCase());
if (captured) {
const prefix = captured[3];
const channels = ChannelStore.getAll();
const moreChannels = ChannelStore.getMoreAll();
function autocomplete() {
autocompleteChannels(
prefix,
(data) => {
const channels = data;
// Remove private channels from the list.
const publicChannels = channels.filter((channel) => {
return channel.type === 'O';
});
// Wrap channels in an outer object to avoid overwriting the 'type' property.
const wrappedChannels = [];
const wrappedMoreChannels = [];
const moreChannels = [];
channels.forEach((item) => {
if (ChannelStore.get(item.id)) {
wrappedChannels.push({
type: Constants.MENTION_CHANNELS,
channel: item
});
return;
}
// Filter channels by prefix.
const filteredChannels = filterChannelsByPrefix(
publicChannels, prefix, MaxChannelSuggestions);
const filteredMoreChannels = filterChannelsByPrefix(
moreChannels, prefix, MaxChannelSuggestions - filteredChannels.length);
wrappedMoreChannels.push({
type: Constants.MENTION_MORE_CHANNELS,
channel: item
});
// Sort channels by display name.
[filteredChannels, filteredMoreChannels].forEach((items) => {
items.sort((a, b) => {
const aPrefix = a.display_name.startsWith(prefix);
const bPrefix = b.display_name.startsWith(prefix);
moreChannels.push(item);
});
if (aPrefix === bPrefix) {
return a.display_name.localeCompare(b.display_name);
} else if (aPrefix) {
return -1;
const wrapped = wrappedChannels.concat(wrappedMoreChannels);
const mentions = wrapped.map((item) => '~' + item.channel.name);
AppDispatcher.handleServerAction({
type: ActionTypes.RECEIVED_MORE_CHANNELS,
channels: moreChannels
});
AppDispatcher.handleServerAction({
type: ActionTypes.SUGGESTION_RECEIVED_SUGGESTIONS,
id: suggestionId,
matchedPretext: captured[2],
terms: mentions,
items: wrapped,
component: ChannelMentionSuggestion
});
}
);
}
return 1;
});
});
// Wrap channels in an outer object to avoid overwriting the 'type' property.
const wrappedChannels = filteredChannels.map((item) => {
return {
type: Constants.MENTION_CHANNELS,
channel: item
};
});
const wrappedMoreChannels = filteredMoreChannels.map((item) => {
return {
type: Constants.MENTION_MORE_CHANNELS,
channel: item
};
});
const wrapped = wrappedChannels.concat(wrappedMoreChannels);
const mentions = wrapped.map((item) => '~' + item.channel.name);
SuggestionStore.clearSuggestions(suggestionId);
SuggestionStore.addSuggestions(suggestionId, mentions, wrapped, ChannelMentionSuggestion, captured[2]);
this.timeoutId = setTimeout(
autocomplete.bind(this),
Constants.AUTOCOMPLETE_TIMEOUT
);
}
}
}

Просмотреть файл

@@ -1,13 +1,16 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React from 'react';
import Suggestion from './suggestion.jsx';
import {autocompleteChannels} from 'actions/channel_actions.jsx';
import ChannelStore from 'stores/channel_store.jsx';
import Constants from 'utils/constants.jsx';
import SuggestionStore from 'stores/suggestion_store.jsx';
import Suggestion from './suggestion.jsx';
import AppDispatcher from 'dispatcher/app_dispatcher.jsx';
import {Constants, ActionTypes} from 'utils/constants.jsx';
import React from 'react';
class SearchChannelSuggestion extends Suggestion {
render() {
@@ -30,37 +33,64 @@ class SearchChannelSuggestion extends Suggestion {
}
export default class SearchChannelProvider {
constructor() {
this.timeoutId = '';
}
componentWillUnmount() {
clearTimeout(this.timeoutId);
}
handlePretextChanged(suggestionId, pretext) {
const captured = (/\b(?:in|channel):\s*(\S*)$/i).exec(pretext.toLowerCase());
if (captured) {
const channelPrefix = captured[1];
const channels = ChannelStore.getAll();
const publicChannels = [];
const privateChannels = [];
function autocomplete() {
autocompleteChannels(
channelPrefix,
(data) => {
const publicChannels = data;
for (const id of Object.keys(channels)) {
const channel = channels[id];
const localChannels = ChannelStore.getAll();
const privateChannels = [];
// 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);
for (const id of Object.keys(localChannels)) {
const channel = localChannels[id];
if (channel.name.startsWith(channelPrefix) && channel.type === Constants.PRIVATE_CHANNEL) {
privateChannels.push(channel);
}
}
const filteredPublicChannels = [];
publicChannels.forEach((item) => {
if (item.name.startsWith(channelPrefix)) {
filteredPublicChannels.push(item);
}
});
privateChannels.sort((a, b) => a.name.localeCompare(b.name));
filteredPublicChannels.sort((a, b) => a.name.localeCompare(b.name));
const channels = filteredPublicChannels.concat(privateChannels);
const channelNames = channels.map((channel) => channel.name);
AppDispatcher.handleServerAction({
type: ActionTypes.SUGGESTION_RECEIVED_SUGGESTIONS,
id: suggestionId,
matchedPretext: channelPrefix,
terms: channelNames,
items: channels,
component: SearchChannelSuggestion
});
}
}
);
}
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.clearSuggestions(suggestionId);
SuggestionStore.addSuggestions(suggestionId, publicChannelNames, publicChannels, SearchChannelSuggestion, channelPrefix);
SuggestionStore.addSuggestions(suggestionId, privateChannelNames, privateChannels, SearchChannelSuggestion, channelPrefix);
this.timeoutId = setTimeout(
autocomplete.bind(this),
Constants.AUTOCOMPLETE_TIMEOUT
);
}
}
}