PLT-5049 (Webapp) New Channel Members UI. (#5036)

This replaces the existing Channel Members UI with one based on the Team
Members UI, so that either a button, a role or a role with a menu can be
displayed.

Basic logic for which actions and roles are displayed is implemented,
although this doesn't change behaviour or functionality at all, as that
will come in later PRs. It does, however, add code to fetch the
ChannelMember objects as that is necessary to provide the full set of
actions and roles as intended.
This commit is contained in:
George Goldberg
2017-01-15 15:40:43 +00:00
committed by Joram Wilander
parent 2010f74a21
commit 95251258f5
10 changed files with 543 additions and 150 deletions

View File

@@ -108,6 +108,9 @@ export function removeUserFromChannel(channelId, userId, success, error) {
}
UserStore.emitInChannelChange();
ChannelStore.removeMemberInChannel(channelId, userId);
ChannelStore.emitChange();
if (success) {
success(data);
}

View File

@@ -58,6 +58,34 @@ export function loadProfilesAndTeamMembers(offset, limit, teamId = TeamStore.get
);
}
export function loadProfilesAndTeamMembersAndChannelMembers(offset, limit, teamId = TeamStore.getCurrentId(), channelId = ChannelStore.getCurrentId(), success, error) {
Client.getProfilesInChannel(
channelId,
offset,
limit,
(data) => {
AppDispatcher.handleServerAction({
type: ActionTypes.RECEIVED_PROFILES_IN_CHANNEL,
profiles: data,
channel_id: channelId,
offset,
count: Object.keys(data).length
});
loadTeamMembersForProfilesMap(
data,
teamId,
() => {
loadChannelMembersForProfilesMap(data, channelId, success, error);
loadStatusesForProfilesMap(data);
});
},
(err) => {
AsyncClient.dispatchError(err, 'getProfilesInChannel');
}
);
}
export function loadTeamMembersForProfilesMap(profiles, teamId = TeamStore.getCurrentId(), success, error) {
const membersToLoad = {};
for (const pid in profiles) {
@@ -132,6 +160,86 @@ function loadTeamMembersForProfiles(userIds, teamId, success, error) {
);
}
export function loadChannelMembersForProfilesMap(profiles, channelId = ChannelStore.getCurrentId(), success, error) {
const membersToLoad = {};
for (const pid in profiles) {
if (!profiles.hasOwnProperty(pid)) {
continue;
}
if (!ChannelStore.hasActiveMemberInChannel(channelId, pid)) {
membersToLoad[pid] = true;
}
}
const list = Object.keys(membersToLoad);
if (list.length === 0) {
if (success) {
success({});
}
return;
}
loadChannelMembersForProfiles(list, channelId, success, error);
}
export function loadTeamMembersAndChannelMembersForProfilesList(profiles, teamId = TeamStore.getCurrentId(), channelId = ChannelStore.getCurrentId(), success, error) {
loadTeamMembersForProfilesList(profiles, teamId, () => {
loadChannelMembersForProfilesList(profiles, channelId, success, error);
}, error);
}
export function loadChannelMembersForProfilesList(profiles, channelId = ChannelStore.getCurrentId(), success, error) {
const membersToLoad = {};
for (let i = 0; i < profiles.length; i++) {
const pid = profiles[i].id;
if (!ChannelStore.hasActiveMemberInChannel(channelId, pid)) {
membersToLoad[pid] = true;
}
}
const list = Object.keys(membersToLoad);
if (list.length === 0) {
if (success) {
success({});
}
return;
}
loadChannelMembersForProfiles(list, channelId, success, error);
}
function loadChannelMembersForProfiles(userIds, channelId, success, error) {
Client.getChannelMembersByIds(
channelId,
userIds,
(data) => {
const memberMap = {};
for (let i = 0; i < data.length; i++) {
memberMap[data[i].user_id] = data[i];
}
AppDispatcher.handleServerAction({
type: ActionTypes.RECEIVED_MEMBERS_IN_CHANNEL,
channel_id: channelId,
channel_members: memberMap
});
if (success) {
success(data);
}
},
(err) => {
AsyncClient.dispatchError(err, 'getChannelMembersByIds');
if (error) {
error(err);
}
}
);
}
function populateDMChannelsWithProfiles(userIds) {
const currentUserId = UserStore.getCurrentId();

View File

@@ -1496,6 +1496,16 @@ export default class Client {
end(this.handleResponse.bind(this, 'getChannelMember', success, error));
}
getChannelMembersByIds(channelId, userIds, success, error) {
request.
post(`${this.getChannelNeededRoute(channelId)}/members/ids`).
set(this.defaultHeaders).
type('application/json').
accept('application/json').
send(userIds).
end(this.handleResponse.bind(this, 'getChannelMembersByIds', success, error));
}
addChannelMember(channelId, userId, success, error) {
request.
post(`${this.getChannelNeededRoute(channelId)}/add`).

View File

@@ -0,0 +1,180 @@
// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import ChannelStore from 'stores/channel_store.jsx';
import TeamStore from 'stores/team_store.jsx';
import UserStore from 'stores/user_store.jsx';
import {removeUserFromChannel} from 'actions/channel_actions.jsx';
import * as AsyncClient from 'utils/async_client.jsx';
import * as Utils from 'utils/utils.jsx';
import React from 'react';
import {FormattedMessage} from 'react-intl';
export default class ChannelMembersDropdown extends React.Component {
constructor(props) {
super(props);
this.handleRemoveFromChannel = this.handleRemoveFromChannel.bind(this);
this.state = {
serverError: null,
user: null,
role: null
};
}
handleRemoveFromChannel() {
removeUserFromChannel(
this.props.channel.id,
this.props.user.id,
() => {
AsyncClient.getChannelStats(this.props.channel.id);
},
(err) => {
this.setState({serverError: err.message});
}
);
}
// Checks if the user this menu is for is a channel admin or not.
isChannelAdmin() {
if (Utils.isChannelAdmin(this.props.channelMember.roles)) {
return true;
}
return false;
}
// Checks if the current user has the power to change the roles of this member.
canChangeMemberRoles() {
if (UserStore.isSystemAdminForCurrentUser()) {
return true;
} else if (TeamStore.isTeamAdminForCurrentTeam()) {
return true;
} else if (ChannelStore.isChannelAdminForCurrentChannel()) {
return true;
}
return false;
}
// Checks if the current user has the power to remove this member from the channel.
canRemoveMember() {
// TODO: This will be implemented as part of PLT-5047.
return true;
}
render() {
let serverError = null;
if (this.state.serverError) {
serverError = (
<div className='has-error'>
<label className='has-error control-label'>{this.state.serverError}</label>
</div>
);
}
if (this.props.user.id === UserStore.getCurrentId()) {
return null;
}
if (this.canChangeMemberRoles()) {
let role = (
<FormattedMessage
id='channel_members_dropdown.channel_member'
defaultMessage='Channel Member'
/>
);
if (this.isChannelAdmin()) {
role = (
<FormattedMessage
id='channel_members_dropdown.channel_admin'
defaultMessage='Channel Admin'
/>
);
}
let removeFromChannel = null;
if (this.canRemoveMember()) {
removeFromChannel = (
<li role='presentation'>
<a
role='menuitem'
href='#'
onClick={this.handleRemoveFromChannel}
>
<FormattedMessage
id='channel_members_dropdown.remove_from_channel'
defaultMessage='Remove From Channel'
/>
</a>
</li>
);
}
return (
<div className='dropdown member-drop'>
<a
href='#'
className='dropdown-toggle theme'
type='button'
data-toggle='dropdown'
aria-expanded='true'
>
<span>{role} </span>
<span className='fa fa-chevron-down'/>
</a>
<ul
className='dropdown-menu member-menu'
role='menu'
>
{removeFromChannel}
</ul>
{serverError}
</div>
);
} else if (this.canRemoveMember()) {
return (
<button
type='button'
className='btn btn-danger btn-message'
onClick={this.handleRemoveFromChannel}
>
<FormattedMessage
id='channel_members_dropdown.remove_member'
defaultMessage='Remove Member'
/>
</button>
);
} else if (this.isChannelAdmin()) {
return (
<div>
<FormattedMessage
id='channel_members_dropdown.channel_admin'
defaultMessage='Channel Admin'
/>
</div>
);
}
return (
<div>
<FormattedMessage
id='channel_members_dropdown.channel_member'
defaultMessage='Channel Member'
/>
</div>
);
}
}
ChannelMembersDropdown.propTypes = {
channel: React.PropTypes.object.isRequired,
user: React.PropTypes.object.isRequired,
teamMember: React.PropTypes.object.isRequired,
channelMember: React.PropTypes.object.isRequired
};

View File

@@ -1,170 +1,29 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import SearchableUserList from './searchable_user_list.jsx';
import LoadingScreen from './loading_screen.jsx';
import UserStore from 'stores/user_store.jsx';
import ChannelStore from 'stores/channel_store.jsx';
import TeamStore from 'stores/team_store.jsx';
import {searchUsers} from 'actions/user_actions.jsx';
import {removeUserFromChannel} from 'actions/channel_actions.jsx';
import * as AsyncClient from 'utils/async_client.jsx';
import * as UserAgent from 'utils/user_agent.jsx';
import Constants from 'utils/constants.jsx';
import MemberListChannel from './member_list_channel.jsx';
import React from 'react';
import {Modal} from 'react-bootstrap';
import {FormattedMessage} from 'react-intl';
const USERS_PER_PAGE = 50;
export default class ChannelMembersModal extends React.Component {
constructor(props) {
super(props);
this.onChange = this.onChange.bind(this);
this.onStatusChange = this.onStatusChange.bind(this);
this.onHide = this.onHide.bind(this);
this.handleRemove = this.handleRemove.bind(this);
this.createRemoveMemberButton = this.createRemoveMemberButton.bind(this);
this.search = this.search.bind(this);
this.nextPage = this.nextPage.bind(this);
this.term = '';
this.searchTimeoutId = 0;
const stats = ChannelStore.getStats(props.channel.id);
this.state = {
users: [],
total: stats.member_count,
show: true,
search: false,
statusChange: false
channel: this.props.channel,
show: true
};
}
componentDidMount() {
ChannelStore.addStatsChangeListener(this.onChange);
UserStore.addInChannelChangeListener(this.onChange);
UserStore.addStatusesChangeListener(this.onStatusChange);
AsyncClient.getProfilesInChannel(this.props.channel.id, 0);
}
componentWillUnmount() {
ChannelStore.removeStatsChangeListener(this.onChange);
UserStore.removeInChannelChangeListener(this.onChange);
UserStore.removeStatusesChangeListener(this.onStatusChange);
}
onChange(force) {
if (this.state.search && !force) {
this.search(this.term);
return;
}
const stats = ChannelStore.getStats(this.props.channel.id);
this.setState({
users: UserStore.getProfileListInChannel(this.props.channel.id),
total: stats.member_count
});
}
onStatusChange() {
// Initiate a render to pick up on new statuses
this.setState({
statusChange: !this.state.statusChange
});
}
onHide() {
this.setState({show: false});
}
handleRemove(user) {
const userId = user.id;
removeUserFromChannel(
this.props.channel.id,
userId,
null,
(err) => {
this.setState({inviteError: err.message});
}
);
}
createRemoveMemberButton({user}) {
if (user.id === UserStore.getCurrentId()) {
return null;
}
return (
<button
type='button'
className='btn btn-primary btn-message'
onClick={this.handleRemove.bind(this, user)}
>
<FormattedMessage
id='channel_members_modal.remove'
defaultMessage='Remove'
/>
</button>
);
}
nextPage(page) {
AsyncClient.getProfilesInChannel(this.props.channel.id, (page + 1) * USERS_PER_PAGE, USERS_PER_PAGE);
}
search(term) {
this.term = term;
if (term === '') {
this.onChange(true);
this.setState({search: false});
return;
}
clearTimeout(this.searchTimeoutId);
this.searchTimeoutId = setTimeout(
() => {
searchUsers(
term,
TeamStore.getCurrentId(),
{in_channel_id: this.props.channel.id},
(users) => {
this.setState({search: true, users});
}
);
},
Constants.SEARCH_TIMEOUT_MILLISECONDS
);
}
render() {
let content;
if (this.state.loading) {
content = (<LoadingScreen/>);
} else {
content = (
<SearchableUserList
users={this.state.users}
usersPerPage={USERS_PER_PAGE}
total={this.state.total}
nextPage={this.nextPage}
search={this.search}
actions={[this.createRemoveMemberButton]}
focusOnMount={!UserAgent.isMobile()}
/>
);
}
return (
<div>
<Modal
@@ -177,7 +36,7 @@ export default class ChannelMembersModal extends React.Component {
<Modal.Title>
<span className='name'>{this.props.channel.display_name}</span>
<FormattedMessage
id='channel_memebers_modal.members'
id='channel_members_modal.members'
defaultMessage=' Members'
/>
</Modal.Title>
@@ -198,7 +57,9 @@ export default class ChannelMembersModal extends React.Component {
<Modal.Body
ref='modalBody'
>
{content}
<MemberListChannel
channel={this.props.channel}
/>
</Modal.Body>
</Modal>
</div>

View File

@@ -0,0 +1,173 @@
// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import ChannelMembersDropdown from 'components/channel_members_dropdown.jsx';
import SearchableUserList from 'components/searchable_user_list.jsx';
import ChannelStore from 'stores/channel_store.jsx';
import UserStore from 'stores/user_store.jsx';
import TeamStore from 'stores/team_store.jsx';
import {searchUsers, loadProfilesAndTeamMembersAndChannelMembers, loadTeamMembersAndChannelMembersForProfilesList} from 'actions/user_actions.jsx';
import {getChannelStats} from 'utils/async_client.jsx';
import Constants from 'utils/constants.jsx';
import * as UserAgent from 'utils/user_agent.jsx';
import React from 'react';
const USERS_PER_PAGE = 50;
export default class MemberListChannel extends React.Component {
constructor(props) {
super(props);
this.onChange = this.onChange.bind(this);
this.onStatsChange = this.onStatsChange.bind(this);
this.search = this.search.bind(this);
this.loadComplete = this.loadComplete.bind(this);
this.searchTimeoutId = 0;
const stats = ChannelStore.getCurrentStats();
this.state = {
users: UserStore.getProfileListInChannel(),
teamMembers: Object.assign({}, TeamStore.getMembersInTeam()),
channelMembers: Object.assign({}, ChannelStore.getMembersInChannel()),
total: stats.member_count,
search: false,
term: '',
loading: true
};
}
componentDidMount() {
UserStore.addInTeamChangeListener(this.onChange);
UserStore.addStatusesChangeListener(this.onChange);
TeamStore.addChangeListener(this.onChange);
ChannelStore.addChangeListener(this.onChange);
ChannelStore.addStatsChangeListener(this.onStatsChange);
loadProfilesAndTeamMembersAndChannelMembers(0, Constants.PROFILE_CHUNK_SIZE, TeamStore.getCurrentId(), ChannelStore.getCurrentId(), this.loadComplete);
getChannelStats(ChannelStore.getCurrentId());
}
componentWillUnmount() {
UserStore.removeInTeamChangeListener(this.onChange);
UserStore.removeStatusesChangeListener(this.onChange);
TeamStore.removeChangeListener(this.onChange);
ChannelStore.removeChangeListener(this.onChange);
ChannelStore.removeStatsChangeListener(this.onStatsChange);
}
loadComplete() {
this.setState({loading: false});
}
onChange(force) {
if (this.state.search && !force) {
return;
} else if (this.state.search) {
this.search(this.state.term);
return;
}
this.setState({
users: UserStore.getProfileListInChannel(),
teamMembers: Object.assign({}, TeamStore.getMembersInTeam()),
channelMembers: Object.assign({}, ChannelStore.getMembersInChannel())
});
}
onStatsChange() {
const stats = ChannelStore.getCurrentStats();
this.setState({total: stats.member_count});
}
nextPage(page) {
loadProfilesAndTeamMembersAndChannelMembers((page + 1) * USERS_PER_PAGE, USERS_PER_PAGE);
}
search(term) {
if (term === '') {
this.setState({
search: false,
term,
users: UserStore.getProfileListInChannel(),
teamMembers: Object.assign([], TeamStore.getMembersInTeam()),
channelMembers: Object.assign([], ChannelStore.getMembersInChannel())
});
return;
}
clearTimeout(this.searchTimeoutId);
this.searchTimeoutId = setTimeout(
() => {
searchUsers(
term,
TeamStore.getCurrentId(),
{},
(users) => {
this.setState({
loading: true,
search: true,
users,
term,
teamMembers: Object.assign([], TeamStore.getMembersInTeam()),
channelMembers: Object.assign([], ChannelStore.getMembersInChannel())
});
loadTeamMembersAndChannelMembersForProfilesList(users, TeamStore.getCurrentId(), ChannelStore.getCurrentId(), this.loadComplete);
}
);
},
Constants.SEARCH_TIMEOUT_MILLISECONDS
);
}
render() {
const teamMembers = this.state.teamMembers;
const channelMembers = this.state.channelMembers;
const users = this.state.users;
const actionUserProps = {};
let usersToDisplay;
if (this.state.loading) {
usersToDisplay = null;
} else {
usersToDisplay = [];
for (let i = 0; i < users.length; i++) {
const user = users[i];
if (teamMembers[user.id] && channelMembers[user.id]) {
usersToDisplay.push(user);
actionUserProps[user.id] = {
channel: this.props.channel,
teamMember: teamMembers[user.id],
channelMember: channelMembers[user.id]
};
}
}
}
return (
<SearchableUserList
users={usersToDisplay}
usersPerPage={USERS_PER_PAGE}
total={this.state.total}
nextPage={this.nextPage}
search={this.search}
actions={[ChannelMembersDropdown]}
actionUserProps={actionUserProps}
focusOnMount={!UserAgent.isMobile()}
/>
);
}
}
MemberListChannel.propTypes = {
channel: React.PropTypes.object.isRequired
};

View File

@@ -1082,10 +1082,12 @@
"channel_loader.uploadedFile": " uploaded a file",
"channel_loader.uploadedImage": " uploaded an image",
"channel_loader.wrote": " wrote: ",
"channel_members_dropdown.channel_admin": "Channel Admin",
"channel_members_dropdown.channel_member": "Channel Member",
"channel_members_dropdown.remove_from_channel": "Remove From Channel",
"channel_members_dropdown.remove_member": "Remove Member",
"channel_members_modal.addNew": " Add New Members",
"channel_members_modal.close": "Close",
"channel_members_modal.remove": "Remove",
"channel_memebers_modal.members": " Members",
"channel_members_modal.members": " Members",
"channel_modal.cancel": "Cancel",
"channel_modal.channel": "Channel",
"channel_modal.createNew": "Create New ",

View File

@@ -5,6 +5,7 @@ import AppDispatcher from '../dispatcher/app_dispatcher.jsx';
import EventEmitter from 'events';
import TeamStore from 'stores/team_store.jsx';
import UserStore from 'stores/user_store.jsx';
var Utils;
import {ActionTypes, Constants} from 'utils/constants.jsx';
@@ -25,6 +26,7 @@ class ChannelStoreClass extends EventEmitter {
this.currentId = null;
this.postMode = this.POST_MODE_CHANNEL;
this.channels = [];
this.members_in_channel = {};
this.myChannelMembers = {};
this.moreChannels = {};
this.stats = {};
@@ -241,6 +243,29 @@ class ChannelStoreClass extends EventEmitter {
return this.myChannelMembers;
}
saveMembersInChannel(channelId = this.getCurrentId(), members) {
const oldMembers = this.members_in_channel[channelId] || {};
this.members_in_channel[channelId] = Object.assign({}, oldMembers, members);
}
removeMemberInChannel(channelId = this.getCurrentId(), userId) {
if (this.members_in_channel[channelId]) {
Reflect.deleteProperty(this.members_in_channel[channelId], userId);
}
}
getMembersInChannel(channelId = this.getCurrentId()) {
return Object.assign({}, this.members_in_channel[channelId]) || {};
}
hasActiveMemberInChannel(channelId = this.getCurrentId(), userId) {
if (this.members_in_channel[channelId] && this.members_in_channel[channelId][userId]) {
return true;
}
return false;
}
storeMoreChannels(channels, teamId = TeamStore.getCurrentId()) {
const newChannels = {};
for (let i = 0; i < channels.length; i++) {
@@ -343,6 +368,25 @@ class ChannelStoreClass extends EventEmitter {
return channelNamesMap;
}
isChannelAdminForCurrentChannel() {
return this.isChannelAdmin(UserStore.getCurrentId(), this.getCurrentId());
}
isChannelAdmin(userId, channelId) {
if (!Utils) {
Utils = require('utils/utils.jsx'); //eslint-disable-line global-require
}
const channelMembers = this.getMembersInChannel(channelId);
const channelMember = channelMembers[userId];
if (channelMember) {
return Utils.isChannelAdmin(channelMember.roles);
}
return false;
}
}
var ChannelStore = new ChannelStoreClass();
@@ -409,7 +453,10 @@ ChannelStore.dispatchToken = AppDispatcher.register((payload) => {
ChannelStore.storeMoreChannels(action.channels);
ChannelStore.emitChange();
break;
case ActionTypes.RECEIVED_MEMBERS_IN_CHANNEL:
ChannelStore.saveMembersInChannel(action.channel_id, action.channel_members);
ChannelStore.emitChange();
break;
case ActionTypes.RECEIVED_CHANNEL_STATS:
var stats = Object.assign({}, ChannelStore.getStats());
stats[action.stats.channel_id] = action.stats;

View File

@@ -74,6 +74,7 @@ export const ActionTypes = keyMirror({
RECEIVED_MORE_CHANNELS: null,
RECEIVED_CHANNEL_STATS: null,
RECEIVED_MY_CHANNEL_MEMBERS: null,
RECEIVED_MEMBERS_IN_CHANNEL: null,
FOCUS_POST: null,
RECEIVED_POSTS: null,

View File

@@ -54,6 +54,14 @@ export function isInRole(roles, inRole) {
return false;
}
export function isChannelAdmin(roles) {
if (isInRole(roles, 'channel_admin')) {
return true;
}
return false;
}
export function isAdmin(roles) {
if (isInRole(roles, 'team_admin')) {
return true;