From c933da341a9572432d8271b17652d5fe722074be Mon Sep 17 00:00:00 2001 From: Chris Duarte Date: Tue, 7 Feb 2017 17:57:52 -0800 Subject: [PATCH] Channel Add Members (#218) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Channel Add Members Add ability to select rows Change member list to accept datasource instead of manage it’s own Add navbar button Add alert for empty member selection Add batched method to send to server * Feedback changes --- app/actions/navigation/index.js | 9 ++ app/actions/views/channel_add_members.js | 16 +++ app/components/member_list/index.js | 73 +++++++++--- app/components/member_list/member_list_row.js | 70 +++++++++-- app/navigation/routes.js | 9 ++ .../channel_add_members/add_member_button.js | 47 ++++++++ .../channel_add_members.js | 110 ++++++++++++++++++ .../channel_add_members_container.js | 46 ++++++++ app/scenes/channel_add_members/index.js | 6 + app/scenes/channel_info/channel_info.js | 3 +- .../channel_info/channel_info_container.js | 3 +- app/scenes/index.js | 2 + app/scenes/navigationSceneConnect.js | 2 +- service/selectors/entities/teams.js | 12 ++ service/selectors/entities/users.js | 56 ++++++--- 15 files changed, 416 insertions(+), 48 deletions(-) create mode 100644 app/actions/views/channel_add_members.js create mode 100644 app/scenes/channel_add_members/add_member_button.js create mode 100644 app/scenes/channel_add_members/channel_add_members.js create mode 100644 app/scenes/channel_add_members/channel_add_members_container.js create mode 100644 app/scenes/channel_add_members/index.js 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) {