diff --git a/webapp/actions/team_actions.jsx b/webapp/actions/team_actions.jsx index 4cb57961b5..b091692f82 100644 --- a/webapp/actions/team_actions.jsx +++ b/webapp/actions/team_actions.jsx @@ -114,6 +114,31 @@ export function addUserToTeamFromInvite(data, hash, inviteId, success, error) { ); } +export function addUsersToTeam(teamId, userIds, success, error) { + Client.addUsersToTeam( + teamId, + userIds, + (teamMembers) => { + teamMembers.forEach((member) => { + TeamStore.removeMemberNotInTeam(teamId, member.user_id); + UserStore.removeProfileNotInTeam(teamId, member.user_id); + }); + UserStore.emitNotInTeamChange(); + + if (success) { + success(teamMembers); + } + }, + (err) => { + AsyncClient.dispatchError(err, 'addUsersToTeam'); + + if (error) { + error(err); + } + } + ); +} + export function getInviteInfo(inviteId, success, error) { Client.getInviteInfo( inviteId, diff --git a/webapp/actions/user_actions.jsx b/webapp/actions/user_actions.jsx index b9d4ec3769..1ab85922d5 100644 --- a/webapp/actions/user_actions.jsx +++ b/webapp/actions/user_actions.jsx @@ -492,6 +492,28 @@ export function searchUsers(term, teamId = TeamStore.getCurrentId(), options = { ); } +export function searchUsersNotInTeam(term, teamId = TeamStore.getCurrentId(), options = {}, success, error) { + Client.searchUsersNotInTeam( + term, + teamId, + options, + (data) => { + loadStatusesForProfilesList(data); + + if (success) { + success(data); + } + }, + (err) => { + AsyncClient.dispatchError(err, 'searchUsersNotInTeam'); + + if (error) { + error(err); + } + } + ); +} + export function autocompleteUsersInChannel(username, channelId, success, error) { Client.autocompleteUsersInChannel( username, diff --git a/webapp/actions/websocket_actions.jsx b/webapp/actions/websocket_actions.jsx index ab798df28c..e9ebea4720 100644 --- a/webapp/actions/websocket_actions.jsx +++ b/webapp/actions/websocket_actions.jsx @@ -153,6 +153,10 @@ function handleEvent(msg) { handleUpdateTeamEvent(msg); break; + case SocketEvents.ADDED_TO_TEAM: + handleTeamAddedEvent(msg); + break; + case SocketEvents.USER_ADDED: handleUserAddedEvent(msg); break; @@ -241,6 +245,27 @@ function handlePostDeleteEvent(msg) { GlobalActions.emitPostDeletedEvent(post); } +function handleTeamAddedEvent(msg) { + Client.getTeam(msg.data.team_id, (team) => { + AppDispatcher.handleServerAction({ + type: ActionTypes.RECEIVED_TEAM, + team + }); + + Client.getMyTeamMembers((data) => { + AppDispatcher.handleServerAction({ + type: ActionTypes.RECEIVED_MY_TEAM_MEMBERS, + team_members: data + }); + AsyncClient.getMyTeamsUnread(); + }, (err) => { + AsyncClient.dispatchError(err, 'getMyTeamMembers'); + }); + }, (err) => { + AsyncClient.dispatchError(err, 'getTeam'); + }); +} + function handleLeaveTeamEvent(msg) { if (UserStore.getCurrentId() === msg.data.user_id) { TeamStore.removeMyTeamMember(msg.data.team_id); diff --git a/webapp/client/client.jsx b/webapp/client/client.jsx index 1f70300e8f..c1a9d2f85a 100644 --- a/webapp/client/client.jsx +++ b/webapp/client/client.jsx @@ -489,6 +489,15 @@ export default class Client { // Team Routes Section + getTeam(teamId, success, error) { + request. + get(`${this.getTeamsRoute()}/${teamId}/me`). + set(this.defaultHeaders). + type('application/json'). + accept('application/json'). + end(this.handleResponse.bind(this, 'getTeam', success, error)); + } + findTeamByName(teamName, success, error) { request. post(`${this.getTeamsRoute()}/find_team_by_name`). @@ -681,6 +690,30 @@ export default class Client { this.trackEvent('api', 'api_teams_invite_members'); } + addUsersToTeam(teamId, userIds, success, error) { + let nonEmptyTeamId = teamId; + if (nonEmptyTeamId === '') { + nonEmptyTeamId = this.getTeamId(); + } + + const teamMembers = userIds.map((userId) => { + return { + team_id: nonEmptyTeamId, + user_id: userId + }; + }); + + request. + post(`${this.url}/api/v4/teams/${nonEmptyTeamId}/members/batch`). + set(this.defaultHeaders). + type('application/json'). + accept('application/json'). + send(teamMembers). + end(this.handleResponse.bind(this, 'addUsersToTeam', success, error)); + + this.trackEvent('api', 'api_teams_batch_add_members', {team_id: nonEmptyTeamId, count: teamMembers.length}); + } + removeUserFromTeam(teamId, userId, success, error) { let nonEmptyTeamId = teamId; if (nonEmptyTeamId === '') { @@ -1124,6 +1157,29 @@ export default class Client { this.trackEvent('api', 'api_profiles_get_in_team', {team_id: teamId}); } + getProfilesNotInTeam(teamId, offset, limit, success, error) { + // Super hacky, but this option only exists in api v4 + function wrappedSuccess(data, res) { + // Convert the profile list provided by api v4 to a map to match similar v3 calls + const profiles = {}; + + for (const profile of data) { + profiles[profile.id] = profile; + } + + success(profiles, res); + } + + request. + get(`${this.url}/api/v4/users?not_in_team=${this.getTeamId()}&page=${offset}&per_page=${limit}`). + set(this.defaultHeaders). + type('application/json'). + accept('application/json'). + end(this.handleResponse.bind(this, 'getProfilesNotInTeam', wrappedSuccess, error)); + + this.trackEvent('api', 'api_profiles_get_not_in_team', {team_id: teamId}); + } + getProfilesInChannel(channelId, offset, limit, success, error) { request. get(`${this.getChannelNeededRoute(channelId)}/users/${offset}/${limit}`). @@ -1191,6 +1247,19 @@ export default class Client { end(this.handleResponse.bind(this, 'searchUsers', success, error)); } + searchUsersNotInTeam(term, teamId, options, success, error) { + // Note that this is calling an APIv4 Endpoint since no APIv3 equivalent exists. + request. + post(`${this.url}/api/v4/users/search`). + set(this.defaultHeaders). + type('application/json'). + accept('application/json'). + send({term, not_in_team_id: teamId, ...options}). + end(this.handleResponse.bind(this, 'searchUsersNotInTeam', success, error)); + + this.trackEvent('api', 'api_search_users_not_in_team', {team_id: teamId}); + } + autocompleteUsersInChannel(term, channelId, success, error) { request. get(`${this.getChannelNeededRoute(channelId)}/users/autocomplete?term=${encodeURIComponent(term)}`). diff --git a/webapp/components/add_users_to_team.jsx b/webapp/components/add_users_to_team.jsx new file mode 100644 index 0000000000..fce651d9f5 --- /dev/null +++ b/webapp/components/add_users_to_team.jsx @@ -0,0 +1,270 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import MultiSelect from 'components/multiselect/multiselect.jsx'; +import ProfilePicture from 'components/profile_picture.jsx'; + +import {addUsersToTeam} from 'actions/team_actions.jsx'; +import {searchUsersNotInTeam} from 'actions/user_actions.jsx'; + +import UserStore from 'stores/user_store.jsx'; +import TeamStore from 'stores/team_store.jsx'; + +import * as AsyncClient from 'utils/async_client.jsx'; +import Constants from 'utils/constants.jsx'; +import {displayUsernameForUser} from 'utils/utils.jsx'; +import Client from 'client/web_client.jsx'; + +import React from 'react'; +import {Modal} from 'react-bootstrap'; +import {FormattedMessage} from 'react-intl'; +import {browserHistory} from 'react-router/es6'; + +const USERS_PER_PAGE = 50; +const MAX_SELECTABLE_VALUES = 20; + +export default class AddUsersToTeam extends React.Component { + constructor(props) { + super(props); + + this.handleHide = this.handleHide.bind(this); + this.handleExit = this.handleExit.bind(this); + this.handleSubmit = this.handleSubmit.bind(this); + this.handleDelete = this.handleDelete.bind(this); + this.onChange = this.onChange.bind(this); + this.search = this.search.bind(this); + this.addValue = this.addValue.bind(this); + + this.searchTimeoutId = 0; + + this.state = { + users: null, + values: [], + show: true, + search: false + }; + } + + componentDidMount() { + UserStore.addChangeListener(this.onChange); + UserStore.addNotInTeamChangeListener(this.onChange); + UserStore.addStatusesChangeListener(this.onChange); + + AsyncClient.getProfilesNotInTeam(TeamStore.getCurrentId(), 0, USERS_PER_PAGE * 2); + } + + componentWillUnmount() { + UserStore.removeChangeListener(this.onChange); + UserStore.removeNotInTeamChangeListener(this.onChange); + UserStore.removeStatusesChangeListener(this.onChange); + } + + handleHide() { + this.setState({show: false}); + } + + handleExit() { + if (this.exitToChannel) { + browserHistory.push(this.exitToChannel); + } + + if (this.props.onModalDismissed) { + this.props.onModalDismissed(); + } + } + + handleSubmit(e) { + if (e) { + e.preventDefault(); + } + + const userIds = this.state.values.map((v) => v.id); + if (userIds.length === 0) { + return; + } + + addUsersToTeam(TeamStore.getCurrentId(), userIds); + + this.handleHide(); + } + + addValue(value) { + const values = Object.assign([], this.state.values); + if (values.indexOf(value) === -1) { + values.push(value); + } + + this.setState({values}); + } + + onChange(force) { + if (this.state.search && !force) { + return; + } + + const users = Object.assign([], UserStore.getProfileListNotInTeam(TeamStore.getCurrentId(), true)); + + for (let i = 0; i < users.length; i++) { + const user = Object.assign({}, users[i]); + user.value = user.id; + user.label = '@' + user.username; + users[i] = user; + } + + this.setState({ + users + }); + } + + handlePageChange(page, prevPage) { + if (page > prevPage) { + AsyncClient.getProfilesNotInTeam((page + 1) * USERS_PER_PAGE, USERS_PER_PAGE); + } + } + + search(term) { + clearTimeout(this.searchTimeoutId); + + if (term === '') { + this.onChange(true); + this.setState({search: false}); + this.searchTimeoutId = ''; + return; + } + + const teamId = TeamStore.getCurrentId(); + + const searchTimeoutId = setTimeout( + () => { + searchUsersNotInTeam( + term, + teamId, + {}, + (users) => { + if (searchTimeoutId !== this.searchTimeoutId) { + return; + } + + let indexToDelete = -1; + for (let i = 0; i < users.length; i++) { + if (users[i].id === UserStore.getCurrentId()) { + indexToDelete = i; + } + users[i].value = users[i].id; + users[i].label = '@' + users[i].username; + } + + if (indexToDelete !== -1) { + users.splice(indexToDelete, 1); + } + this.setState({search: true, users}); + } + ); + }, + Constants.SEARCH_TIMEOUT_MILLISECONDS + ); + + this.searchTimeoutId = searchTimeoutId; + } + + handleDelete(values) { + this.setState({values}); + } + + renderOption(option, isSelected, onAdd) { + var rowSelected = ''; + if (isSelected) { + rowSelected = 'more-modal__row--selected'; + } + + return ( +
The Main Menu is where you can Invite New Members, access your Account Settings and set your Theme Color.
Team administrators can also access their Team Settings from this menu.
System administrators will find a System Console option to administrate the entire system.
", "sidebar_right_menu.accountSettings": "Account Settings", + "sidebar_right_menu.addMemberToTeam": "Add Members to Team", "sidebar_right_menu.console": "System Console", "sidebar_right_menu.flagged": "Flagged Posts", "sidebar_right_menu.help": "Help", - "sidebar_right_menu.inviteNew": "Invite New Member", + "sidebar_right_menu.inviteNew": "Send Email Invite", "sidebar_right_menu.logout": "Logout", "sidebar_right_menu.manageMembers": "Manage Members", "sidebar_right_menu.nativeApps": "Download Apps", diff --git a/webapp/stores/team_store.jsx b/webapp/stores/team_store.jsx index 6f81a9345d..a77527d376 100644 --- a/webapp/stores/team_store.jsx +++ b/webapp/stores/team_store.jsx @@ -252,6 +252,12 @@ class TeamStoreClass extends EventEmitter { } } + removeMemberNotInTeam(teamId = this.getCurrentId(), userId) { + if (this.members_not_in_team[teamId]) { + Reflect.deleteProperty(this.members_not_in_team[teamId], userId); + } + } + getMembersInTeam(teamId = this.getCurrentId()) { return Object.assign({}, this.members_in_team[teamId]) || {}; } @@ -365,6 +371,10 @@ TeamStore.dispatchToken = AppDispatcher.register((payload) => { TeamStore.saveMyTeam(action.team); TeamStore.emitChange(); break; + case ActionTypes.RECEIVED_TEAM: + TeamStore.saveTeam(action.team); + TeamStore.emitChange(); + break; case ActionTypes.CREATED_TEAM: TeamStore.saveTeam(action.team); TeamStore.appendMyTeamMember(action.member); diff --git a/webapp/stores/user_store.jsx b/webapp/stores/user_store.jsx index 6f4acae0ab..02adeb7899 100644 --- a/webapp/stores/user_store.jsx +++ b/webapp/stores/user_store.jsx @@ -15,6 +15,7 @@ const UserStatuses = Constants.UserStatuses; const CHANGE_EVENT_NOT_IN_CHANNEL = 'change_not_in_channel'; const CHANGE_EVENT_IN_CHANNEL = 'change_in_channel'; +const CHANGE_EVENT_NOT_IN_TEAM = 'change_not_in_team'; const CHANGE_EVENT_IN_TEAM = 'change_in_team'; const CHANGE_EVENT_WITHOUT_TEAM = 'change_without_team'; const CHANGE_EVENT = 'change'; @@ -36,6 +37,11 @@ class UserStoreClass extends EventEmitter { this.paging_offset = 0; this.paging_count = 0; + // Lists of sorted IDs for users in a team + this.profiles_not_in_team = {}; + this.not_in_team_offset = 0; + this.not_in_team_count = 0; + // Lists of sorted IDs for users in a team this.profiles_in_team = {}; this.in_team_offset = 0; @@ -85,6 +91,18 @@ class UserStoreClass extends EventEmitter { this.removeListener(CHANGE_EVENT_IN_TEAM, callback); } + emitNotInTeamChange() { + this.emit(CHANGE_EVENT_NOT_IN_TEAM); + } + + addNotInTeamChangeListener(callback) { + this.on(CHANGE_EVENT_NOT_IN_TEAM, callback); + } + + removeNotInTeamChangeListener(callback) { + this.removeListener(CHANGE_EVENT_NOT_IN_TEAM, callback); + } + emitInChannelChange() { this.emit(CHANGE_EVENT_IN_CHANNEL); } @@ -373,6 +391,75 @@ class UserStoreClass extends EventEmitter { userIds.splice(index, 1); } + // Not In Team Profiles + + saveProfilesNotInTeam(teamId, profiles) { + const oldProfileList = this.profiles_not_in_team[teamId] || []; + const oldProfileMap = {}; + for (let i = 0; i < oldProfileList.length; i++) { + oldProfileMap[oldProfileList[i]] = this.getProfile(oldProfileList[i]); + } + + const newProfileMap = Object.assign({}, oldProfileMap, profiles); + const newProfileList = Object.keys(newProfileMap); + + newProfileList.sort((a, b) => { + const aProfile = newProfileMap[a]; + const bProfile = newProfileMap[b]; + + if (aProfile.username < bProfile.username) { + return -1; + } + if (aProfile.username > bProfile.username) { + return 1; + } + return 0; + }); + + this.profiles_not_in_team[teamId] = newProfileList; + this.saveProfiles(profiles); + } + + removeProfileNotInTeam(teamId, userId) { + const userIds = this.profiles_not_in_team[teamId]; + if (!userIds) { + return; + } + + const index = userIds.indexOf(userId); + if (index === -1) { + return; + } + + userIds.splice(index, 1); + } + + getProfileListNotInTeam(teamId = TeamStore.getCurrentId(), skipCurrent = false, skipInactive = false) { + const userIds = this.profiles_not_in_team[teamId] || []; + const profiles = []; + const currentId = this.getCurrentId(); + + for (let i = 0; i < userIds.length; i++) { + const profile = this.getProfile(userIds[i]); + + if (!profile) { + continue; + } + + if (skipCurrent && profile.id === currentId) { + continue; + } + + if (skipInactive && profile.delete_at > 0) { + continue; + } + + profiles.push(profile); + } + + return profiles; + } + // Channel-Wide Profiles saveProfilesInChannel(channelId = ChannelStore.getCurrentId(), profiles) { @@ -646,6 +733,19 @@ class UserStoreClass extends EventEmitter { return this.in_team_count; } + setNotInTeamPage(offset, count) { + this.not_in_team_offset = offset + count; + this.not_in_team_count = this.not_in_team_count + count; + } + + getNotInTeamPagingOffset() { + return this.not_in_team_offset; + } + + getNotInTeamPagingCount() { + return this.not_in_team_count; + } + setInChannelPage(channelId, offset, count) { this.in_channel_offset[channelId] = offset + count; this.in_channel_count[channelId] = this.dm_paging_count + count; @@ -694,6 +794,13 @@ UserStore.dispatchToken = AppDispatcher.register((payload) => { } UserStore.emitInTeamChange(); break; + case ActionTypes.RECEIVED_PROFILES_NOT_IN_TEAM: + UserStore.saveProfilesNotInTeam(action.team_id, action.profiles); + if (action.offset != null && action.count != null) { + UserStore.setNotInTeamPage(action.offset, action.count); + } + UserStore.emitNotInTeamChange(); + break; case ActionTypes.RECEIVED_PROFILES_IN_CHANNEL: UserStore.saveProfilesInChannel(action.channel_id, action.profiles); if (action.offset != null && action.count != null) { diff --git a/webapp/utils/async_client.jsx b/webapp/utils/async_client.jsx index b4b361cb4f..faaf3aee65 100644 --- a/webapp/utils/async_client.jsx +++ b/webapp/utils/async_client.jsx @@ -403,6 +403,36 @@ export function getProfilesInTeam(teamId = TeamStore.getCurrentId(), offset = Us ); } +export function getProfilesNotInTeam(teamId = TeamStore.getCurrentId(), offset = UserStore.getInTeamPagingOffset(), limit = Constants.PROFILE_CHUNK_SIZE) { + const callName = `getProfilesNotInTeam${teamId}${offset}${limit}`; + + if (isCallInProgress(callName)) { + return; + } + + callTracker[callName] = utils.getTimestamp(); + Client.getProfilesNotInTeam( + teamId, + offset, + limit, + (data) => { + callTracker[callName] = 0; + + AppDispatcher.handleServerAction({ + type: ActionTypes.RECEIVED_PROFILES_NOT_IN_TEAM, + profiles: data, + team_id: teamId, + offset, + count: Object.keys(data).length + }); + }, + (err) => { + callTracker[callName] = 0; + dispatchError(err, 'getProfilesNotInTeam'); + } + ); +} + export function getProfilesInChannel(channelId = ChannelStore.getCurrentId(), offset = UserStore.getInChannelPagingOffset(), limit = Constants.PROFILE_CHUNK_SIZE) { const callName = `getProfilesInChannel${channelId}${offset}${limit}`; @@ -830,34 +860,6 @@ export function getTeamMember(teamId, userId) { ); } -export function getMyTeamMembers() { - const callName = 'getMyTeamMembers'; - if (isCallInProgress(callName)) { - return; - } - - callTracker[callName] = utils.getTimestamp(); - Client.getMyTeamMembers( - (data) => { - callTracker[callName] = 0; - - const members = {}; - for (const member of data) { - members[member.team_id] = member; - } - - AppDispatcher.handleServerAction({ - type: ActionTypes.RECEIVED_MY_TEAM_MEMBERS_UNREAD, - team_members: members - }); - }, - (err) => { - callTracker[callName] = 0; - dispatchError(err, 'getMyTeamMembers'); - } - ); -} - export function getMyTeamsUnread(teamId) { const members = TeamStore.getMyTeamMembers(); if (members.length > 1) { diff --git a/webapp/utils/constants.jsx b/webapp/utils/constants.jsx index 5b4ab6611d..8428f71213 100644 --- a/webapp/utils/constants.jsx +++ b/webapp/utils/constants.jsx @@ -95,6 +95,7 @@ export const ActionTypes = keyMirror({ RECEIVED_PROFILES: null, RECEIVED_PROFILES_IN_TEAM: null, + RECEIVED_PROFILES_NOT_IN_TEAM: null, RECEIVED_PROFILE: null, RECEIVED_PROFILES_IN_CHANNEL: null, RECEIVED_PROFILES_NOT_IN_CHANNEL: null, @@ -137,6 +138,7 @@ export const ActionTypes = keyMirror({ RECEIVED_MSG: null, + RECEIVED_TEAM: null, RECEIVED_MY_TEAM: null, CREATED_TEAM: null, UPDATE_TEAM: null, @@ -219,6 +221,7 @@ export const SocketEvents = { CHANNEL_VIEWED: 'channel_viewed', DIRECT_ADDED: 'direct_added', NEW_USER: 'new_user', + ADDED_TO_TEAM: 'added_to_team', LEAVE_TEAM: 'leave_team', UPDATE_TEAM: 'update_team', USER_ADDED: 'user_added',