diff --git a/app/actions/navigation/index.js b/app/actions/navigation/index.js index a34ed7d7a..2a24a6c7d 100644 --- a/app/actions/navigation/index.js +++ b/app/actions/navigation/index.js @@ -74,6 +74,15 @@ export function goToChannelMembers() { }; } +export function goToChannelAddMembers() { + return async (dispatch, getState) => { + dispatch({ + type: NavigationTypes.NAVIGATION_PUSH, + route: Routes.ChannelAddMembers + }, getState); + }; +} + export function openChannelDrawer() { return async (dispatch, getState) => { dispatch({ diff --git a/app/actions/views/channel_add_members.js b/app/actions/views/channel_add_members.js new file mode 100644 index 000000000..0718a4eda --- /dev/null +++ b/app/actions/views/channel_add_members.js @@ -0,0 +1,16 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {addChannelMember} from 'service/actions/channels'; + +export function handleAddChannelMembers(teamId, channelId, members) { + return async (dispatch, getState) => { + try { + const requests = members.map((m) => dispatch(addChannelMember(teamId, channelId, m, getState))); + + await Promise.all(requests); + } catch (error) { + // should be handled by global error handling + } + }; +} diff --git a/app/components/member_list/index.js b/app/components/member_list/index.js index 6f0acd909..66193608a 100644 --- a/app/components/member_list/index.js +++ b/app/components/member_list/index.js @@ -43,21 +43,26 @@ export default class MemberList extends PureComponent { onRowPress: PropTypes.func, onListEndReached: PropTypes.func, onListEndReachedThreshold: PropTypes.number, - sections: PropTypes.bool, preferences: PropTypes.object, loadingMembers: PropTypes.bool, listPageSize: PropTypes.number, listInitialSize: PropTypes.number, - listScrollRenderAheadDistance: PropTypes.number + listScrollRenderAheadDistance: PropTypes.number, + showSections: PropTypes.bool, + selectable: PropTypes.bool, + onRowSelect: PropTypes.func, + renderRow: PropTypes.func } static defaultProps = { onListEndReached: () => true, onListEndThreshold: 50, - sections: true, listPageSize: 10, listInitialSize: 10, - listScrollRenderAheadDistance: 200 + listScrollRenderAheadDistance: 200, + selectable: false, + onRowSelect: () => true, + showSections: true } constructor(props) { @@ -67,18 +72,33 @@ export default class MemberList extends PureComponent { rowHasChanged: (r1, r2) => r1 !== r2, sectionHeaderHasChanged: (s1, s2) => s1 !== s2 }); - const dataSource = props.sections ? ds.cloneWithRowsAndSections(this.createSections(props.members)) : ds.cloneWithRows(props.members); + let data = props.members; + if (props.showSections) { + data = this.createSections(props.members); + } + const dataSource = ds.cloneWithRowsAndSections(data); this.state = { + data, dataSource }; } componentWillReceiveProps(nextProps) { - const {members, sections} = nextProps; - const dataSource = sections ? this.state.dataSource.cloneWithRowsAndSections(this.createSections(members)) : this.state.dataSource.cloneWithRows(members); - this.setState({ - dataSource - }); + const {members, showSections} = nextProps; + + if (members !== this.props.members || showSections !== this.props.showSections) { + let data = members; + if (showSections) { + data = this.createSections(members); + } + + const mergedData = Object.assign({}, data, this.state.data); + const dataSource = showSections ? this.state.dataSource.cloneWithRowsAndSections(mergedData) : this.state.dataSource.cloneWithRows(mergedData); + this.setState({ + data: mergedData, + dataSource + }); + } } createSections = (data) => { @@ -97,11 +117,23 @@ export default class MemberList extends PureComponent { return sections; } - renderSectionHeader = (sectionData, sectionId) => { - if (!this.props.sections) { - return null; - } + handleRowSelect = (sectionId, rowId) => { + const data = this.state.data; + const section = [...data[sectionId]]; + section[rowId] = Object.assign({}, section[rowId], {selected: !section[rowId].selected}); + const mergedData = Object.assign({}, data, {[sectionId]: section}); + + const id = section[rowId].id; + + const dataSource = this.state.dataSource.cloneWithRowsAndSections(mergedData); + this.setState({ + data: mergedData, + dataSource + }, () => this.props.onRowSelect(id)); + } + + renderSectionHeader = (sectionData, sectionId) => { return ( {sectionId} @@ -109,9 +141,13 @@ export default class MemberList extends PureComponent { ); } - renderRow = (user) => { + renderRow = (user, sectionId, rowId) => { const {id, username} = user; const displayName = displayUsername(user, this.props.preferences); + let onRowSelect = null; + if (this.props.selectable) { + onRowSelect = () => this.handleRowSelect(sectionId, rowId); + } return ( ); } @@ -152,11 +191,13 @@ export default class MemberList extends PureComponent { } render() { + const renderRow = this.props.renderRow || this.renderRow; + return ( + {children} - + ); } @@ -43,17 +65,40 @@ function MemberListRow(props) { const RowComponent = ( + {props.selectable && + + + + {props.selected && + + } + + + + } - - {displayName} - - - {`(@${username})`} - + + + {displayName} + + + + + {`(@${username})`} + + ); @@ -70,7 +115,10 @@ MemberListRow.propTypes = { displayName: PropTypes.string.isRequired, pictureURL: PropTypes.string, username: PropTypes.string.isRequired, - onPress: PropTypes.func + onPress: PropTypes.func, + selectable: PropTypes.bool, + onRowSelect: PropTypes.func, + selected: PropTypes.bool }; export default MemberListRow; diff --git a/app/navigation/routes.js b/app/navigation/routes.js index 2f79e5b98..09189704d 100644 --- a/app/navigation/routes.js +++ b/app/navigation/routes.js @@ -6,6 +6,7 @@ import { ChannelDrawer, ChannelInfo, ChannelMembers, + ChannelAddMembers, LoadTeam, Login, Mfa, @@ -43,6 +44,14 @@ export const Routes = { title: {id: 'channel_header.manageMembers', defaultMessage: 'Manage Members'} } }, + ChannelAddMembers: { + key: 'ChannelAddMembers', + transition: RouteTransitions.Horizontal, + component: ChannelAddMembers, + navigationProps: { + title: {id: 'channel_header.addMembers', defaultMessage: 'Add Members'} + } + }, ChannelView: { key: 'ChannelView', transition: RouteTransitions.Horizontal, diff --git a/app/scenes/channel_add_members/add_member_button.js b/app/scenes/channel_add_members/add_member_button.js new file mode 100644 index 000000000..b8af71c10 --- /dev/null +++ b/app/scenes/channel_add_members/add_member_button.js @@ -0,0 +1,47 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import React, {PropTypes} from 'react'; +import {connect} from 'react-redux'; +import { + TouchableOpacity, + View +} from 'react-native'; + +import FormattedText from 'app/components/formatted_text'; + +import {getTheme} from 'service/selectors/entities/preferences'; + +function AddMemberButton(props) { + return ( + + props.emitter('add_members')} + style={{paddingHorizontal: 15}} + > + + + + ); +} + +AddMemberButton.propTypes = { + emitter: PropTypes.func.isRequired, + theme: PropTypes.object +}; + +AddMemberButton.defaultProps = { + theme: {} +}; + +function mapStateToProps(state) { + return { + theme: getTheme(state) + }; +} + +export default connect(mapStateToProps)(AddMemberButton); diff --git a/app/scenes/channel_add_members/channel_add_members.js b/app/scenes/channel_add_members/channel_add_members.js new file mode 100644 index 000000000..8211fbc9a --- /dev/null +++ b/app/scenes/channel_add_members/channel_add_members.js @@ -0,0 +1,110 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import React, {PropTypes, PureComponent} from 'react'; +import { + Alert, + InteractionManager, + StyleSheet, + View +} from 'react-native'; + +import MemberList from 'app/components/member_list'; + +import AddMemberButton from './add_member_button'; + +const style = StyleSheet.create({ + container: { + flex: 1 + } +}); + +export default class ChannelAddMembers extends PureComponent { + static propTypes = { + currentChannel: PropTypes.object, + currentChannelMemberCount: PropTypes.number, + membersNotInChannel: PropTypes.array.isRequired, + currentTeam: PropTypes.object, + currentTeamMemberCount: PropTypes.number, + preferences: PropTypes.object, + loadMoreRequestStatus: PropTypes.string, + subscribeToHeaderEvent: React.PropTypes.func, + unsubscribeFromHeaderEvent: React.PropTypes.func, + actions: PropTypes.shape({ + getTeamStats: PropTypes.func.isRequired, + getProfilesNotInChannel: PropTypes.func.isRequired, + goBack: PropTypes.func.isRequired, + handleAddChannelMembers: PropTypes.func.isRequired + }) + } + + static navigationProps = { + renderRightComponent: (props, emitter) => { + return ; + } + } + + state = { + selectedMembers: {} + } + + componentWillMount() { + this.props.subscribeToHeaderEvent('add_members', this.handleAddMembersPress); + } + + componentDidMount() { + InteractionManager.runAfterInteractions(() => { + this.props.actions.getProfilesNotInChannel(this.props.currentTeam.id, this.props.currentChannel.id, 0); + this.props.actions.getTeamStats(this.props.currentTeam.id); + }); + } + + componentWillUnmount() { + this.props.unsubscribeFromHeaderEvent('add_members'); + } + + handleAddMembersPress = () => { + const {selectedMembers} = this.state; + const {actions, currentTeam, currentChannel} = this.props; + const membersToAdd = Object.keys(selectedMembers).filter((m) => selectedMembers[m]); + + if (!membersToAdd.length) { + Alert.alert('Add Members', 'You must select at least one member to add to the channel.'); + return; + } + + actions.handleAddChannelMembers(currentTeam.id, currentChannel.id, membersToAdd).then(() => { + actions.goBack(); + }); + } + + loadMoreMembers = () => { + const {currentChannelMemberCount, currentTeamMemberCount, membersNotInChannel, loadMoreRequestStatus} = this.props; + if (loadMoreRequestStatus !== 'started' && membersNotInChannel.length < (currentTeamMemberCount - currentChannelMemberCount - 1)) { + this.props.actions.getProfilesNotInChannel(this.props.currentTeam.id, this.props.currentChannel.id, this.props.membersNotInChannel.length); + } + } + + handleRowSelect = (id) => { + const selectedMembers = Object.assign({}, this.state.selectedMembers, {[id]: !this.state.selectedMembers[id]}); + this.setState({ + selectedMembers + }); + } + + render() { + return ( + + + + ); + } +} diff --git a/app/scenes/channel_add_members/channel_add_members_container.js b/app/scenes/channel_add_members/channel_add_members_container.js new file mode 100644 index 000000000..c4e13fc84 --- /dev/null +++ b/app/scenes/channel_add_members/channel_add_members_container.js @@ -0,0 +1,46 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {bindActionCreators} from 'redux'; + +import navigationSceneConnect from '../navigationSceneConnect'; + +import {handleAddChannelMembers} from 'app/actions/views/channel_add_members'; +import {goBack} from 'app/actions/navigation'; +import {getCurrentChannel, getCurrentChannelStats} from 'service/selectors/entities/channels'; +import {getMyPreferences} from 'service/selectors/entities/preferences'; +import {getCurrentTeam, getCurrentTeamStats} from 'service/selectors/entities/teams'; +import {getProfilesNotInCurrentChannel} from 'service/selectors/entities/users'; +import {getTeamStats} from 'service/actions/teams'; +import {getProfilesNotInChannel} from 'service/actions/users'; + +import ChannelAddMembers from './channel_add_members'; + +function mapStateToProps(state) { + const currentTeamMemberCount = getCurrentTeamStats(state) && getCurrentTeamStats(state).total_member_count; + const currentChannelMemberCount = getCurrentChannelStats(state) && getCurrentChannelStats(state).member_count; + + return { + currentChannel: getCurrentChannel(state), + membersNotInChannel: getProfilesNotInCurrentChannel(state), + currentTeam: getCurrentTeam(state), + currentTeamMemberCount, + currentChannelMemberCount, + preferences: getMyPreferences(state), + loadMoreRequestStatus: state.requests.users.getProfilesNotInChannel.status, + addChannelMemberRequestStatus: state.requests.channels.addChannelMember + }; +} + +function mapDispatchToProps(dispatch) { + return { + actions: bindActionCreators({ + getTeamStats, + getProfilesNotInChannel, + goBack, + handleAddChannelMembers + }, dispatch) + }; +} + +export default navigationSceneConnect(mapStateToProps, mapDispatchToProps)(ChannelAddMembers); diff --git a/app/scenes/channel_add_members/index.js b/app/scenes/channel_add_members/index.js new file mode 100644 index 000000000..672d255d6 --- /dev/null +++ b/app/scenes/channel_add_members/index.js @@ -0,0 +1,6 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import ChannelAddMembersContainer from './channel_add_members_container'; + +export default ChannelAddMembersContainer; diff --git a/app/scenes/channel_info/channel_info.js b/app/scenes/channel_info/channel_info.js index 3ef3d355d..99beda72e 100644 --- a/app/scenes/channel_info/channel_info.js +++ b/app/scenes/channel_info/channel_info.js @@ -47,6 +47,7 @@ class ChannelInfo extends PureComponent { actions: PropTypes.shape({ getChannelStats: PropTypes.func.isRequired, goToChannelMembers: PropTypes.func.isRequired, + goToChannelAddMembers: PropTypes.func.isRequired, markFavorite: PropTypes.func.isRequired, unmarkFavorite: PropTypes.func.isRequired, goBack: PropTypes.func.isRequired, @@ -177,7 +178,7 @@ class ChannelInfo extends PureComponent { true} + action={this.props.actions.goToChannelAddMembers} defaultMessage='Add Members' icon='user-plus' textId='channel_header.addMembers' diff --git a/app/scenes/channel_info/channel_info_container.js b/app/scenes/channel_info/channel_info_container.js index a695e8a0c..e1a813852 100644 --- a/app/scenes/channel_info/channel_info_container.js +++ b/app/scenes/channel_info/channel_info_container.js @@ -5,7 +5,7 @@ import {bindActionCreators} from 'redux'; import navigationSceneConnect from '../navigationSceneConnect'; -import {goToChannelMembers, goBack} from 'app/actions/navigation'; +import {goToChannelMembers, goToChannelAddMembers, goBack} from 'app/actions/navigation'; import {getChannelStats} from 'service/actions/channels'; import {markFavorite, unmarkFavorite, leaveChannel} from 'app/actions/views/channel'; import {getCurrentChannel, getCurrentChannelStats, getChannelsByCategory} from 'service/selectors/entities/channels'; @@ -39,6 +39,7 @@ function mapDispatchToProps(dispatch) { actions: bindActionCreators({ getChannelStats, goToChannelMembers, + goToChannelAddMembers, markFavorite, unmarkFavorite, leaveChannel, diff --git a/app/scenes/index.js b/app/scenes/index.js index 7c195edf5..f1812bad5 100644 --- a/app/scenes/index.js +++ b/app/scenes/index.js @@ -5,6 +5,7 @@ import Channel from './channel'; import ChannelDrawer from './channel_drawer'; import ChannelInfo from './channel_info'; import ChannelMembers from './channel_members'; +import ChannelAddMembers from './channel_add_members'; import LoadTeam from './load_team'; import Login from './login/login_container.js'; import Mfa from './mfa'; @@ -19,6 +20,7 @@ module.exports = { ChannelDrawer, ChannelInfo, ChannelMembers, + ChannelAddMembers, LoadTeam, Login, Mfa, diff --git a/app/scenes/navigationSceneConnect.js b/app/scenes/navigationSceneConnect.js index fba77cde0..990e913e9 100644 --- a/app/scenes/navigationSceneConnect.js +++ b/app/scenes/navigationSceneConnect.js @@ -43,7 +43,7 @@ const defaults = { ); diff --git a/service/selectors/entities/teams.js b/service/selectors/entities/teams.js index 4812ac19c..8441573d4 100644 --- a/service/selectors/entities/teams.js +++ b/service/selectors/entities/teams.js @@ -13,6 +13,10 @@ export function getTeams(state) { return state.entities.teams.teams; } +export function getTeamStats(state) { + return state.entities.teams.stats; +} + export const getCurrentTeam = createSelector( getTeams, getCurrentTeamId, @@ -28,3 +32,11 @@ export const getCurrentTeamUrl = createSelector( return `${currentUrl}/${currentTeam.name}`; } ); + +export const getCurrentTeamStats = createSelector( + getCurrentTeamId, + getTeamStats, + (currentTeamId, teamStats) => { + return teamStats[currentTeamId]; + } +); diff --git a/service/selectors/entities/users.js b/service/selectors/entities/users.js index 8c2861894..0ef49b6cd 100644 --- a/service/selectors/entities/users.js +++ b/service/selectors/entities/users.js @@ -13,6 +13,10 @@ export function getProfilesInChannel(state) { return state.entities.users.profilesInChannel; } +export function getProfilesNotInChannel(state) { + return state.entities.users.profilesNotInChannel; +} + export function getUserStatuses(state) { return state.entities.users.statuses; } @@ -41,28 +45,44 @@ export const getProfileSetInCurrentChannel = createSelector( } ); +export const getProfileSetNotInCurrentChannel = createSelector( + getCurrentChannelId, + getProfilesNotInChannel, + (currentChannel, channelProfiles) => { + return channelProfiles[currentChannel]; + } +); + +function sortAndInjectProfiles(profiles, profileSet) { + const currentProfiles = []; + if (typeof profileSet === 'undefined') { + return currentProfiles; + } + + profileSet.forEach((p) => { + currentProfiles.push(profiles[p]); + }); + + const sortedCurrentProfiles = currentProfiles.sort((a, b) => { + const nameA = a.username; + const nameB = b.username; + + return nameA.localeCompare(nameB); + }); + + return sortedCurrentProfiles; +} + export const getProfilesInCurrentChannel = createSelector( getUsers, getProfileSetInCurrentChannel, - (profiles, currentChannelProfileSet) => { - const currentProfiles = []; - if (typeof currentChannelProfileSet === 'undefined') { - return currentProfiles; - } + (profiles, currentChannelProfileSet) => sortAndInjectProfiles(profiles, currentChannelProfileSet) +); - currentChannelProfileSet.forEach((p) => { - currentProfiles.push(profiles[p]); - }); - - const sortedCurrentProfiles = currentProfiles.sort((a, b) => { - const nameA = a.username; - const nameB = b.username; - - return nameA.localeCompare(nameB); - }); - - return sortedCurrentProfiles; - } +export const getProfilesNotInCurrentChannel = createSelector( + getUsers, + getProfileSetNotInCurrentChannel, + (profiles, notInCurrentChannelProfileSet) => sortAndInjectProfiles(profiles, notInCurrentChannelProfileSet) ); export function getStatusForUserId(state, userId) {