PLT-5699 Improvements to channel switcher (#6486)

* Refactor channel switcher to not wait on server results

* Change channel switcher to quick switcher and include team switching

* Add sections, update ordering and add discoverability button

* Fix styling error

* Use CMD in text if on mac

* Clean yarn cache on every install

* Various UX updates per feedback

* Add shortcut help text for team switcher

* Couple more updates per feedback

* Some minor fixes for GM and autocomplete race

* Updating UI for channel switcher (#6504)

* Updating channel switcher button (#6506)

* Updating switcher modal on mobile (#6507)

* Removed jQuery usage

* Rename function to toggleQuickSwitchModal
Этот коммит содержится в:
Joram Wilander
2017-05-31 16:51:42 -04:00
коммит произвёл GitHub
родитель 8ce72aedc3
Коммит 5aaedb9663
23 изменённых файлов: 900 добавлений и 345 удалений

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

@@ -7,6 +7,7 @@ export default class Provider {
constructor() {
this.latestPrefix = '';
this.latestComplete = true;
this.disableDispatches = false;
}
handlePretextChanged(suggestionId, pretext) { // eslint-disable-line no-unused-vars
@@ -22,6 +23,10 @@ export default class Provider {
}
shouldCancelDispatch(prefix) {
if (this.disableDispatches) {
return true;
}
if (prefix === this.latestPrefix) {
this.latestComplete = true;
} else if (this.latestComplete) {

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

@@ -15,6 +15,71 @@ import PropTypes from 'prop-types';
import React from 'react';
export default class SuggestionBox extends React.Component {
static propTypes = {
/**
* The list component to render, usually SuggestionList
*/
listComponent: PropTypes.func.isRequired,
/**
* The HTML input box type
*/
type: PropTypes.oneOf(['input', 'textarea', 'search']).isRequired,
/**
* The value of in the input
*/
value: PropTypes.string.isRequired,
/**
* Array of suggestion providers
*/
providers: PropTypes.arrayOf(PropTypes.object),
/**
* Where the list will be displayed relative to the input box, defaults to 'top'
*/
listStyle: PropTypes.string,
/**
* Set to true to draw dividers between types of list items, defaults to false
*/
renderDividers: PropTypes.bool,
/**
* Set to allow TAB to select an item in the list, defaults to true
*/
completeOnTab: PropTypes.bool,
/**
* Function called when input box loses focus
*/
onBlur: PropTypes.func,
/**
* Function called when input box value changes
*/
onChange: PropTypes.func,
/**
* Function called when a key is pressed and the input box is in focus
*/
onKeyDown: PropTypes.func,
/**
* Function called when an item is selected
*/
onItemSelected: PropTypes.func
}
static defaultProps = {
type: 'input',
listStyle: 'top',
renderDividers: false,
completeOnTab: true
}
constructor(props) {
super(props);
@@ -46,6 +111,14 @@ export default class SuggestionBox extends React.Component {
SuggestionStore.unregisterSuggestionBox(this.suggestionId);
}
componentDidUpdate(prevProps) {
if (this.props.providers !== prevProps.providers) {
const textbox = this.getTextbox();
const pretext = textbox.value.substring(0, textbox.selectionEnd);
GlobalActions.emitSuggestionPretextChanged(this.suggestionId, pretext);
}
}
getTextbox() {
if (this.props.type === 'textarea') {
return this.refs.textbox.getDOMNode();
@@ -171,7 +244,7 @@ export default class SuggestionBox extends React.Component {
} else if (e.which === KeyCodes.DOWN) {
GlobalActions.emitSelectNextSuggestion(this.suggestionId);
e.preventDefault();
} else if (e.which === KeyCodes.ENTER || e.which === KeyCodes.TAB) {
} else if (e.which === KeyCodes.ENTER || (this.props.completeOnTab && e.which === KeyCodes.TAB)) {
this.handleCompleteWord(SuggestionStore.getSelection(this.suggestionId), SuggestionStore.getSelectedMatchedPretext(this.suggestionId));
this.props.onKeyDown(e);
e.preventDefault();
@@ -281,23 +354,3 @@ export default class SuggestionBox extends React.Component {
return '';
}
}
SuggestionBox.defaultProps = {
type: 'input',
listStyle: 'top'
};
SuggestionBox.propTypes = {
listComponent: PropTypes.func.isRequired,
type: PropTypes.oneOf(['input', 'textarea', 'search']).isRequired,
value: PropTypes.string.isRequired,
providers: PropTypes.arrayOf(PropTypes.object),
listStyle: PropTypes.string,
renderDividers: PropTypes.bool,
// explicitly name any input event handlers we override and need to manually call
onBlur: PropTypes.func,
onChange: PropTypes.func,
onKeyDown: PropTypes.func,
onItemSelected: PropTypes.func
};

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

@@ -1,14 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import SuggestionStore from 'stores/suggestion_store.jsx';
import $ from 'jquery';
import PropTypes from 'prop-types';
import React from 'react';
import ReactDOM from 'react-dom';
import PropTypes from 'prop-types';
import {FormattedMessage} from 'react-intl';
import SuggestionStore from 'stores/suggestion_store.jsx';
export default class SuggestionList extends React.Component {
static propTypes = {
suggestionId: PropTypes.string.isRequired,
@@ -111,6 +111,17 @@ export default class SuggestionList extends React.Component {
);
}
renderLoading(type) {
return (
<div
key={type + '-loading'}
className='suggestion-loader'
>
<i className='fa fa-spinner fa-pulse fa-fw margin-bottom'/>
</div>
);
}
render() {
if (this.state.items.length === 0) {
return null;
@@ -131,6 +142,11 @@ export default class SuggestionList extends React.Component {
lastType = item.type;
}
if (item.loading) {
items.push(this.renderLoading(item.type));
continue;
}
items.push(
<Component
key={term}

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

@@ -4,10 +4,6 @@
import Suggestion from './suggestion.jsx';
import Provider from './provider.jsx';
import ChannelStore from 'stores/channel_store.jsx';
import UserStore from 'stores/user_store.jsx';
import {autocompleteUsers} from 'actions/user_actions.jsx';
import Client from 'client/web_client.jsx';
import AppDispatcher from 'dispatcher/app_dispatcher.jsx';
import {Constants, ActionTypes} from 'utils/constants.jsx';
@@ -16,30 +12,44 @@ import {sortChannelsByDisplayName, getChannelDisplayName} from 'utils/channel_ut
import React from 'react';
import store from 'stores/redux_store.jsx';
const getState = store.getState;
const dispatch = store.dispatch;
import {searchChannels} from 'mattermost-redux/actions/channels';
import {autocompleteUsers} from 'mattermost-redux/actions/users';
import {getCurrentUserId, searchProfiles} from 'mattermost-redux/selectors/entities/users';
import {getChannelsInCurrentTeam, getMyChannelMemberships, getGroupChannels} from 'mattermost-redux/selectors/entities/channels';
import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
import {getBool} from 'mattermost-redux/selectors/entities/preferences';
import {Preferences} from 'mattermost-redux/constants';
class SwitchChannelSuggestion extends Suggestion {
render() {
const {item, isSelection} = this.props;
const channel = item.channel;
let className = 'mentions__name';
if (isSelection) {
className += ' suggestion--selected';
}
let displayName = item.display_name;
let displayName = channel.display_name;
let icon = null;
if (item.type === Constants.OPEN_CHANNEL) {
if (channel.type === Constants.OPEN_CHANNEL) {
icon = <div className='status'><i className='fa fa-globe'/></div>;
} else if (item.type === Constants.PRIVATE_CHANNEL) {
} else if (channel.type === Constants.PRIVATE_CHANNEL) {
icon = <div className='status'><i className='fa fa-lock'/></div>;
} else if (item.type === Constants.GM_CHANNEL) {
displayName = getChannelDisplayName(item);
} else if (channel.type === Constants.GM_CHANNEL) {
displayName = getChannelDisplayName(channel);
icon = <div className='status status--group'>{'G'}</div>;
} else {
icon = (
<div className='pull-left'>
<img
className='mention__image'
src={Client.getUsersRoute() + '/' + item.id + '/image?time=' + item.last_picture_update}
src={Client.getUsersRoute() + '/' + channel.id + '/image?time=' + channel.last_picture_update}
/>
</div>
);
@@ -57,83 +67,179 @@ class SwitchChannelSuggestion extends Suggestion {
}
}
let prefix = '';
function quickSwitchSorter(wrappedA, wrappedB) {
if (wrappedA.type === Constants.MENTION_CHANNELS && wrappedB.type === Constants.MENTION_MORE_CHANNELS) {
return -1;
} else if (wrappedB.type === Constants.MENTION_CHANNELS && wrappedA.type === Constants.MENTION_MORE_CHANNELS) {
return 1;
}
const a = wrappedA.channel;
const b = wrappedB.channel;
let aDisplayName = getChannelDisplayName(a).toLowerCase();
let bDisplayName = getChannelDisplayName(b).toLowerCase();
if (a.type === Constants.DM_CHANNEL) {
aDisplayName = aDisplayName.substring(1);
}
if (b.type === Constants.DM_CHANNEL) {
bDisplayName = bDisplayName.substring(1);
}
const aStartsWith = aDisplayName.startsWith(prefix);
const bStartsWith = bDisplayName.startsWith(prefix);
if (aStartsWith && bStartsWith) {
return sortChannelsByDisplayName(a, b);
} else if (!aStartsWith && !bStartsWith) {
return sortChannelsByDisplayName(a, b);
} else if (aStartsWith) {
return -1;
}
return 1;
}
export default class SwitchChannelProvider extends Provider {
handlePretextChanged(suggestionId, channelPrefix) {
if (channelPrefix) {
prefix = channelPrefix;
this.startNewRequest(suggestionId, channelPrefix);
const allChannels = ChannelStore.getAll();
const channels = [];
// Dispatch suggestions for local data
const channels = getChannelsInCurrentTeam(getState()).concat(getGroupChannels(getState()));
const users = Object.assign([], searchProfiles(getState(), channelPrefix, true), true);
this.formatChannelsAndDispatch(channelPrefix, suggestionId, channels, users, true);
autocompleteUsers(
channelPrefix,
(data) => {
const users = Object.assign([], data.users);
// Fetch data from the server and dispatch
this.fetchUsersAndChannels(channelPrefix, suggestionId);
if (this.shouldCancelDispatch(channelPrefix)) {
return;
}
return true;
}
const currentId = UserStore.getCurrentId();
return false;
}
for (const id of Object.keys(allChannels)) {
const channel = allChannels[id];
if (channel.display_name.toLowerCase().indexOf(channelPrefix.toLowerCase()) !== -1) {
const newChannel = Object.assign({}, channel);
if (newChannel.type === Constants.GM_CHANNEL) {
newChannel.name = getChannelDisplayName(newChannel);
}
channels.push(newChannel);
}
}
async fetchUsersAndChannels(channelPrefix, suggestionId) {
const usersAsync = autocompleteUsers(channelPrefix)(dispatch, getState);
const channelsAsync = searchChannels(getCurrentTeamId(getState()), channelPrefix)(dispatch, getState);
await usersAsync;
await channelsAsync;
const userMap = {};
for (let i = 0; i < users.length; i++) {
const user = users[i];
let displayName = `@${user.username} `;
if (this.shouldCancelDispatch(channelPrefix)) {
return;
}
if (user.id === currentId) {
const users = Object.assign([], searchProfiles(getState(), channelPrefix, true));
const channels = getChannelsInCurrentTeam(getState()).concat(getGroupChannels(getState()));
this.formatChannelsAndDispatch(channelPrefix, suggestionId, channels, users);
}
formatChannelsAndDispatch(channelPrefix, suggestionId, allChannels, users, skipNotInChannel = false) {
const channels = [];
const members = getMyChannelMemberships(getState());
if (this.shouldCancelDispatch(channelPrefix)) {
return;
}
const currentId = getCurrentUserId(getState());
for (const id of Object.keys(allChannels)) {
const channel = allChannels[id];
const member = members[channel.id];
if (channel.display_name.toLowerCase().indexOf(channelPrefix.toLowerCase()) !== -1) {
const newChannel = Object.assign({}, channel);
const wrappedChannel = {channel: newChannel, name: newChannel.name};
if (newChannel.type === Constants.GM_CHANNEL) {
newChannel.name = getChannelDisplayName(newChannel);
wrappedChannel.name = newChannel.name;
const isGMVisible = getBool(getState(), Preferences.CATEGORY_GROUP_CHANNEL_SHOW, newChannel.id, false);
if (isGMVisible) {
wrappedChannel.type = Constants.MENTION_CHANNELS;
} else {
wrappedChannel.type = Constants.MENTION_MORE_CHANNELS;
if (skipNotInChannel) {
continue;
}
if ((user.first_name || user.last_name) && user.nickname) {
displayName += `- ${Utils.getFullName(user)} (${user.nickname})`;
} else if (user.nickname) {
displayName += `- (${user.nickname})`;
} else if (user.first_name || user.last_name) {
displayName += `- ${Utils.getFullName(user)}`;
}
const newChannel = {
display_name: displayName,
name: user.username,
id: user.id,
update_at: user.update_at,
type: Constants.DM_CHANNEL
};
channels.push(newChannel);
userMap[user.id] = user;
}
const channelNames = channels.
sort(sortChannelsByDisplayName).
map((channel) => channel.name);
AppDispatcher.handleServerAction({
type: ActionTypes.SUGGESTION_RECEIVED_SUGGESTIONS,
id: suggestionId,
matchedPretext: channelPrefix,
terms: channelNames,
items: channels,
component: SwitchChannelSuggestion
});
AppDispatcher.handleServerAction({
type: ActionTypes.RECEIVED_PROFILES,
profiles: userMap
});
} else if (member) {
wrappedChannel.type = Constants.MENTION_CHANNELS;
} else {
wrappedChannel.type = Constants.MENTION_MORE_CHANNELS;
if (skipNotInChannel || !newChannel.display_name.startsWith(channelPrefix)) {
continue;
}
}
);
channels.push(wrappedChannel);
}
}
for (let i = 0; i < users.length; i++) {
const user = users[i];
const isDMVisible = getBool(getState(), Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, user.id, false);
let displayName = `@${user.username} `;
if (user.id === currentId) {
continue;
}
if ((user.first_name || user.last_name) && user.nickname) {
displayName += `- ${Utils.getFullName(user)} (${user.nickname})`;
} else if (user.nickname) {
displayName += `- (${user.nickname})`;
} else if (user.first_name || user.last_name) {
displayName += `- ${Utils.getFullName(user)}`;
}
const wrappedChannel = {
channel: {
display_name: displayName,
name: user.username,
id: user.id,
update_at: user.update_at,
type: Constants.DM_CHANNEL
},
name: user.username
};
if (isDMVisible) {
wrappedChannel.type = Constants.MENTION_CHANNELS;
} else {
wrappedChannel.type = Constants.MENTION_MORE_CHANNELS;
if (skipNotInChannel) {
continue;
}
}
channels.push(wrappedChannel);
}
const channelNames = channels.
sort(quickSwitchSorter).
map((wrappedChannel) => wrappedChannel.channel.name);
if (skipNotInChannel) {
channels.push({
type: Constants.MENTION_MORE_CHANNELS,
loading: true
});
}
setTimeout(() => {
AppDispatcher.handleServerAction({
type: ActionTypes.SUGGESTION_RECEIVED_SUGGESTIONS,
id: suggestionId,
matchedPretext: channelPrefix,
terms: channelNames,
items: channels,
component: SwitchChannelSuggestion
});
}, 0);
}
}

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

@@ -0,0 +1,96 @@
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import Suggestion from './suggestion.jsx';
import Provider from './provider.jsx';
import AppDispatcher from 'dispatcher/app_dispatcher.jsx';
import {ActionTypes} from 'utils/constants.jsx';
import LocalizationStore from 'stores/localization_store.jsx';
import React from 'react';
// Redux actions
import store from 'stores/redux_store.jsx';
const getState = store.getState;
import * as Selectors from 'mattermost-redux/selectors/entities/teams';
class SwitchTeamSuggestion extends Suggestion {
render() {
const {item, isSelection} = this.props;
let className = 'mentions__name';
if (isSelection) {
className += ' suggestion--selected';
}
return (
<div
onClick={this.handleClick}
className={className}
>
<div className='status'><i className='fa fa-group'/></div>
{item.display_name}
</div>
);
}
}
let prefix = '';
function quickSwitchSorter(a, b) {
const aDisplayName = a.display_name.toLowerCase();
const bDisplayName = b.display_name.toLowerCase();
const aStartsWith = aDisplayName.startsWith(prefix);
const bStartsWith = bDisplayName.startsWith(prefix);
if (aStartsWith && bStartsWith) {
const locale = LocalizationStore.getLocale();
if (aDisplayName !== bDisplayName) {
return aDisplayName.localeCompare(bDisplayName, locale, {numeric: true});
}
return a.name.localeCompare(b.name, locale, {numeric: true});
} else if (aStartsWith) {
return -1;
}
return 1;
}
export default class SwitchTeamProvider extends Provider {
handlePretextChanged(suggestionId, teamPrefix) {
if (teamPrefix) {
prefix = teamPrefix;
this.startNewRequest(suggestionId, teamPrefix);
const allTeams = Selectors.getMyTeams(getState());
const teams = allTeams.filter((team) => {
return team.display_name.toLowerCase().indexOf(teamPrefix) !== -1 ||
team.name.indexOf(teamPrefix) !== -1;
});
const teamNames = teams.
sort(quickSwitchSorter).
map((team) => team.name);
setTimeout(() => {
AppDispatcher.handleServerAction({
type: ActionTypes.SUGGESTION_RECEIVED_SUGGESTIONS,
id: suggestionId,
matchedPretext: teamPrefix,
terms: teamNames,
items: teams,
component: SwitchTeamSuggestion
});
}, 0);
return true;
}
return false;
}
}