MM-11934 Add interactive action menus (#2099)
* Add interactive action menus * Add snapshot tests * Updates per feedback * Update styling of menu action * Updates per feedback * Update submitted message color * Update snapshots
This commit is contained in:
parent
46b6687d1f
commit
ef3ba7eb00
20 changed files with 2120 additions and 17 deletions
|
|
@ -4,6 +4,8 @@
|
|||
import {Posts} from 'mattermost-redux/constants';
|
||||
import {PostTypes} from 'mattermost-redux/action_types';
|
||||
|
||||
import {ViewTypes} from 'app/constants';
|
||||
|
||||
import {generateId} from 'app/utils/file';
|
||||
|
||||
export function sendAddToChannelEphemeralPost(user, addedUsername, message, channelId, postRootId = '') {
|
||||
|
|
@ -37,3 +39,14 @@ export function sendAddToChannelEphemeralPost(user, addedUsername, message, chan
|
|||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function setMenuActionSelector(dataSource, onSelect, options) {
|
||||
return {
|
||||
type: ViewTypes.SELECTED_ACTION_MENU,
|
||||
data: {
|
||||
dataSource,
|
||||
onSelect,
|
||||
options,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ export default class ChannelListRow extends React.PureComponent {
|
|||
};
|
||||
|
||||
onPress = () => {
|
||||
this.props.onPress(this.props.id);
|
||||
this.props.onPress(this.props.id, this.props.item);
|
||||
};
|
||||
|
||||
render() {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ export default class CustomListRow extends React.PureComponent {
|
|||
selectable: PropTypes.bool,
|
||||
selected: PropTypes.bool,
|
||||
children: CustomPropTypes.Children,
|
||||
item: PropTypes.object,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
|
|
|
|||
16
app/components/custom_list/option_list_row/index.js
Normal file
16
app/components/custom_list/option_list_row/index.js
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {connect} from 'react-redux';
|
||||
|
||||
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
|
||||
|
||||
import OptionListRow from './option_list_row';
|
||||
|
||||
function mapStateToProps(state) {
|
||||
return {
|
||||
theme: getTheme(state),
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(OptionListRow);
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {intlShape} from 'react-intl';
|
||||
import {
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import {makeStyleSheetFromTheme} from 'app/utils/theme';
|
||||
|
||||
import CustomListRow from 'app/components/custom_list/custom_list_row';
|
||||
|
||||
export default class OptionListRow extends React.PureComponent {
|
||||
static propTypes = {
|
||||
id: PropTypes.string,
|
||||
theme: PropTypes.object.isRequired,
|
||||
...CustomListRow.propTypes,
|
||||
};
|
||||
|
||||
static contextTypes = {
|
||||
intl: intlShape,
|
||||
};
|
||||
|
||||
onPress = () => {
|
||||
if (this.props.onPress) {
|
||||
this.props.onPress(this.props.id, this.props.item);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
enabled,
|
||||
selectable,
|
||||
selected,
|
||||
theme,
|
||||
item,
|
||||
} = this.props;
|
||||
|
||||
const {text, value} = item;
|
||||
const style = getStyleFromTheme(theme);
|
||||
|
||||
return (
|
||||
<CustomListRow
|
||||
id={value}
|
||||
theme={theme}
|
||||
onPress={this.onPress}
|
||||
enabled={enabled}
|
||||
selectable={selectable}
|
||||
selected={selected}
|
||||
>
|
||||
<View style={style.textContainer}>
|
||||
<View>
|
||||
<Text style={style.optionText}>
|
||||
{text}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</CustomListRow>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
|
||||
return {
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
height: 65,
|
||||
paddingHorizontal: 15,
|
||||
alignItems: 'center',
|
||||
backgroundColor: theme.centerChannelBg,
|
||||
},
|
||||
optionText: {
|
||||
fontSize: 15,
|
||||
color: theme.centerChannelColor,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
@ -29,7 +29,7 @@ export default class UserListRow extends React.PureComponent {
|
|||
|
||||
onPress = () => {
|
||||
if (this.props.onPress) {
|
||||
this.props.onPress(this.props.id);
|
||||
this.props.onPress(this.props.id, this.props.item);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import Button from 'react-native-button';
|
|||
import {preventDoubleTap} from 'app/utils/tap';
|
||||
import {makeStyleSheetFromTheme} from 'app/utils/theme';
|
||||
|
||||
export default class InteractiveAction extends PureComponent {
|
||||
export default class ActionButton extends PureComponent {
|
||||
static propTypes = {
|
||||
actions: PropTypes.shape({
|
||||
doPostAction: PropTypes.func.isRequired,
|
||||
|
|
@ -7,7 +7,7 @@ import {connect} from 'react-redux';
|
|||
import {doPostAction} from 'mattermost-redux/actions/posts';
|
||||
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
|
||||
|
||||
import InteractiveAction from './interactive_action';
|
||||
import ActionButton from './action_button';
|
||||
|
||||
function mapStateToProps(state) {
|
||||
return {
|
||||
|
|
@ -23,4 +23,4 @@ function mapDispatchToProps(dispatch) {
|
|||
};
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(InteractiveAction);
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(ActionButton);
|
||||
190
app/components/message_attachments/action_menu/action_menu.js
Normal file
190
app/components/message_attachments/action_menu/action_menu.js
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {PureComponent} from 'react';
|
||||
import {Text, View} from 'react-native';
|
||||
import PropTypes from 'prop-types';
|
||||
import {intlShape} from 'react-intl';
|
||||
import Icon from 'react-native-vector-icons/FontAwesome';
|
||||
|
||||
import FormattedText from 'app/components/formatted_text';
|
||||
import {preventDoubleTap} from 'app/utils/tap';
|
||||
import {makeStyleSheetFromTheme, changeOpacity} from 'app/utils/theme';
|
||||
import {ViewTypes} from 'app/constants';
|
||||
|
||||
export default class ActionMenu extends PureComponent {
|
||||
static propTypes = {
|
||||
actions: PropTypes.shape({
|
||||
doPostAction: PropTypes.func.isRequired,
|
||||
setMenuActionSelector: PropTypes.func.isRequired,
|
||||
}).isRequired,
|
||||
id: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
dataSource: PropTypes.string,
|
||||
options: PropTypes.arrayOf(PropTypes.object),
|
||||
postId: PropTypes.string.isRequired,
|
||||
theme: PropTypes.object.isRequired,
|
||||
navigator: PropTypes.object,
|
||||
};
|
||||
|
||||
static contextTypes = {
|
||||
intl: intlShape,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
selectedText: null,
|
||||
};
|
||||
}
|
||||
|
||||
handleSelect = (selected) => {
|
||||
if (!selected) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {dataSource, actions, postId, id} = this.props;
|
||||
|
||||
let selectedText;
|
||||
let selectedValue;
|
||||
if (dataSource === ViewTypes.DATA_SOURCE_USERS) {
|
||||
selectedText = selected.username;
|
||||
selectedValue = selected.id;
|
||||
} else if (dataSource === ViewTypes.DATA_SOURCE_CHANNELS) {
|
||||
selectedText = selected.display_name;
|
||||
selectedValue = selected.id;
|
||||
} else {
|
||||
selectedText = selected.text;
|
||||
selectedValue = selected.value;
|
||||
}
|
||||
|
||||
this.setState({selectedText});
|
||||
|
||||
actions.doPostAction(postId, id, selectedValue);
|
||||
}
|
||||
|
||||
goToMenuActionSelector = preventDoubleTap(() => {
|
||||
const {intl} = this.context;
|
||||
const {navigator, theme, actions, dataSource, options, name} = this.props;
|
||||
|
||||
actions.setMenuActionSelector(dataSource, this.handleSelect, options);
|
||||
|
||||
navigator.push({
|
||||
backButtonTitle: '',
|
||||
screen: 'MenuActionSelector',
|
||||
title: name || intl.formatMessage({id: 'mobile.action_menu.select', defaultMessage: 'Select an option'}),
|
||||
animated: true,
|
||||
navigatorStyle: {
|
||||
navBarTextColor: theme.sidebarHeaderTextColor,
|
||||
navBarBackgroundColor: theme.sidebarHeaderBg,
|
||||
navBarButtonColor: theme.sidebarHeaderTextColor,
|
||||
screenBackgroundColor: theme.centerChannelBg,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
render() {
|
||||
const {intl} = this.context;
|
||||
const {name, theme, id} = this.props;
|
||||
const {selectedText} = this.state;
|
||||
const style = getStyleSheet(theme);
|
||||
|
||||
let text = name || intl.formatMessage({id: 'mobile.action_menu.select', defaultMessage: 'Select an option'});
|
||||
let selectedStyle = style.dropdownPlaceholder;
|
||||
let submitted;
|
||||
if (selectedText) {
|
||||
text = selectedText;
|
||||
selectedStyle = style.dropdownSelected;
|
||||
submitted = (
|
||||
<View style={style.submittedContainer}>
|
||||
<Icon
|
||||
key={id + 'check'}
|
||||
name='check'
|
||||
color={'#287B39'}
|
||||
/>
|
||||
<FormattedText
|
||||
key={id + 'submitted'}
|
||||
id='mobile.action_menu.submitted'
|
||||
defaultMessage='Submitted'
|
||||
style={style.submittedText}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
} else {
|
||||
submitted = <View style={style.blankSubmittedContainer}/>;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={style.container}>
|
||||
<View style={style.input}>
|
||||
<Text
|
||||
style={selectedStyle}
|
||||
onPress={this.goToMenuActionSelector}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{text}
|
||||
</Text>
|
||||
<Icon
|
||||
name='chevron-down'
|
||||
onPress={this.goToMenuActionSelector}
|
||||
color={changeOpacity(theme.centerChannelColor, 0.5)}
|
||||
style={style.icon}
|
||||
/>
|
||||
</View>
|
||||
{submitted}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
|
||||
return {
|
||||
container: {
|
||||
width: '100%',
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
position: 'relative',
|
||||
borderWidth: 1,
|
||||
borderRadius: 5,
|
||||
borderColor: changeOpacity(theme.centerChannelColor, 0.1),
|
||||
backgroundColor: changeOpacity(theme.centerChannelBg, 0.9),
|
||||
marginBottom: 2,
|
||||
marginRight: 8,
|
||||
marginTop: 10,
|
||||
paddingLeft: 10,
|
||||
paddingRight: 30,
|
||||
paddingVertical: 7,
|
||||
},
|
||||
dropdownPlaceholder: {
|
||||
color: changeOpacity(theme.centerChannelColor, 0.5),
|
||||
},
|
||||
dropdownSelected: {
|
||||
color: theme.centerChannelColor,
|
||||
},
|
||||
icon: {
|
||||
position: 'absolute',
|
||||
top: 10,
|
||||
right: 12,
|
||||
},
|
||||
blankSubmittedContainer: {
|
||||
width: 80,
|
||||
height: 23,
|
||||
},
|
||||
submittedContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginTop: 10,
|
||||
marginBottom: 2,
|
||||
},
|
||||
submittedText: {
|
||||
marginLeft: 5,
|
||||
color: '#287B39',
|
||||
},
|
||||
};
|
||||
});
|
||||
29
app/components/message_attachments/action_menu/index.js
Normal file
29
app/components/message_attachments/action_menu/index.js
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {bindActionCreators} from 'redux';
|
||||
import {connect} from 'react-redux';
|
||||
|
||||
import {doPostAction} from 'mattermost-redux/actions/posts';
|
||||
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
|
||||
|
||||
import {setMenuActionSelector} from 'app/actions/views/post';
|
||||
|
||||
import ActionMenu from './action_menu';
|
||||
|
||||
function mapStateToProps(state) {
|
||||
return {
|
||||
theme: getTheme(state),
|
||||
};
|
||||
}
|
||||
|
||||
function mapDispatchToProps(dispatch) {
|
||||
return {
|
||||
actions: bindActionCreators({
|
||||
doPostAction,
|
||||
setMenuActionSelector,
|
||||
}, dispatch),
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(ActionMenu);
|
||||
|
|
@ -22,7 +22,8 @@ import ImageCacheManager from 'app/utils/image_cache_manager';
|
|||
import {previewImageAtIndex, calculateDimensions} from 'app/utils/images';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
|
||||
|
||||
import InteractiveAction from './interactive_action';
|
||||
import ActionButton from './action_button';
|
||||
import ActionMenu from './action_menu';
|
||||
|
||||
const VIEWPORT_IMAGE_CONTAINER_OFFSET = 10;
|
||||
const VIEWPORT_IMAGE_OFFSET = 32;
|
||||
|
|
@ -70,32 +71,51 @@ export default class MessageAttachment extends PureComponent {
|
|||
}
|
||||
|
||||
getActionView = (style) => {
|
||||
const {attachment, postId} = this.props;
|
||||
const {attachment, postId, navigator} = this.props;
|
||||
const {actions} = attachment;
|
||||
|
||||
if (!actions || !actions.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const buttons = [];
|
||||
const content = [];
|
||||
|
||||
actions.forEach((action) => {
|
||||
if (!action.id || !action.name) {
|
||||
return;
|
||||
}
|
||||
buttons.push(
|
||||
<InteractiveAction
|
||||
key={action.id}
|
||||
id={action.id}
|
||||
name={action.name}
|
||||
postId={postId}
|
||||
/>
|
||||
);
|
||||
|
||||
switch (action.type) {
|
||||
case 'select':
|
||||
content.push(
|
||||
<ActionMenu
|
||||
key={action.id}
|
||||
id={action.id}
|
||||
name={action.name}
|
||||
dataSource={action.data_source}
|
||||
options={action.options}
|
||||
postId={postId}
|
||||
navigator={navigator}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case 'button':
|
||||
default:
|
||||
content.push(
|
||||
<ActionButton
|
||||
key={action.id}
|
||||
id={action.id}
|
||||
name={action.name}
|
||||
postId={postId}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={style.actionsContainer}>
|
||||
{buttons}
|
||||
{content}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -71,6 +71,8 @@ const ViewTypes = keyMirror({
|
|||
SET_DEEP_LINK_URL: null,
|
||||
|
||||
SET_PROFILE_IMAGE_URI: null,
|
||||
|
||||
SELECTED_ACTION_MENU: null,
|
||||
});
|
||||
|
||||
export default {
|
||||
|
|
@ -88,4 +90,6 @@ export default {
|
|||
IOSX_TOP_PORTRAIT: 88,
|
||||
STATUS_BAR_HEIGHT: 20,
|
||||
PROFILE_PICTURE_SIZE: 32,
|
||||
DATA_SOURCE_USERS: 'users',
|
||||
DATA_SOURCE_CHANNELS: 'channels',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import team from './team';
|
|||
import thread from './thread';
|
||||
import user from './user';
|
||||
import emoji from './emoji';
|
||||
import post from './post';
|
||||
|
||||
export default combineReducers({
|
||||
announcement,
|
||||
|
|
@ -33,4 +34,5 @@ export default combineReducers({
|
|||
thread,
|
||||
user,
|
||||
emoji,
|
||||
post,
|
||||
});
|
||||
|
|
|
|||
20
app/reducers/views/post.js
Normal file
20
app/reducers/views/post.js
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {combineReducers} from 'redux';
|
||||
|
||||
import {ViewTypes} from 'app/constants';
|
||||
|
||||
function menuAction(state = {}, action) {
|
||||
switch (action.type) {
|
||||
case ViewTypes.SELECTED_ACTION_MENU:
|
||||
return action.data;
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export default combineReducers({
|
||||
menuAction,
|
||||
});
|
||||
|
|
@ -33,6 +33,7 @@ export function registerScreens(store, Provider) {
|
|||
Navigation.registerComponent('Login', () => wrapWithContextProvider(require('app/screens/login').default), store, Provider);
|
||||
Navigation.registerComponent('LoginOptions', () => wrapWithContextProvider(require('app/screens/login_options').default), store, Provider);
|
||||
Navigation.registerComponent('LongPost', () => wrapWithContextProvider(require('app/screens/long_post').default), store, Provider);
|
||||
Navigation.registerComponent('MenuActionSelector', () => wrapWithContextProvider(require('app/screens/menu_action_selector').default), store, Provider);
|
||||
Navigation.registerComponent('MFA', () => wrapWithContextProvider(require('app/screens/mfa').default), store, Provider);
|
||||
Navigation.registerComponent('MoreChannels', () => wrapWithContextProvider(require('app/screens/more_channels').default), store, Provider);
|
||||
Navigation.registerComponent('MoreDirectMessages', () => wrapWithContextProvider(require('app/screens/more_dms').default), store, Provider);
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
58
app/screens/menu_action_selector/index.js
Normal file
58
app/screens/menu_action_selector/index.js
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {bindActionCreators} from 'redux';
|
||||
import {connect} from 'react-redux';
|
||||
|
||||
import {getChannelsInCurrentTeam} from 'mattermost-redux/selectors/entities/channels';
|
||||
import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
|
||||
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
|
||||
import {getProfiles} from 'mattermost-redux/selectors/entities/users';
|
||||
import {getProfiles as getProfilesAction, searchProfiles} from 'mattermost-redux/actions/users';
|
||||
import {getChannels, searchChannels} from 'mattermost-redux/actions/channels';
|
||||
|
||||
import {ViewTypes} from 'app/constants';
|
||||
|
||||
import MenuActionSelector from './menu_action_selector';
|
||||
|
||||
function mapStateToProps(state) {
|
||||
const menuAction = state.views.post.menuAction || {};
|
||||
|
||||
let data;
|
||||
let loadMoreRequestStatus;
|
||||
let searchRequestStatus;
|
||||
if (menuAction.dataSource === ViewTypes.DATA_SOURCE_USERS) {
|
||||
data = getProfiles(state);
|
||||
loadMoreRequestStatus = state.requests.users.getProfiles.status;
|
||||
searchRequestStatus = state.requests.users.searchProfiles.status;
|
||||
} else if (menuAction.dataSource === ViewTypes.DATA_SOURCE_CHANNELS) {
|
||||
data = getChannelsInCurrentTeam(state);
|
||||
loadMoreRequestStatus = state.requests.channels.getChannels.status;
|
||||
searchRequestStatus = state.requests.channels.getChannels.status;
|
||||
} else {
|
||||
data = menuAction.options || [];
|
||||
}
|
||||
|
||||
return {
|
||||
data,
|
||||
dataSource: menuAction.dataSource,
|
||||
onSelect: menuAction.onSelect,
|
||||
theme: getTheme(state),
|
||||
currentTeamId: getCurrentTeamId(state),
|
||||
loadMoreRequestStatus,
|
||||
searchRequestStatus,
|
||||
};
|
||||
}
|
||||
|
||||
function mapDispatchToProps(dispatch) {
|
||||
return {
|
||||
actions: bindActionCreators({
|
||||
getProfiles: getProfilesAction,
|
||||
getChannels,
|
||||
searchProfiles,
|
||||
searchChannels,
|
||||
}, dispatch),
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(MenuActionSelector);
|
||||
286
app/screens/menu_action_selector/menu_action_selector.js
Normal file
286
app/screens/menu_action_selector/menu_action_selector.js
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {PureComponent} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {injectIntl, intlShape} from 'react-intl';
|
||||
import {
|
||||
InteractionManager,
|
||||
Platform,
|
||||
View,
|
||||
} from 'react-native';
|
||||
|
||||
import CustomList from 'app/components/custom_list';
|
||||
import UserListRow from 'app/components/custom_list/user_list_row';
|
||||
import ChannelListRow from 'app/components/custom_list/channel_list_row';
|
||||
import OptionListRow from 'app/components/custom_list/option_list_row';
|
||||
import SearchBar from 'app/components/search_bar';
|
||||
import StatusBar from 'app/components/status_bar';
|
||||
import {loadingText} from 'app/utils/member_list';
|
||||
import {changeOpacity, makeStyleSheetFromTheme, setNavigatorStyles} from 'app/utils/theme';
|
||||
import {ViewTypes} from 'app/constants';
|
||||
|
||||
import {General, RequestStatus} from 'mattermost-redux/constants';
|
||||
import {filterProfilesMatchingTerm} from 'mattermost-redux/utils/user_utils';
|
||||
import {filterChannelsMatchingTerm} from 'mattermost-redux/utils/channel_utils';
|
||||
import {memoizeResult} from 'mattermost-redux/utils/helpers';
|
||||
|
||||
class MenuActionSelector extends PureComponent {
|
||||
static propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
theme: PropTypes.object.isRequired,
|
||||
navigator: PropTypes.object,
|
||||
data: PropTypes.arrayOf(PropTypes.object),
|
||||
dataSource: PropTypes.string,
|
||||
onSelect: PropTypes.func.isRequired,
|
||||
currentTeamId: PropTypes.string.isRequired,
|
||||
loadMoreRequestStatus: PropTypes.string,
|
||||
searchRequestStatus: PropTypes.string,
|
||||
actions: PropTypes.shape({
|
||||
getProfiles: PropTypes.func.isRequired,
|
||||
getChannels: PropTypes.func.isRequired,
|
||||
searchProfiles: PropTypes.func.isRequired,
|
||||
searchChannels: PropTypes.func.isRequired,
|
||||
}),
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.searchTimeoutId = 0;
|
||||
|
||||
let data = [];
|
||||
if (!props.dataSource) {
|
||||
data = props.data;
|
||||
}
|
||||
|
||||
const needsLoading = props.dataSource === ViewTypes.DATA_SOURCE_USERS || props.dataSource === ViewTypes.DATA_SOURCE_CHANNELS;
|
||||
|
||||
this.state = {
|
||||
next: needsLoading,
|
||||
page: 0,
|
||||
data,
|
||||
searching: false,
|
||||
showNoResults: false,
|
||||
term: '',
|
||||
isLoading: needsLoading,
|
||||
prevLoadMoreRequestStatus: RequestStatus.STARTED,
|
||||
};
|
||||
|
||||
props.navigator.setOnNavigatorEvent(this.onNavigatorEvent);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.mounted = true;
|
||||
InteractionManager.runAfterInteractions(() => {
|
||||
if (this.props.dataSource === ViewTypes.DATA_SOURCE_USERS) {
|
||||
this.props.actions.getProfiles().then(() => this.setState({isLoading: false}));
|
||||
} else if (this.props.dataSource === ViewTypes.DATA_SOURCE_CHANNELS) {
|
||||
this.props.actions.getChannels(this.props.currentTeamId).then(() => this.setState({isLoading: false}));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.mounted = false;
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (this.props.theme !== prevProps.theme) {
|
||||
setNavigatorStyles(this.props.navigator, this.props.theme);
|
||||
}
|
||||
}
|
||||
|
||||
cancelSearch = () => {
|
||||
this.setState({
|
||||
searching: false,
|
||||
isLoading: false,
|
||||
term: '',
|
||||
page: 0,
|
||||
data: filterPageData(this.props.data, 0),
|
||||
});
|
||||
};
|
||||
|
||||
close = () => {
|
||||
this.props.navigator.pop({animated: true});
|
||||
};
|
||||
|
||||
handleRowSelect = (id, selected) => {
|
||||
this.props.onSelect(selected);
|
||||
this.close();
|
||||
};
|
||||
|
||||
loadMore = async () => {
|
||||
const {actions, loadMoreRequestStatus, currentTeamId, dataSource} = this.props;
|
||||
const {next, searching} = this.state;
|
||||
let {page} = this.state;
|
||||
|
||||
if (loadMoreRequestStatus !== RequestStatus.STARTED && next && !searching) {
|
||||
page = page + 1;
|
||||
|
||||
let results;
|
||||
if (dataSource === ViewTypes.DATA_SOURCE_USERS) {
|
||||
results = await actions.getProfiles(page, General.PROFILE_CHUNK_SIZE);
|
||||
} else if (dataSource === ViewTypes.DATA_SOURCE_CHANNELS) {
|
||||
results = await actions.getChannels(currentTeamId, page, General.PROFILE_CHUNK_SIZE);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (results.data && results.data.length) {
|
||||
this.setState({
|
||||
isLoading: false,
|
||||
page,
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
isLoading: false,
|
||||
next: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
searchProfiles = (text) => {
|
||||
const term = text;
|
||||
const {actions, currentTeamId, dataSource} = this.props;
|
||||
|
||||
if (term) {
|
||||
if (!dataSource) {
|
||||
this.setState({data: filterSearchData(null, this.props.data, term.toLowerCase())});
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({searching: true, isLoading: true, term});
|
||||
clearTimeout(this.searchTimeoutId);
|
||||
|
||||
this.searchTimeoutId = setTimeout(async () => {
|
||||
if (dataSource === ViewTypes.DATA_SOURCE_USERS) {
|
||||
await actions.searchProfiles(term.toLowerCase());
|
||||
} else if (dataSource === ViewTypes.DATA_SOURCE_CHANNELS) {
|
||||
await actions.searchChannels(currentTeamId, term.toLowerCase());
|
||||
}
|
||||
|
||||
if (!this.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({isLoading: false});
|
||||
}, General.SEARCH_TIMEOUT_MILLISECONDS);
|
||||
} else {
|
||||
this.cancelSearch();
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const {intl, data, theme, dataSource} = this.props;
|
||||
const {searching, term, page} = this.state;
|
||||
const {formatMessage} = intl;
|
||||
const style = getStyleFromTheme(theme);
|
||||
const more = searching ? () => true : this.loadMore;
|
||||
|
||||
const searchBarInput = {
|
||||
backgroundColor: changeOpacity(theme.centerChannelColor, 0.2),
|
||||
color: theme.centerChannelColor,
|
||||
fontSize: 15,
|
||||
...Platform.select({
|
||||
android: {
|
||||
marginBottom: -5,
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
let rowComponent;
|
||||
if (dataSource === ViewTypes.DATA_SOURCE_USERS) {
|
||||
rowComponent = UserListRow;
|
||||
} else if (dataSource === ViewTypes.DATA_SOURCE_CHANNELS) {
|
||||
rowComponent = ChannelListRow;
|
||||
} else {
|
||||
rowComponent = OptionListRow;
|
||||
}
|
||||
|
||||
let filteredData;
|
||||
if (searching) {
|
||||
filteredData = filterSearchData(dataSource, data, term);
|
||||
} else {
|
||||
filteredData = filterPageData(data, page);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={style.container}>
|
||||
<StatusBar/>
|
||||
<View
|
||||
style={style.searchContainer}
|
||||
>
|
||||
<SearchBar
|
||||
ref='search_bar'
|
||||
placeholder={formatMessage({id: 'search_bar.search', defaultMessage: 'Search'})}
|
||||
cancelTitle={formatMessage({id: 'mobile.post.cancel', defaultMessage: 'Cancel'})}
|
||||
backgroundColor='transparent'
|
||||
inputHeight={33}
|
||||
inputStyle={searchBarInput}
|
||||
placeholderTextColor={changeOpacity(theme.centerChannelColor, 0.5)}
|
||||
tintColorSearch={changeOpacity(theme.centerChannelColor, 0.5)}
|
||||
tintColorDelete={changeOpacity(theme.centerChannelColor, 0.5)}
|
||||
titleCancelColor={theme.centerChannelColor}
|
||||
onChangeText={this.searchProfiles}
|
||||
onSearchButtonPress={this.searchProfiles}
|
||||
onCancelButtonPress={this.cancelSearch}
|
||||
autoCapitalize='none'
|
||||
value={term}
|
||||
/>
|
||||
</View>
|
||||
<CustomList
|
||||
data={filteredData}
|
||||
theme={theme}
|
||||
searching={searching}
|
||||
onListEndReached={more}
|
||||
listScrollRenderAheadDistance={50}
|
||||
loading={this.state.isLoading}
|
||||
loadingText={loadingText}
|
||||
selectable={false}
|
||||
onRowPress={this.handleRowSelect}
|
||||
renderRow={rowComponent}
|
||||
showNoResults={this.state.showNoResults}
|
||||
showSections={false}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
|
||||
return {
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.centerChannelBg,
|
||||
},
|
||||
searchContainer: {
|
||||
marginVertical: 5,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const filterPageData = memoizeResult((data, page) => {
|
||||
return data.slice(0, (page + 1) * General.PROFILE_CHUNK_SIZE);
|
||||
});
|
||||
|
||||
const filterSearchData = memoizeResult((dataSource, data, term) => {
|
||||
if (!data) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (dataSource === ViewTypes.DATA_SOURCE_USERS) {
|
||||
return filterProfilesMatchingTerm(data, term);
|
||||
} else if (dataSource === ViewTypes.DATA_SOURCE_CHANNELS) {
|
||||
return filterChannelsMatchingTerm(data, term);
|
||||
}
|
||||
|
||||
return data.filter((option) => option.text && option.text.toLowerCase().startsWith(term));
|
||||
});
|
||||
|
||||
export default injectIntl(MenuActionSelector);
|
||||
139
app/screens/menu_action_selector/menu_action_selector.test.js
Normal file
139
app/screens/menu_action_selector/menu_action_selector.test.js
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import React from 'react';
|
||||
import {shallow} from 'enzyme';
|
||||
import {IntlProvider} from 'react-intl';
|
||||
|
||||
import MenuActionSelector from './menu_action_selector.js';
|
||||
|
||||
jest.mock('rn-fetch-blob', () => ({
|
||||
fs: {
|
||||
dirs: {
|
||||
DocumentDir: () => jest.fn(),
|
||||
CacheDir: () => jest.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('rn-fetch-blob/fs', () => ({
|
||||
dirs: {
|
||||
DocumentDir: () => jest.fn(),
|
||||
CacheDir: () => jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const user1 = {id: 'id', username: 'username'};
|
||||
const user2 = {id: 'id2', username: 'username2'};
|
||||
|
||||
const getProfiles = async () => {
|
||||
return {
|
||||
data: [user1, user2],
|
||||
error: {},
|
||||
};
|
||||
};
|
||||
|
||||
const searchProfiles = async () => {
|
||||
return {
|
||||
data: [user2],
|
||||
error: {},
|
||||
};
|
||||
};
|
||||
|
||||
const channel1 = {id: 'id', name: 'name', display_name: 'display_name'};
|
||||
const channel2 = {id: 'id2', name: 'name2', display_name: 'display_name2'};
|
||||
|
||||
const getChannels = async () => {
|
||||
return {
|
||||
data: [channel1, channel2],
|
||||
error: {},
|
||||
};
|
||||
};
|
||||
|
||||
const searchChannels = async () => {
|
||||
return {
|
||||
data: [channel2],
|
||||
error: {},
|
||||
};
|
||||
};
|
||||
|
||||
const intlProvider = new IntlProvider({locale: 'en'}, {});
|
||||
const {intl} = intlProvider.getChildContext();
|
||||
|
||||
describe('MenuActionSelector', () => {
|
||||
const actions = {
|
||||
getProfiles,
|
||||
getChannels,
|
||||
searchProfiles,
|
||||
searchChannels,
|
||||
};
|
||||
|
||||
const baseProps = {
|
||||
actions,
|
||||
currentTeamId: 'someId',
|
||||
navigator: {
|
||||
setOnNavigatorEvent: jest.fn(),
|
||||
},
|
||||
onSelect: jest.fn(),
|
||||
data: [{text: 'text', value: 'value'}],
|
||||
dataSource: null,
|
||||
theme: {},
|
||||
};
|
||||
|
||||
test('should match snapshot for explicit options', async () => {
|
||||
const wrapper = shallow(
|
||||
<MenuActionSelector {...baseProps}/>,
|
||||
{context: {intl}},
|
||||
);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('should match snapshot for users', async () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
dataSource: 'users',
|
||||
data: [user1, user2],
|
||||
};
|
||||
|
||||
const wrapper = shallow(
|
||||
<MenuActionSelector {...props}/>,
|
||||
{context: {intl}},
|
||||
);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
wrapper.setState({isLoading: false});
|
||||
wrapper.update();
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('should match snapshot for channels', async () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
dataSource: 'channels',
|
||||
data: [channel1, channel2],
|
||||
};
|
||||
|
||||
const wrapper = shallow(
|
||||
<MenuActionSelector {...props}/>,
|
||||
{context: {intl}},
|
||||
);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
wrapper.setState({isLoading: false});
|
||||
wrapper.update();
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('should match snapshot for searching', async () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
dataSource: 'channels',
|
||||
data: [channel1, channel2],
|
||||
};
|
||||
|
||||
const wrapper = shallow(
|
||||
<MenuActionSelector {...props}/>,
|
||||
{context: {intl}},
|
||||
);
|
||||
wrapper.setState({isLoading: false, searching: true, term: 'name2'});
|
||||
wrapper.update();
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
|
@ -21,6 +21,8 @@
|
|||
"about.title": "About Mattermost",
|
||||
"about.version": "Mattermost Version:",
|
||||
"access_history.title": "Access History",
|
||||
"mobile.action_menu.select": "Select an option",
|
||||
"mobile.action_menu.submitted": "Submitted",
|
||||
"activity_log.activeSessions": "Active Sessions",
|
||||
"activity_log.browser": "Browser: {browser}",
|
||||
"activity_log.firstTime": "First time active: {date}, {time}",
|
||||
|
|
|
|||
Loading…
Reference in a new issue