diff --git a/app/actions/views/command.js b/app/actions/views/command.js index cad181e88..a51266e25 100644 --- a/app/actions/views/command.js +++ b/app/actions/views/command.js @@ -1,6 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {IntegrationTypes} from 'mattermost-redux/action_types'; import {executeCommand as executeCommandService} from 'mattermost-redux/actions/integrations'; import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams'; @@ -27,6 +28,12 @@ export function executeCommand(message, channelId, rootId) { const cmd = msg.substring(0, cmdLength).toLowerCase(); msg = cmd + msg.substring(cmdLength, msg.length); - return executeCommandService(msg, args)(dispatch, getState); + const {data, error} = await dispatch(executeCommandService(msg, args)); + + if (data.trigger_id) { + dispatch({type: IntegrationTypes.RECEIVED_DIALOG_TRIGGER_ID, data: data.trigger_id}); + } + + return {data, error}; }; } diff --git a/app/actions/views/post.js b/app/actions/views/post.js index 1d7dc9827..b840c8337 100644 --- a/app/actions/views/post.js +++ b/app/actions/views/post.js @@ -41,7 +41,7 @@ export function sendAddToChannelEphemeralPost(user, addedUsername, message, chan }; } -export function setMenuActionSelector(dataSource, onSelect, options) { +export function setAutocompleteSelector(dataSource, onSelect, options) { return { type: ViewTypes.SELECTED_ACTION_MENU, data: { @@ -52,14 +52,14 @@ export function setMenuActionSelector(dataSource, onSelect, options) { }; } -export function selectAttachmentMenuAction(postId, actionId, displayText, value) { +export function selectAttachmentMenuAction(postId, actionId, text, value) { return (dispatch) => { dispatch({ type: ViewTypes.SUBMIT_ATTACHMENT_MENU_ACTION, postId, data: { [actionId]: { - displayText, + text, value, }, }, diff --git a/app/components/autocomplete_selector/autocomplete_selector.js b/app/components/autocomplete_selector/autocomplete_selector.js new file mode 100644 index 000000000..49cbc44d6 --- /dev/null +++ b/app/components/autocomplete_selector/autocomplete_selector.js @@ -0,0 +1,289 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {PureComponent} from 'react'; +import {Text, TouchableOpacity, View} from 'react-native'; +import PropTypes from 'prop-types'; +import {intlShape} from 'react-intl'; +import Icon from 'react-native-vector-icons/FontAwesome'; + +import {displayUsername} from 'mattermost-redux/utils/user_utils'; + +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 AutocompleteSelector extends PureComponent { + static propTypes = { + actions: PropTypes.shape({ + setAutocompleteSelector: PropTypes.func.isRequired, + }).isRequired, + label: PropTypes.string, + placeholder: PropTypes.string.isRequired, + dataSource: PropTypes.string, + options: PropTypes.arrayOf(PropTypes.object), + selected: PropTypes.object, + optional: PropTypes.bool, + showRequiredAsterisk: PropTypes.bool, + teammateNameDisplay: PropTypes.string, + theme: PropTypes.object.isRequired, + navigator: PropTypes.object, + onSelected: PropTypes.func, + helpText: PropTypes.node, + errorText: PropTypes.node, + roundedBorders: PropTypes.bool, + }; + + static contextTypes = { + intl: intlShape, + }; + + static defaultProps = { + optional: false, + showRequiredAsterisk: false, + roundedBorders: true, + }; + + constructor(props) { + super(props); + + this.state = { + selectedText: null, + }; + } + + static getDerivedStateFromProps(props, state) { + if (props.selected && props.selected !== state.selected) { + return { + selectedText: props.selected.text, + selected: props.selected, + }; + } + + return null; + } + + handleSelect = (selected) => { + if (!selected) { + return; + } + + const { + dataSource, + teammateNameDisplay, + } = this.props; + + let selectedText; + let selectedValue; + if (dataSource === ViewTypes.DATA_SOURCE_USERS) { + selectedText = displayUsername(selected, teammateNameDisplay); + 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}); + + if (this.props.onSelected) { + this.props.onSelected({text: selectedText, value: selectedValue}); + } + }; + + goToSelectorScreen = preventDoubleTap(() => { + const {formatMessage} = this.context.intl; + const {navigator, theme, actions, dataSource, options, placeholder} = this.props; + + actions.setAutocompleteSelector(dataSource, this.handleSelect, options); + + navigator.push({ + backButtonTitle: '', + screen: 'SelectorScreen', + title: placeholder || 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 { + placeholder, + theme, + label, + helpText, + errorText, + optional, + showRequiredAsterisk, + roundedBorders, + } = this.props; + const {selectedText} = this.state; + const style = getStyleSheet(theme); + + let text = placeholder || intl.formatMessage({id: 'mobile.action_menu.select', defaultMessage: 'Select an option'}); + let selectedStyle = style.dropdownPlaceholder; + + if (selectedText) { + text = selectedText; + selectedStyle = style.dropdownSelected; + } + + let inputStyle = style.input; + if (roundedBorders) { + inputStyle = style.roundedInput; + } + + let optionalContent; + let asterisk; + if (optional) { + optionalContent = ( + + ); + } else if (showRequiredAsterisk) { + asterisk = {' *'}; + } + + let labelContent; + if (label) { + labelContent = ( + + + {label} + + {asterisk} + {optionalContent} + + + ); + } + + let helpTextContent; + if (helpText) { + helpTextContent = ( + + {helpText} + + ); + } + + let errorTextContent; + if (errorText) { + errorTextContent = ( + + {errorText} + + ); + } + + return ( + + {labelContent} + + + + {text} + + + + + {helpTextContent} + {errorTextContent} + + ); + } +} + +const getStyleSheet = makeStyleSheetFromTheme((theme) => { + const input = { + borderWidth: 1, + borderColor: changeOpacity(theme.centerChannelColor, 0.1), + backgroundColor: changeOpacity(theme.centerChannelBg, 0.9), + paddingLeft: 10, + paddingRight: 30, + paddingVertical: 7, + height: 40, + }; + + return { + container: { + width: '100%', + marginBottom: 2, + marginRight: 8, + marginTop: 10, + }, + roundedInput: { + ...input, + borderRadius: 5, + }, + input, + dropdownPlaceholder: { + top: 3, + marginLeft: 5, + color: changeOpacity(theme.centerChannelColor, 0.5), + }, + dropdownSelected: { + top: 3, + marginLeft: 5, + color: theme.centerChannelColor, + }, + icon: { + position: 'absolute', + top: 13, + right: 12, + }, + labelContainer: { + flexDirection: 'row', + marginTop: 15, + marginBottom: 10, + }, + label: { + fontSize: 14, + color: theme.centerChannelColor, + marginLeft: 15, + }, + optional: { + color: changeOpacity(theme.centerChannelColor, 0.5), + fontSize: 14, + marginLeft: 5, + }, + helpText: { + fontSize: 12, + color: changeOpacity(theme.centerChannelColor, 0.5), + marginHorizontal: 15, + marginVertical: 10, + }, + errorText: { + fontSize: 12, + color: theme.errorTextColor, + marginHorizontal: 15, + marginVertical: 10, + }, + asterisk: { + color: theme.errorTextColor, + fontSize: 14, + }, + }; +}); diff --git a/app/components/autocomplete_selector/index.js b/app/components/autocomplete_selector/index.js new file mode 100644 index 000000000..dc77afea9 --- /dev/null +++ b/app/components/autocomplete_selector/index.js @@ -0,0 +1,28 @@ +// 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 {getTeammateNameDisplaySetting, getTheme} from 'mattermost-redux/selectors/entities/preferences'; + +import {setAutocompleteSelector} from 'app/actions/views/post'; + +import AutocompleteSelector from './autocomplete_selector'; + +function mapStateToProps(state) { + return { + teammateNameDisplay: getTeammateNameDisplaySetting(state), + theme: getTheme(state), + }; +} + +function mapDispatchToProps(dispatch) { + return { + actions: bindActionCreators({ + setAutocompleteSelector, + }, dispatch), + }; +} + +export default connect(mapStateToProps, mapDispatchToProps)(AutocompleteSelector); diff --git a/app/components/interactive_dialog_controller/index.js b/app/components/interactive_dialog_controller/index.js new file mode 100644 index 000000000..10d93d094 --- /dev/null +++ b/app/components/interactive_dialog_controller/index.js @@ -0,0 +1,15 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {connect} from 'react-redux'; + +import InteractiveDialogController from './interactive_dialog_controller'; + +function mapStateToProps(state) { + return { + triggerId: state.entities.integrations.dialogTriggerId, + dialog: state.entities.integrations.dialog || {}, + }; +} + +export default connect(mapStateToProps)(InteractiveDialogController); diff --git a/app/components/interactive_dialog_controller/interactive_dialog_controller.js b/app/components/interactive_dialog_controller/interactive_dialog_controller.js new file mode 100644 index 000000000..fcba9b347 --- /dev/null +++ b/app/components/interactive_dialog_controller/interactive_dialog_controller.js @@ -0,0 +1,76 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {PureComponent} from 'react'; +import PropTypes from 'prop-types'; +import MaterialIcon from 'react-native-vector-icons/MaterialIcons'; + +export default class InteractiveDialogController extends PureComponent { + static propTypes = { + triggerId: PropTypes.string, + dialog: PropTypes.object, + navigator: PropTypes.object, + theme: PropTypes.object, + }; + + constructor(props) { + super(props); + + MaterialIcon.getImageSource('close', 20, props.theme.sidebarHeaderTextColor).then((source) => { + this.closeButton = source; + }); + } + + componentDidUpdate(prevProps) { + const triggerId = this.props.triggerId; + if (!triggerId) { + return; + } + + const dialogData = this.props.dialog || {}; + const prevDialogData = prevProps.dialog || {}; + if (prevProps.triggerId === triggerId && dialogData.trigger_id === prevDialogData.trigger_id) { + return; + } + + if (dialogData.trigger_id !== triggerId) { + return; + } + + if (!dialogData.trigger_id || !dialogData.dialog) { + return; + } + + const theme = this.props.theme; + + this.props.navigator.showModal({ + backButtonTitle: '', + screen: 'InteractiveDialog', + title: dialogData.dialog.title, + animated: true, + navigatorStyle: { + navBarTextColor: theme.sidebarHeaderTextColor, + navBarBackgroundColor: theme.sidebarHeaderBg, + navBarButtonColor: theme.sidebarHeaderTextColor, + screenBackgroundColor: theme.centerChannelBg, + }, + navigatorButtons: { + leftButtons: [{ + id: 'close-dialog', + icon: this.closeButton, + }], + rightButtons: [ + { + id: 'submit-dialog', + showAsAction: 'always', + title: dialogData.dialog.submit_label, + }, + ], + }, + }); + } + + render() { + return null; + } +} diff --git a/app/components/message_attachments/action_menu/action_menu.js b/app/components/message_attachments/action_menu/action_menu.js index 57f0313fc..8a0b1d461 100644 --- a/app/components/message_attachments/action_menu/action_menu.js +++ b/app/components/message_attachments/action_menu/action_menu.js @@ -2,23 +2,14 @@ // See LICENSE.txt for license information. import React, {PureComponent} from 'react'; -import {Text, TouchableOpacity, View} from 'react-native'; import PropTypes from 'prop-types'; -import {intlShape} from 'react-intl'; -import Icon from 'react-native-vector-icons/FontAwesome'; -import {displayUsername} from 'mattermost-redux/utils/user_utils'; - -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'; +import AutocompleteSelector from 'app/components/autocomplete_selector'; export default class ActionMenu extends PureComponent { static propTypes = { actions: PropTypes.shape({ selectAttachmentMenuAction: PropTypes.func.isRequired, - setMenuActionSelector: PropTypes.func.isRequired, }).isRequired, id: PropTypes.string.isRequired, name: PropTypes.string.isRequired, @@ -26,34 +17,9 @@ export default class ActionMenu extends PureComponent { options: PropTypes.arrayOf(PropTypes.object), postId: PropTypes.string.isRequired, selected: PropTypes.object, - teammateNameDisplay: PropTypes.string, - theme: PropTypes.object.isRequired, navigator: PropTypes.object, }; - static contextTypes = { - intl: intlShape, - }; - - constructor(props) { - super(props); - - this.state = { - selectedText: null, - }; - } - - static getDerivedStateFromProps(props, state) { - if (props.selected && props.selected !== state.selected) { - return { - selectedText: props.selected.displayText, - selected: props.selected, - }; - } - - return null; - } - handleSelect = (selected) => { if (!selected) { return; @@ -61,157 +27,31 @@ export default class ActionMenu extends PureComponent { const { actions, - dataSource, id, postId, - teammateNameDisplay, } = this.props; - let selectedText; - let selectedValue; - if (dataSource === ViewTypes.DATA_SOURCE_USERS) { - selectedText = displayUsername(selected, teammateNameDisplay); - 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.selectAttachmentMenuAction(postId, id, selectedText, selectedValue); + actions.selectAttachmentMenuAction(postId, id, selected.text, selected.value); }; - goToMenuActionSelector = preventDoubleTap(() => { - const {formatMessage} = this.context.intl; - const {navigator, theme, actions, dataSource, options, name} = this.props; - - actions.setMenuActionSelector(dataSource, this.handleSelect, options); - - navigator.push({ - backButtonTitle: '', - screen: 'MenuActionSelector', - title: name || 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 = ( - - - - - ); - } else { - submitted = ; - } + const { + name, + dataSource, + selected, + options, + navigator, + } = this.props; return ( - - - - - {text} - - - - - {submitted} - + ); } -} - -const getStyleSheet = makeStyleSheetFromTheme((theme) => { - return { - flex: { - flex: 1, - }, - 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', - }, - }; -}); +} \ No newline at end of file diff --git a/app/components/message_attachments/action_menu/index.js b/app/components/message_attachments/action_menu/index.js index 0a0ee77c5..913d338fb 100644 --- a/app/components/message_attachments/action_menu/index.js +++ b/app/components/message_attachments/action_menu/index.js @@ -4,9 +4,7 @@ import {bindActionCreators} from 'redux'; import {connect} from 'react-redux'; -import {getTeammateNameDisplaySetting, getTheme} from 'mattermost-redux/selectors/entities/preferences'; - -import {setMenuActionSelector, selectAttachmentMenuAction} from 'app/actions/views/post'; +import {selectAttachmentMenuAction} from 'app/actions/views/post'; import ActionMenu from './action_menu'; @@ -16,8 +14,6 @@ function mapStateToProps(state, ownProps) { return { selected, - teammateNameDisplay: getTeammateNameDisplaySetting(state), - theme: getTheme(state), }; } @@ -25,7 +21,6 @@ function mapDispatchToProps(dispatch) { return { actions: bindActionCreators({ selectAttachmentMenuAction, - setMenuActionSelector, }, dispatch), }; } diff --git a/app/components/message_attachments/attachment_actions.js b/app/components/message_attachments/attachment_actions.js index 186c95b54..4c9aa6195 100644 --- a/app/components/message_attachments/attachment_actions.js +++ b/app/components/message_attachments/attachment_actions.js @@ -2,7 +2,6 @@ // See LICENSE.txt for license information. import React, {PureComponent} from 'react'; -import {StyleSheet, View} from 'react-native'; import PropTypes from 'prop-types'; import ActionMenu from './action_menu'; @@ -61,16 +60,6 @@ export default class AttachmentActions extends PureComponent { } }); - return ( - - {content} - - ); + return content; } -} - -const style = StyleSheet.create({ - container: { - flex: 1, - }, -}); +} \ No newline at end of file diff --git a/app/components/widgets/settings/text_setting.js b/app/components/widgets/settings/text_setting.js new file mode 100644 index 000000000..f2bf76df0 --- /dev/null +++ b/app/components/widgets/settings/text_setting.js @@ -0,0 +1,235 @@ +// 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 { + View, + Text, + TextInput, + Platform, +} from 'react-native'; + +import FormattedText from 'app/components/formatted_text'; +import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme'; + +export default class TextSetting extends PureComponent { + static propTypes = { + id: PropTypes.string.isRequired, + label: PropTypes.oneOfType([ + PropTypes.shape({ + id: PropTypes.string.isRequired, + defaultMessage: PropTypes.string.isRequired, + }), + PropTypes.string, + ]), + placeholder: PropTypes.string, + helpText: PropTypes.node, + errorText: PropTypes.node, + disabled: PropTypes.bool, + disabledText: PropTypes.string, + maxLength: PropTypes.number, + optional: PropTypes.bool, + theme: PropTypes.object.isRequired, + onChange: PropTypes.func.isRequired, + value: PropTypes.string.isRequired, + multiline: PropTypes.bool, + showRequiredAsterisk: PropTypes.bool, + keyboardType: PropTypes.oneOf([ + 'default', + 'number-pad', + 'decimal-pad', + 'numeric', + 'email-address', + 'phone-pad', + 'url', + ]), + }; + + static defaultProps = { + optional: false, + disabled: false, + multiline: false, + showRequiredAsterisk: false, + keyboardType: 'default', + }; + + onChangeText = (value) => { + const {id, onChange} = this.props; + onChange(id, value); + }; + + render() { + const { + theme, + label, + placeholder, + helpText, + optional, + disabled, + disabledText, + errorText, + value, + multiline, + showRequiredAsterisk, + } = this.props; + const style = getStyleSheet(theme); + + let labelContent = label; + if (label && label.defaultMessage) { + labelContent = ( + + ); + } else if (typeof label === 'string') { + labelContent = {label}; + } + + let optionalContent; + let asterisk; + if (optional) { + optionalContent = ( + + ); + } else if (showRequiredAsterisk) { + asterisk = {' *'}; + } + + let {keyboardType} = this.props; + if (Platform.OS === 'android' && keyboardType === 'url') { + keyboardType = 'default'; + } + + let inputStyle = style.input; + if (multiline) { + inputStyle = style.multiline; + } + + let helpTextContent; + if (helpText) { + helpTextContent = ( + + {helpText} + + ); + } + + let errorTextContent; + if (errorText) { + errorTextContent = ( + + {errorText} + + ); + } + + let disabledTextContent; + if (disabled && disabledText) { + disabledTextContent = ( + + {disabledText} + + ); + } + + return ( + + + {labelContent} + {asterisk} + {optionalContent} + + + + + + + {disabledTextContent} + {helpTextContent} + {errorTextContent} + + ); + } +} + +const getStyleSheet = makeStyleSheetFromTheme((theme) => { + const input = { + color: theme.centerChannelColor, + fontSize: 14, + paddingHorizontal: 15, + }; + + return { + inputContainer: { + borderTopWidth: 1, + borderBottomWidth: 1, + borderTopColor: changeOpacity(theme.centerChannelColor, 0.1), + borderBottomColor: changeOpacity(theme.centerChannelColor, 0.1), + backgroundColor: '#fff', + }, + input: { + ...input, + height: 40, + }, + multiline: { + ...input, + paddingTop: 10, + paddingBottom: 13, + height: 125, + }, + disabled: { + backgroundColor: changeOpacity(theme.centerChannelColor, 0.1), + }, + title: { + fontSize: 14, + color: theme.centerChannelColor, + marginLeft: 15, + }, + titleContainer: { + flexDirection: 'row', + marginTop: 15, + marginBottom: 10, + }, + optional: { + color: changeOpacity(theme.centerChannelColor, 0.5), + fontSize: 14, + marginLeft: 5, + }, + helpText: { + fontSize: 12, + color: changeOpacity(theme.centerChannelColor, 0.5), + marginHorizontal: 15, + marginTop: 10, + }, + errorText: { + fontSize: 12, + color: theme.errorTextColor, + marginHorizontal: 15, + marginTop: 10, + }, + asterisk: { + color: theme.errorTextColor, + fontSize: 14, + }, + }; +}); diff --git a/app/components/widgets/settings/text_setting.test.js b/app/components/widgets/settings/text_setting.test.js new file mode 100644 index 000000000..1cb293c3a --- /dev/null +++ b/app/components/widgets/settings/text_setting.test.js @@ -0,0 +1,29 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import React from 'react'; +import {shallow} from 'enzyme'; + +import Preferences from 'mattermost-redux/constants/preferences'; + +import TextSetting from './text_setting.js'; + +describe('components/widgets/settings/TextSetting', () => { + const theme = Preferences.THEMES.default; + test('onChange', () => { + const onChange = jest.fn(); + const wrapper = shallow( + + ); + + wrapper.instance().onChangeText('somenewvalue'); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith('string.id', 'somenewvalue'); + }); +}); diff --git a/app/screens/channel/channel.js b/app/screens/channel/channel.js index 6e2ec37e2..09196d527 100644 --- a/app/screens/channel/channel.js +++ b/app/screens/channel/channel.js @@ -14,6 +14,7 @@ import { import MaterialIcon from 'react-native-vector-icons/MaterialIcons'; import EventEmitter from 'mattermost-redux/utils/event_emitter'; +import InteractiveDialogController from 'app/components/interactive_dialog_controller'; import EmptyToolbar from 'app/components/start/empty_toolbar'; import ChannelLoader from 'app/components/channel_loader'; import MainSidebar from 'app/components/sidebars/main'; @@ -336,6 +337,10 @@ export default class Channel extends PureComponent { {LocalConfig.EnableMobileClientUpgrade && } + ); } diff --git a/app/screens/edit_profile/edit_profile.js b/app/screens/edit_profile/edit_profile.js index ff40c1e3c..8c38d2e32 100644 --- a/app/screens/edit_profile/edit_profile.js +++ b/app/screens/edit_profile/edit_profile.js @@ -16,6 +16,7 @@ import {preventDoubleTap} from 'app/utils/tap'; import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme'; import {t} from 'app/utils/i18n'; +import TextSetting from 'app/components/widgets/settings/text_setting'; import Loading from 'app/components/loading'; import ErrorText from 'app/components/error_text'; import StatusBar from 'app/components/status_bar/index'; @@ -24,8 +25,6 @@ import AttachmentButton from 'app/components/attachment_button'; import mattermostBucket from 'app/mattermost_bucket'; import LocalConfig from 'assets/config'; -import EditProfileItem from './edit_profile_item'; - const holders = { firstName: { id: t('user.settings.general.firstName'), @@ -221,7 +220,8 @@ export default class EditProfile extends PureComponent { return RNFetchBlob.config(options).fetch('POST', `${Client4.getUserRoute(currentUser.id)}/image`, headers, [fileInfo]); }; - updateField = (field) => { + updateField = (id, name) => { + const field = {[id]: name}; this.setState(field, () => { this.emitCanUpdateAccount(this.canUpdate(field)); }); @@ -250,15 +250,15 @@ export default class EditProfile extends PureComponent { (service === 'saml' && config.SamlFirstNameAttributeSet === 'true'); return ( - @@ -276,15 +276,15 @@ export default class EditProfile extends PureComponent { return ( - @@ -299,16 +299,16 @@ export default class EditProfile extends PureComponent { const disabled = currentUser.auth_service !== ''; return ( - @@ -368,12 +368,12 @@ export default class EditProfile extends PureComponent { return ( - @@ -391,16 +391,16 @@ export default class EditProfile extends PureComponent { (service === 'saml' && config.SamlNicknameAttributeSet === 'true'); return ( - @@ -416,16 +416,16 @@ export default class EditProfile extends PureComponent { const disabled = (service === 'ldap' || service === 'saml') && config.PositionAttribute === 'true'; return ( - diff --git a/app/screens/edit_profile/edit_profile_item.js b/app/screens/edit_profile/edit_profile_item.js deleted file mode 100644 index 656833be1..000000000 --- a/app/screens/edit_profile/edit_profile_item.js +++ /dev/null @@ -1,133 +0,0 @@ -// 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 { - View, - Text, - TextInput, -} from 'react-native'; - -import FormattedText from 'app/components/formatted_text'; -import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme'; - -export default class AccountSettingsItem extends PureComponent { - static propTypes = { - disabled: PropTypes.bool, - field: PropTypes.string.isRequired, - format: PropTypes.shape({ - id: PropTypes.string.isRequired, - defaultMessage: PropTypes.string.isRequired, - }), - helpText: PropTypes.string, - maxLength: PropTypes.number, - optional: PropTypes.bool, - theme: PropTypes.object.isRequired, - updateValue: PropTypes.func.isRequired, - value: PropTypes.string.isRequired, - }; - - static defaultProps = { - optional: false, - disabled: false, - }; - - onChangeText = (value) => { - const {field, updateValue} = this.props; - updateValue({[field]: value}); - }; - - render() { - const { - theme, - format, - helpText, - optional, - disabled, - value, - } = this.props; - const style = getStyleSheet(theme); - - return ( - - - - {optional && ( - - )} - - - - - - {disabled && - - {helpText} - - } - - - ); - } -} - -const getStyleSheet = makeStyleSheetFromTheme((theme) => { - return { - inputContainer: { - borderTopWidth: 1, - borderBottomWidth: 1, - borderTopColor: changeOpacity(theme.centerChannelColor, 0.1), - borderBottomColor: changeOpacity(theme.centerChannelColor, 0.1), - backgroundColor: theme.centerChannelBg, - }, - input: { - color: theme.centerChannelColor, - fontSize: 14, - height: 40, - paddingHorizontal: 15, - }, - disabled: { - backgroundColor: changeOpacity(theme.centerChannelColor, 0.1), - }, - title: { - fontSize: 14, - color: theme.centerChannelColor, - marginLeft: 15, - }, - titleContainer15: { - flexDirection: 'row', - marginTop: 15, - }, - optional: { - color: changeOpacity(theme.centerChannelColor, 0.5), - fontSize: 14, - marginLeft: 5, - }, - helpText: { - fontSize: 12, - color: changeOpacity(theme.centerChannelColor, 0.5), - marginHorizontal: 15, - marginVertical: 10, - }, - }; -}); diff --git a/app/screens/index.js b/app/screens/index.js index a6b3986bc..5722c3155 100644 --- a/app/screens/index.js +++ b/app/screens/index.js @@ -30,10 +30,10 @@ export function registerScreens(store, Provider) { Navigation.registerComponent('FlaggedPosts', () => wrapWithContextProvider(require('app/screens/flagged_posts').default), store, Provider); Navigation.registerComponent('ForgotPassword', () => wrapWithContextProvider(require('app/screens/forgot_password').default), store, Provider); Navigation.registerComponent('ImagePreview', () => wrapWithContextProvider(require('app/screens/image_preview').default), store, Provider); + Navigation.registerComponent('InteractiveDialog', () => wrapWithContextProvider(require('app/screens/interactive_dialog').default), 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); @@ -52,6 +52,7 @@ export function registerScreens(store, Provider) { Navigation.registerComponent('RecentMentions', () => wrapWithContextProvider(require('app/screens/recent_mentions').default), store, Provider); Navigation.registerComponent('Root', () => require('app/screens/root').default, store, Provider); Navigation.registerComponent('Search', () => wrapWithContextProvider(require('app/screens/search').default), store, Provider); + Navigation.registerComponent('SelectorScreen', () => wrapWithContextProvider(require('app/screens/selector_screen').default), store, Provider); Navigation.registerComponent('SelectServer', () => wrapWithContextProvider(SelectServer), store, Provider); Navigation.registerComponent('SelectTeam', () => wrapWithContextProvider(require('app/screens/select_team').default), store, Provider); Navigation.registerComponent('SelectTimezone', () => wrapWithContextProvider(require('app/screens/timezone/select_timezone').default), store, Provider); diff --git a/app/screens/interactive_dialog/dialog_element.js b/app/screens/interactive_dialog/dialog_element.js new file mode 100644 index 000000000..e675bcf78 --- /dev/null +++ b/app/screens/interactive_dialog/dialog_element.js @@ -0,0 +1,139 @@ +// 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 TextSetting from 'app/components/widgets/settings/text_setting'; +import AutocompleteSelector from 'app/components/autocomplete_selector'; + +const TEXT_DEFAULT_MAX_LENGTH = 150; +const TEXTAREA_DEFAULT_MAX_LENGTH = 3000; + +export default class DialogElement extends PureComponent { + static propTypes = { + displayName: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + type: PropTypes.string.isRequired, + subtype: PropTypes.string, + placeholder: PropTypes.string, + helpText: PropTypes.string, + errorText: PropTypes.node, + maxLength: PropTypes.number, + dataSource: PropTypes.string, + optional: PropTypes.bool, + options: PropTypes.arrayOf(PropTypes.object), + value: PropTypes.any, + onChange: PropTypes.func, + navigator: PropTypes.object, + theme: PropTypes.object, + }; + + constructor(props) { + super(props); + + this.state = { + selected: null, + }; + } + + onChange = (name, value) => { + const {type, subtype, onChange} = this.props; + let newValue = value; + if (type === 'text' && subtype === 'number') { + newValue = parseInt(value, 10); + } + onChange(name, newValue); + } + + handleAutocompleteSelect = (selected) => { + if (!selected) { + return; + } + + this.setState({selected}); + + const {name, onChange} = this.props; + onChange(name, selected.value); + } + + render() { + const { + name, + type, + subtype, + displayName, + value, + placeholder, + helpText, + errorText, + optional, + theme, + dataSource, + options, + navigator, + } = this.props; + + let {maxLength} = this.props; + + if (type === 'text' || type === 'textarea') { + let keyboardType = 'default'; + let multiline = false; + if (type === 'text') { + maxLength = maxLength || TEXT_DEFAULT_MAX_LENGTH; + + if (subtype === 'email') { + keyboardType = 'email-address'; + } else if (subtype === 'number') { + keyboardType = 'numeric'; + } else if (subtype === 'tel') { + keyboardType = 'phone-pad'; + } else if (subtype === 'url') { + keyboardType = 'url'; + } + } else { + multiline = true; + maxLength = maxLength || TEXTAREA_DEFAULT_MAX_LENGTH; + } + + return ( + + ); + } else if (type === 'select') { + return ( + + ); + } + + return null; + } +} \ No newline at end of file diff --git a/app/screens/interactive_dialog/index.js b/app/screens/interactive_dialog/index.js new file mode 100644 index 000000000..e68b387ea --- /dev/null +++ b/app/screens/interactive_dialog/index.js @@ -0,0 +1,36 @@ +// 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 {getTheme} from 'mattermost-redux/selectors/entities/preferences'; +import {submitInteractiveDialog} from 'mattermost-redux/actions/integrations'; + +import InteractiveDialog from './interactive_dialog'; + +function mapStateToProps(state) { + const data = state.entities.integrations.dialog || {dialog: {}}; + + return { + url: data.url, + callbackId: data.dialog.callback_id, + elements: data.dialog.elements, + title: data.dialog.title, + iconUrl: data.dialog.icon_url, + submitLabel: data.dialog.submit_label, + notifyOnCancel: data.dialog.notify_on_cancel, + state: data.dialog.state, + theme: getTheme(state), + }; +} + +function mapDispatchToProps(dispatch) { + return { + actions: bindActionCreators({ + submitInteractiveDialog, + }, dispatch), + }; +} + +export default connect(mapStateToProps, mapDispatchToProps)(InteractiveDialog); diff --git a/app/screens/interactive_dialog/interactive_dialog.js b/app/screens/interactive_dialog/interactive_dialog.js new file mode 100644 index 000000000..8f181a89d --- /dev/null +++ b/app/screens/interactive_dialog/interactive_dialog.js @@ -0,0 +1,195 @@ +// 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 {ScrollView, View} from 'react-native'; + +import {checkDialogElementForError, checkIfErrorsMatchElements} from 'mattermost-redux/utils/integration_utils'; + +import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme'; + +import StatusBar from 'app/components/status_bar'; +import FormattedText from 'app/components/formatted_text'; + +import DialogElement from './dialog_element.js'; + +export default class InteractiveDialog extends PureComponent { + static propTypes = { + url: PropTypes.string.isRequired, + callbackId: PropTypes.string, + elements: PropTypes.arrayOf(PropTypes.object).isRequired, + notifyOnCancel: PropTypes.bool, + state: PropTypes.string, + navigator: PropTypes.object, + theme: PropTypes.object, + actions: PropTypes.shape({ + submitInteractiveDialog: PropTypes.func.isRequired, + }).isRequired, + }; + + static defaultProps = { + url: '', + elements: [], + }; + + constructor(props) { + super(props); + + props.navigator.setOnNavigatorEvent(this.onNavigatorEvent); + + const values = {}; + props.elements.forEach((e) => { + values[e.name] = e.default || null; + }); + + this.state = { + values, + errors: {}, + submitting: false, + }; + } + + onNavigatorEvent = (event) => { + if (event.type === 'NavBarButtonPress') { + switch (event.id) { + case 'submit-dialog': + this.handleSubmit(); + break; + case 'close-dialog': + this.notifyOnCancelIfNeeded(); + this.handleHide(); + break; + } + } + }; + + handleSubmit = async () => { + const {elements} = this.props; + const values = this.state.values; + const errors = {}; + elements.forEach((elem) => { + const error = checkDialogElementForError(elem, values[elem.name]); + if (error) { + errors[elem.name] = ( + + ); + } + }); + + this.setState({errors}); + + if (Object.keys(errors).length !== 0) { + return; + } + + const {url, callbackId, state} = this.props; + + const dialog = { + url, + callback_id: callbackId, + state, + submission: values, + }; + + const {data} = await this.props.actions.submitInteractiveDialog(dialog); + + this.submitted = true; + + if (!data || !data.errors || Object.keys(data.errors).length === 0) { + this.handleHide(); + return; + } + + if (checkIfErrorsMatchElements(data.errors, elements)) { + this.setState({errors: data.errors}); + return; + } + + this.handleHide(); + } + + notifyOnCancelIfNeeded = () => { + if (this.submitted) { + return; + } + + const {url, callbackId, state, notifyOnCancel} = this.props; + + if (!notifyOnCancel) { + return; + } + + const dialog = { + url, + callback_id: callbackId, + state, + cancelled: true, + }; + + this.props.actions.submitInteractiveDialog(dialog); + } + + handleHide = () => { + this.props.navigator.dismissModal({ + animationType: 'slide-down', + }); + } + + onChange = (name, value) => { + const values = {...this.state.values, [name]: value}; + this.setState({values}); + } + + render() { + const {elements, theme, navigator} = this.props; + const style = getStyleFromTheme(theme); + + return ( + + + + {elements.map((e) => { + return ( + + ); + })} + + + ); + } +} + +const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { + return { + container: { + backgroundColor: changeOpacity(theme.centerChannelColor, 0.03), + }, + scrollView: { + marginBottom: 20, + marginTop: 10, + }, + }; +}); \ No newline at end of file diff --git a/app/screens/interactive_dialog/interactive_dialog.test.js b/app/screens/interactive_dialog/interactive_dialog.test.js new file mode 100644 index 000000000..7f36358bb --- /dev/null +++ b/app/screens/interactive_dialog/interactive_dialog.test.js @@ -0,0 +1,95 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import React from 'react'; +import {shallow} from 'enzyme'; + +import Preferences from 'mattermost-redux/constants/preferences'; + +import InteractiveDialog from './interactive_dialog'; + +describe('InteractiveDialog', () => { + const baseProps = { + url: 'http://mattermost.com', + callbackId: 'someid', + elements: [ + {type: 'text', name: 'name1', default: 'defaulttext', display_name: 'Name1'}, + ], + notifyOnCancel: false, + state: 'somestate', + theme: Preferences.THEMES.default, + actions: { + submitInteractiveDialog: jest.fn(), + }, + navigator: { + setOnNavigatorEvent: jest.fn(), + dismissModal: jest.fn(), + }, + }; + + test('should set default values', async () => { + const wrapper = shallow( + , + ); + + expect(wrapper.state().values.name1).toBe('defaulttext'); + }); + + test('should submit dialog', async () => { + const submitInteractiveDialog = jest.fn(); + const wrapper = shallow( + , + ); + + const dialog = { + url: baseProps.url, + callback_id: baseProps.callbackId, + state: baseProps.state, + submission: {name1: 'defaulttext'}, + }; + + wrapper.instance().handleSubmit(); + expect(submitInteractiveDialog).toHaveBeenCalledTimes(1); + expect(submitInteractiveDialog).toHaveBeenCalledWith(dialog); + }); + + test('should submit dialog on cancel', async () => { + const submitInteractiveDialog = jest.fn(); + const wrapper = shallow( + , + ); + + const dialog = { + url: baseProps.url, + callback_id: baseProps.callbackId, + state: baseProps.state, + cancelled: true, + }; + + wrapper.instance().onNavigatorEvent({type: 'NavBarButtonPress', id: 'close-dialog'}); + expect(submitInteractiveDialog).toHaveBeenCalledTimes(1); + expect(submitInteractiveDialog).toHaveBeenCalledWith(dialog); + }); + + test('should not submit dialog on cancel', async () => { + const submitInteractiveDialog = jest.fn(); + const wrapper = shallow( + , + ); + + wrapper.instance().onNavigatorEvent({type: 'NavBarButtonPress', id: 'close-dialog'}); + expect(submitInteractiveDialog).not.toHaveBeenCalled(); + }); +}); diff --git a/app/screens/menu_action_selector/__snapshots__/menu_action_selector.test.js.snap b/app/screens/selector_screen/__snapshots__/selector_screen.test.js.snap similarity index 96% rename from app/screens/menu_action_selector/__snapshots__/menu_action_selector.test.js.snap rename to app/screens/selector_screen/__snapshots__/selector_screen.test.js.snap index 94127201f..b5e94b672 100644 --- a/app/screens/menu_action_selector/__snapshots__/menu_action_selector.test.js.snap +++ b/app/screens/selector_screen/__snapshots__/selector_screen.test.js.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`MenuActionSelector should match snapshot for channels 1`] = ` +exports[`SelectorScreen should match snapshot for channels 1`] = ` `; -exports[`MenuActionSelector should match snapshot for channels 2`] = ` +exports[`SelectorScreen should match snapshot for channels 2`] = ` `; -exports[`MenuActionSelector should match snapshot for explicit options 1`] = ` +exports[`SelectorScreen should match snapshot for explicit options 1`] = ` `; -exports[`MenuActionSelector should match snapshot for searching 1`] = ` +exports[`SelectorScreen should match snapshot for searching 1`] = ` `; -exports[`MenuActionSelector should match snapshot for users 1`] = ` +exports[`SelectorScreen should match snapshot for users 1`] = ` `; -exports[`MenuActionSelector should match snapshot for users 2`] = ` +exports[`SelectorScreen should match snapshot for users 2`] = ` ({ fs: { @@ -61,7 +61,7 @@ const searchChannels = async () => { const intlProvider = new IntlProvider({locale: 'en'}, {}); const {intl} = intlProvider.getChildContext(); -describe('MenuActionSelector', () => { +describe('SelectorScreen', () => { const actions = { getProfiles, getChannels, @@ -83,7 +83,7 @@ describe('MenuActionSelector', () => { test('should match snapshot for explicit options', async () => { const wrapper = shallow( - , + , {context: {intl}}, ); expect(wrapper.getElement()).toMatchSnapshot(); @@ -97,7 +97,7 @@ describe('MenuActionSelector', () => { }; const wrapper = shallow( - , + , {context: {intl}}, ); expect(wrapper.getElement()).toMatchSnapshot(); @@ -114,7 +114,7 @@ describe('MenuActionSelector', () => { }; const wrapper = shallow( - , + , {context: {intl}}, ); expect(wrapper.getElement()).toMatchSnapshot(); @@ -131,7 +131,7 @@ describe('MenuActionSelector', () => { }; const wrapper = shallow( - , + , {context: {intl}}, ); wrapper.setState({isLoading: false, searching: true, term: 'name2'}); diff --git a/package-lock.json b/package-lock.json index 88d346850..5bd14cfc7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2806,7 +2806,7 @@ }, "colors": { "version": "0.5.1", - "resolved": "http://registry.npmjs.org/colors/-/colors-0.5.1.tgz", + "resolved": "https://registry.npmjs.org/colors/-/colors-0.5.1.tgz", "integrity": "sha1-fQAj6usVTo7p/Oddy5I9DtFmd3Q=", "dev": true }, @@ -12143,7 +12143,7 @@ "dependencies": { "jsesc": { "version": "0.5.0", - "resolved": "http://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz", "integrity": "sha1-597mbjXW/Bb3EP6R1c9p9w8IkR0=" } }