From 95251258f513d076c99da164e607dae5c39b9275 Mon Sep 17 00:00:00 2001 From: George Goldberg Date: Sun, 15 Jan 2017 15:40:43 +0000 Subject: 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. --- webapp/actions/channel_actions.jsx | 3 + webapp/actions/user_actions.jsx | 108 +++++++++++++++ webapp/client/client.jsx | 10 ++ webapp/components/channel_members_dropdown.jsx | 180 +++++++++++++++++++++++++ webapp/components/channel_members_modal.jsx | 153 +-------------------- webapp/components/member_list_channel.jsx | 173 ++++++++++++++++++++++++ webapp/i18n/en.json | 8 +- webapp/stores/channel_store.jsx | 49 ++++++- webapp/utils/constants.jsx | 1 + webapp/utils/utils.jsx | 8 ++ 10 files changed, 543 insertions(+), 150 deletions(-) create mode 100644 webapp/components/channel_members_dropdown.jsx create mode 100644 webapp/components/member_list_channel.jsx diff --git a/webapp/actions/channel_actions.jsx b/webapp/actions/channel_actions.jsx index 71f5abf84..6cf8449f0 100644 --- a/webapp/actions/channel_actions.jsx +++ b/webapp/actions/channel_actions.jsx @@ -108,6 +108,9 @@ export function removeUserFromChannel(channelId, userId, success, error) { } UserStore.emitInChannelChange(); + ChannelStore.removeMemberInChannel(channelId, userId); + ChannelStore.emitChange(); + if (success) { success(data); } diff --git a/webapp/actions/user_actions.jsx b/webapp/actions/user_actions.jsx index a5cef65d8..588e77a0d 100644 --- a/webapp/actions/user_actions.jsx +++ b/webapp/actions/user_actions.jsx @@ -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(); diff --git a/webapp/client/client.jsx b/webapp/client/client.jsx index ba42d7ae8..5104f34e2 100644 --- a/webapp/client/client.jsx +++ b/webapp/client/client.jsx @@ -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`). diff --git a/webapp/components/channel_members_dropdown.jsx b/webapp/components/channel_members_dropdown.jsx new file mode 100644 index 000000000..82aaf0612 --- /dev/null +++ b/webapp/components/channel_members_dropdown.jsx @@ -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 = ( +
+ +
+ ); + } + + if (this.props.user.id === UserStore.getCurrentId()) { + return null; + } + + if (this.canChangeMemberRoles()) { + let role = ( + + ); + + if (this.isChannelAdmin()) { + role = ( + + ); + } + + let removeFromChannel = null; + if (this.canRemoveMember()) { + removeFromChannel = ( +
  • + + + +
  • + ); + } + + return ( +
    + + {role} + + +
      + {removeFromChannel} +
    + {serverError} +
    + ); + } else if (this.canRemoveMember()) { + return ( + + ); + } else if (this.isChannelAdmin()) { + return ( +
    + +
    + ); + } + + return ( +
    + +
    + ); + } +} + +ChannelMembersDropdown.propTypes = { + channel: React.PropTypes.object.isRequired, + user: React.PropTypes.object.isRequired, + teamMember: React.PropTypes.object.isRequired, + channelMember: React.PropTypes.object.isRequired +}; diff --git a/webapp/components/channel_members_modal.jsx b/webapp/components/channel_members_modal.jsx index 351efed96..96d90e5cc 100644 --- a/webapp/components/channel_members_modal.jsx +++ b/webapp/components/channel_members_modal.jsx @@ -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 ( - - ); - } - - 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 = (); - } else { - content = ( - - ); - } - return (
    {this.props.channel.display_name} @@ -198,7 +57,9 @@ export default class ChannelMembersModal extends React.Component { - {content} +
    diff --git a/webapp/components/member_list_channel.jsx b/webapp/components/member_list_channel.jsx new file mode 100644 index 000000000..75ecd8172 --- /dev/null +++ b/webapp/components/member_list_channel.jsx @@ -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 ( + + ); + } +} + +MemberListChannel.propTypes = { + channel: React.PropTypes.object.isRequired +}; diff --git a/webapp/i18n/en.json b/webapp/i18n/en.json index 6584c634a..942ab62f3 100644 --- a/webapp/i18n/en.json +++ b/webapp/i18n/en.json @@ -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 ", diff --git a/webapp/stores/channel_store.jsx b/webapp/stores/channel_store.jsx index c93edf7f4..c1922d3a6 100644 --- a/webapp/stores/channel_store.jsx +++ b/webapp/stores/channel_store.jsx @@ -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; diff --git a/webapp/utils/constants.jsx b/webapp/utils/constants.jsx index af099d4a0..c8ac74a82 100644 --- a/webapp/utils/constants.jsx +++ b/webapp/utils/constants.jsx @@ -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, diff --git a/webapp/utils/utils.jsx b/webapp/utils/utils.jsx index e7ed9567b..9654ff605 100644 --- a/webapp/utils/utils.jsx +++ b/webapp/utils/utils.jsx @@ -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; -- cgit v1.2.3-1-g7c22