Channel Add Members (#218)

* 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
This commit is contained in:
Chris Duarte 2017-02-07 17:57:52 -08:00 committed by Harrison Healey
parent 6722c5a788
commit c933da341a
15 changed files with 416 additions and 48 deletions

View file

@ -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({

View file

@ -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
}
};
}

View file

@ -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 (
<View style={style.sectionContainer}>
<Text style={style.sectionText}>{sectionId}</Text>
@ -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 (
<MemberListRow
@ -120,6 +156,9 @@ export default class MemberList extends PureComponent {
displayName={displayName}
username={username}
onPress={this.props.onRowPress}
selectable={this.props.selectable}
selected={user.selected}
onRowSelect={onRowSelect}
/>
);
}
@ -152,11 +191,13 @@ export default class MemberList extends PureComponent {
}
render() {
const renderRow = this.props.renderRow || this.renderRow;
return (
<ListView
style={style.listView}
dataSource={this.state.dataSource}
renderRow={this.renderRow}
renderRow={renderRow}
renderSectionHeader={this.renderSectionHeader}
renderSeparator={this.renderSeparator}
renderFooter={this.renderFooter}

View file

@ -2,16 +2,19 @@ import React, {PropTypes} from 'react';
import {
StyleSheet,
Text,
TouchableHighlight,
TouchableOpacity,
TouchableWithoutFeedback,
View
} from 'react-native';
import Icon from 'react-native-vector-icons/FontAwesome';
import ProfilePicture from 'app/components/profile_picture';
const style = StyleSheet.create({
container: {
flexDirection: 'row',
padding: 10,
height: 65,
paddingHorizontal: 10,
alignItems: 'center',
backgroundColor: '#fff'
},
@ -27,14 +30,33 @@ const style = StyleSheet.create({
marginLeft: 5,
fontSize: 16,
opacity: 0.7
},
selector: {
height: 28,
width: 28,
borderRadius: 14,
borderWidth: 1,
borderColor: '#888',
alignItems: 'center',
justifyContent: 'center'
},
selectorContainer: {
height: 50,
paddingRight: 15,
alignItems: 'center',
justifyContent: 'center'
},
selectorFilled: {
backgroundColor: '#378FD2',
borderWidth: 0
}
});
function createTouchableComponent(children, action) {
return (
<TouchableHighlight onPress={action}>
<TouchableOpacity onPress={action}>
{children}
</TouchableHighlight>
</TouchableOpacity>
);
}
@ -43,17 +65,40 @@ function MemberListRow(props) {
const RowComponent = (
<View style={style.container}>
{props.selectable &&
<TouchableWithoutFeedback onPress={props.onRowSelect}>
<View style={style.selectorContainer}>
<View style={[style.selector, (props.selected && style.selectorFilled)]}>
{props.selected &&
<Icon
name='check'
size={16}
color='#fff'
/>
}
</View>
</View>
</TouchableWithoutFeedback>
}
<ProfilePicture
user={user}
size={40}
/>
<View style={style.textContainer}>
<Text style={style.displayName}>
{displayName}
</Text>
<Text style={style.username}>
{`(@${username})`}
</Text>
<View style={{flexGrow: 1, flexDirection: 'column'}}>
<Text style={style.displayName}>
{displayName}
</Text>
</View>
<View style={{flexShrink: 1, flexDirection: 'column', flexWrap: 'wrap'}}>
<Text
style={style.username}
ellipsizeMode='tail'
numberOfLines={1}
>
{`(@${username})`}
</Text>
</View>
</View>
</View>
);
@ -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;

View file

@ -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,

View file

@ -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 (
<View style={{flexDirection: 'column', justifyContent: 'center', alignItems: 'center', flex: 1}}>
<TouchableOpacity
onPress={() => props.emitter('add_members')}
style={{paddingHorizontal: 15}}
>
<FormattedText
id='integrations.add'
defaultMessage='Add'
style={{color: props.theme.sidebarHeaderTextColor}}
/>
</TouchableOpacity>
</View>
);
}
AddMemberButton.propTypes = {
emitter: PropTypes.func.isRequired,
theme: PropTypes.object
};
AddMemberButton.defaultProps = {
theme: {}
};
function mapStateToProps(state) {
return {
theme: getTheme(state)
};
}
export default connect(mapStateToProps)(AddMemberButton);

View file

@ -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 <AddMemberButton emitter={emitter}/>;
}
}
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 (
<View style={style.container}>
<MemberList
members={this.props.membersNotInChannel}
onListEndReached={this.loadMoreMembers}
preferences={this.props.preferences}
loadingMembers={this.props.loadMoreRequestStatus === 'started'}
selectable={true}
onRowSelect={this.handleRowSelect}
showSections={true}
/>
</View>
);
}
}

View file

@ -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);

View file

@ -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;

View file

@ -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 {
<View style={[style.separator, {backgroundColor: this.props.theme.centerChannelBg}]}/>
</View>
<ChannelInfoRow
action={() => true}
action={this.props.actions.goToChannelAddMembers}
defaultMessage='Add Members'
icon='user-plus'
textId='channel_header.addMembers'

View file

@ -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,

View file

@ -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,

View file

@ -43,7 +43,7 @@ const defaults = {
<FormattedText
id={title.id}
defaultMessage={title.defaultMessage}
style={{color: '#fff', fontSize: 15, fontWeight: 'bold'}}
style={{color: '#fff', fontSize: 15, fontWeight: 'bold', textAlign: 'center'}}
/>
</View>
);

View file

@ -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];
}
);

View file

@ -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) {