diff --git a/app/components/autocomplete/slash_suggestion/slash_suggestion_item.tsx b/app/components/autocomplete/slash_suggestion/slash_suggestion_item.tsx index 8268299e2..9c5245233 100644 --- a/app/components/autocomplete/slash_suggestion/slash_suggestion_item.tsx +++ b/app/components/autocomplete/slash_suggestion/slash_suggestion_item.tsx @@ -155,13 +155,15 @@ const SlashSuggestionItem = (props: Props) => { {`${suggestionText}`} - - {description} - + {Boolean(description) && + + {description} + + } diff --git a/app/components/autocomplete_selector/autocomplete_selector.js b/app/components/autocomplete_selector/autocomplete_selector.tsx similarity index 54% rename from app/components/autocomplete_selector/autocomplete_selector.js rename to app/components/autocomplete_selector/autocomplete_selector.tsx index e3831efa3..d1c7f2cf2 100644 --- a/app/components/autocomplete_selector/autocomplete_selector.js +++ b/app/components/autocomplete_selector/autocomplete_selector.tsx @@ -1,10 +1,9 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import PropTypes from 'prop-types'; import React, {PureComponent} from 'react'; import {intlShape} from 'react-intl'; -import {Text, View} from 'react-native'; +import {Text, View, Platform} from 'react-native'; import {goToScreen} from '@actions/navigation'; import CompassIcon from '@components/compass_icon'; @@ -12,33 +11,46 @@ import FormattedText from '@components/formatted_text'; import Markdown from '@components/markdown'; import TouchableWithFeedback from '@components/touchable_with_feedback'; import {ViewTypes} from '@constants'; +import {ActionResult} from '@mm-redux/types/actions'; +import {Channel} from '@mm-redux/types/channels'; +import {DialogOption} from '@mm-redux/types/integrations'; +import {Theme} from '@mm-redux/types/theme'; +import {UserProfile} from '@mm-redux/types/users'; import {displayUsername} from '@mm-redux/utils/user_utils'; import {getMarkdownBlockStyles, getMarkdownTextStyles} from '@utils/markdown'; import {preventDoubleTap} from '@utils/tap'; import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme'; -export default class AutocompleteSelector extends PureComponent { - static propTypes = { - actions: PropTypes.shape({ - setAutocompleteSelector: PropTypes.func.isRequired, - }).isRequired, - getDynamicOptions: PropTypes.func, - 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, - onSelected: PropTypes.func, - helpText: PropTypes.node, - errorText: PropTypes.node, - roundedBorders: PropTypes.bool, - disabled: PropTypes.bool, - }; +type Selection = DialogOption | Channel | UserProfile | DialogOption[] | Channel[] | UserProfile[]; +type Props = { + actions: { + setAutocompleteSelector: (dataSource: any, onSelect: any, options: any, getDynamicOptions: any) => Promise; + }; + getDynamicOptions?: (term: string) => Promise; + label?: string; + placeholder?: string; + dataSource?: string; + options?: DialogOption[]; + selected?: DialogOption | DialogOption[]; + optional?: boolean; + showRequiredAsterisk?: boolean; + teammateNameDisplay?: string; + theme: Theme; + onSelected?: ((item: DialogOption) => void) | ((item: DialogOption[]) => void); + helpText?: string; + errorText?: string; + roundedBorders?: boolean; + disabled?: boolean; + isMultiselect?: boolean; +} + +type State = { + selectedText: string; + selected?: DialogOption | DialogOption[]; +} + +export default class AutocompleteSelector extends PureComponent { static contextTypes = { intl: intlShape, }; @@ -49,26 +61,45 @@ export default class AutocompleteSelector extends PureComponent { roundedBorders: true, }; - constructor(props) { + constructor(props: Props) { super(props); this.state = { - selectedText: null, + selectedText: '', }; } - static getDerivedStateFromProps(props, state) { - if (props.selected && props.selected !== state.selected) { + static getDerivedStateFromProps(props: Props, state: State) { + if (!props.selected || props.selected === state.selected) { + return null; + } + + if (!props.isMultiselect) { return { - selectedText: props.selected.text, + selectedText: (props.selected as DialogOption).text, selected: props.selected, }; } - return null; + const options = props.selected as DialogOption[]; + let selectedText = ''; + const selected: DialogOption[] = []; + + options.forEach((option) => { + if (selectedText !== '') { + selectedText += ', '; + } + selectedText += option.text; + selected.push(option); + }); + + return { + selectedText, + selected, + }; } - handleSelect = (selected) => { + handleSelect = (selected: Selection) => { if (!selected) { return; } @@ -78,34 +109,113 @@ export default class AutocompleteSelector extends PureComponent { 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; + if (!this.props.isMultiselect) { + let selectedText: string; + let selectedValue: string; + switch (dataSource) { + case ViewTypes.DATA_SOURCE_USERS: { + const typedSelected = selected as UserProfile; + selectedText = displayUsername(typedSelected, teammateNameDisplay || ''); + selectedValue = typedSelected.id; + break; + } + case ViewTypes.DATA_SOURCE_CHANNELS: { + const typedSelected = selected as Channel; + selectedText = typedSelected.display_name; + selectedValue = typedSelected.id; + break; + } + default: { + const typedSelected = selected as DialogOption; + selectedText = typedSelected.text; + selectedValue = typedSelected.value; + } + } + + this.setState({selectedText}); + + if (this.props.onSelected) { + (this.props.onSelected as (opt: DialogOption) => void)({text: selectedText, value: selectedValue}); + } + return; + } + + let selectedText = ''; + const selectedOptions: DialogOption[] = []; + switch (dataSource) { + case ViewTypes.DATA_SOURCE_USERS: { + const typedSelected = selected as UserProfile[]; + typedSelected.forEach((option) => { + if (selectedText !== '') { + selectedText += ', '; + } + const text = displayUsername(option, teammateNameDisplay || ''); + selectedText += text; + selectedOptions.push({text, value: option.id}); + }); + break; + } + case ViewTypes.DATA_SOURCE_CHANNELS: { + const typedSelected = selected as Channel[]; + typedSelected.forEach((option) => { + if (selectedText !== '') { + selectedText += ', '; + } + const text = option.display_name; + selectedText += text; + selectedOptions.push({text, value: option.id}); + }); + break; + } + default: { + const typedSelected = selected as DialogOption[]; + typedSelected.forEach((option) => { + if (selectedText !== '') { + selectedText += ', '; + } + selectedText += option.text; + selectedOptions.push(option); + }); + break; + } } this.setState({selectedText}); if (this.props.onSelected) { - this.props.onSelected({text: selectedText, value: selectedValue}); + (this.props.onSelected as (opt: DialogOption[]) => void)(selectedOptions); } }; - goToSelectorScreen = preventDoubleTap(() => { + goToSelectorScreen = preventDoubleTap(async () => { + const closeButton = await CompassIcon.getImageSource(Platform.select({ios: 'arrow-back-ios', default: 'arrow-left'}), 24, this.props.theme.sidebarHeaderTextColor); + const {formatMessage} = this.context.intl; - const {actions, dataSource, options, placeholder, getDynamicOptions} = this.props; + const {actions, dataSource, options, placeholder, getDynamicOptions, theme} = this.props; const screen = 'SelectorScreen'; const title = placeholder || formatMessage({id: 'mobile.action_menu.select', defaultMessage: 'Select an option'}); + const buttonName = formatMessage({id: 'mobile.forms.select.done', defaultMessage: 'Done'}); actions.setAutocompleteSelector(dataSource, this.handleSelect, options, getDynamicOptions); - goToScreen(screen, title); + let screenOptions = {}; + if (this.props.isMultiselect) { + screenOptions = { + topBar: { + leftButtons: [{ + id: 'close-dialog', + icon: closeButton, + }], + rightButtons: [{ + id: 'submit-form', + showAsAction: 'always', + text: buttonName, + }], + leftButtonColor: theme.sidebarHeaderTextColor, + rightButtonColor: theme.sidebarHeaderTextColor, + }, + }; + } + goToScreen(screen, title, {isMultiselect: this.props.isMultiselect, selected: this.state.selected}, screenOptions); }); render() { @@ -126,6 +236,8 @@ export default class AutocompleteSelector extends PureComponent { const textStyles = getMarkdownTextStyles(theme); const blockStyles = getMarkdownBlockStyles(theme); + const chevron = Platform.select({ios: 'chevron-right', default: 'chevron-down'}); + let text = placeholder || intl.formatMessage({id: 'mobile.action_menu.select', defaultMessage: 'Select an option'}); let selectedStyle = style.dropdownPlaceholder; @@ -215,8 +327,8 @@ export default class AutocompleteSelector extends PureComponent { {text} @@ -228,7 +340,7 @@ export default class AutocompleteSelector extends PureComponent { } } -const getStyleSheet = makeStyleSheetFromTheme((theme) => { +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { const input = { borderWidth: 1, borderColor: changeOpacity(theme.centerChannelColor, 0.1), @@ -263,8 +375,9 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => { }, icon: { position: 'absolute', - top: 13, + top: 6, right: 12, + fontSize: 28, }, labelContainer: { flexDirection: 'row', diff --git a/app/components/autocomplete_selector/index.js b/app/components/autocomplete_selector/index.ts similarity index 54% rename from app/components/autocomplete_selector/index.js rename to app/components/autocomplete_selector/index.ts index 719e09dcc..5167a794d 100644 --- a/app/components/autocomplete_selector/index.js +++ b/app/components/autocomplete_selector/index.ts @@ -2,23 +2,29 @@ // See LICENSE.txt for license information. import {connect} from 'react-redux'; -import {bindActionCreators} from 'redux'; +import {ActionCreatorsMapObject, bindActionCreators, Dispatch} from 'redux'; import {setAutocompleteSelector} from '@actions/views/post'; import {getTeammateNameDisplaySetting, getTheme} from '@mm-redux/selectors/entities/preferences'; import AutocompleteSelector from './autocomplete_selector'; -function mapStateToProps(state) { +import type {Action, ActionResult, GenericAction} from '@mm-redux/types/actions'; +import type {GlobalState} from '@mm-redux/types/store'; + +function mapStateToProps(state: GlobalState) { return { teammateNameDisplay: getTeammateNameDisplaySetting(state), theme: getTheme(state), }; } -function mapDispatchToProps(dispatch) { +type Actions = { + setAutocompleteSelector: (dataSource: any, onSelect: any, options: any, getDynamicOptions: any) => Promise; +} +function mapDispatchToProps(dispatch: Dispatch) { return { - actions: bindActionCreators({ + actions: bindActionCreators, Actions>({ setAutocompleteSelector, }, dispatch), }; diff --git a/app/components/custom_list/channel_list_row/channel_list_row.js b/app/components/custom_list/channel_list_row/channel_list_row.js index 6f98c5fa3..968c09bf4 100644 --- a/app/components/custom_list/channel_list_row/channel_list_row.js +++ b/app/components/custom_list/channel_list_row/channel_list_row.js @@ -52,33 +52,35 @@ export default class ChannelListRow extends React.PureComponent { } return ( - - + - - - - {this.props.channel.display_name} - + + + + + {this.props.channel.display_name} + + + {purpose} - {purpose} - - + + ); } } @@ -101,7 +103,12 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { container: { flex: 1, flexDirection: 'column', + }, + outerContainer: { + flex: 1, + flexDirection: 'row', paddingHorizontal: 15, + overflow: 'hidden', }, purpose: { marginTop: 7, diff --git a/app/components/custom_list/option_list_row/option_list_row.js b/app/components/custom_list/option_list_row/option_list_row.js index 3056b48ad..edfb961f4 100644 --- a/app/components/custom_list/option_list_row/option_list_row.js +++ b/app/components/custom_list/option_list_row/option_list_row.js @@ -42,21 +42,23 @@ export default class OptionListRow extends React.PureComponent { const style = getStyleFromTheme(theme); return ( - - - - - {text} - + + + + + + {text} + + - - + + ); } } diff --git a/app/components/custom_list/user_list_row/__snapshots__/user_list_test.test.js.snap b/app/components/custom_list/user_list_row/__snapshots__/user_list_test.test.js.snap index b53126b76..c5efe9e48 100644 --- a/app/components/custom_list/user_list_row/__snapshots__/user_list_test.test.js.snap +++ b/app/components/custom_list/user_list_row/__snapshots__/user_list_test.test.js.snap @@ -6,8 +6,8 @@ exports[`UserListRow should match snapshot 1`] = ` Object { "flex": 1, "flexDirection": "row", - "marginHorizontal": 10, "overflow": "hidden", + "paddingHorizontal": 15, } } > @@ -142,8 +142,8 @@ exports[`UserListRow should match snapshot for currentUser with (you) populated Object { "flex": 1, "flexDirection": "row", - "marginHorizontal": 10, "overflow": "hidden", + "paddingHorizontal": 15, } } > @@ -278,8 +278,8 @@ exports[`UserListRow should match snapshot for deactivated user 1`] = ` Object { "flex": 1, "flexDirection": "row", - "marginHorizontal": 10, "overflow": "hidden", + "paddingHorizontal": 15, } } > @@ -427,8 +427,8 @@ exports[`UserListRow should match snapshot for guest user 1`] = ` Object { "flex": 1, "flexDirection": "row", - "marginHorizontal": 10, "overflow": "hidden", + "paddingHorizontal": 15, } } > @@ -563,8 +563,8 @@ exports[`UserListRow should match snapshot for remote user 1`] = ` Object { "flex": 1, "flexDirection": "row", - "marginHorizontal": 10, "overflow": "hidden", + "paddingHorizontal": 15, } } > diff --git a/app/components/custom_list/user_list_row/user_list_row.js b/app/components/custom_list/user_list_row/user_list_row.js index 2130a793a..db240298c 100644 --- a/app/components/custom_list/user_list_row/user_list_row.js +++ b/app/components/custom_list/user_list_row/user_list_row.js @@ -165,7 +165,7 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { container: { flex: 1, flexDirection: 'row', - marginHorizontal: 10, + paddingHorizontal: 15, overflow: 'hidden', }, profileContainer: { diff --git a/app/screens/apps_form/__snapshots__/apps_form_component.test.tsx.snap b/app/screens/apps_form/__snapshots__/apps_form_component.test.tsx.snap index d94e3875c..e6833878d 100644 --- a/app/screens/apps_form/__snapshots__/apps_form_component.test.tsx.snap +++ b/app/screens/apps_form/__snapshots__/apps_form_component.test.tsx.snap @@ -5,6 +5,7 @@ exports[`AppsForm should set match snapshot 1`] = ` style={ Object { "backgroundColor": "rgba(63,67,80,0.03)", + "height": "100%", } } testID="interactive_dialog.screen" diff --git a/app/screens/apps_form/apps_form_component.tsx b/app/screens/apps_form/apps_form_component.tsx index 9fba1b574..3706ad977 100644 --- a/app/screens/apps_form/apps_form_component.tsx +++ b/app/screens/apps_form/apps_form_component.tsx @@ -419,6 +419,7 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => { return { container: { backgroundColor: changeOpacity(theme.centerChannelColor, 0.03), + height: '100%', }, errorContainer: { marginTop: 15, diff --git a/app/screens/apps_form/apps_form_field.tsx b/app/screens/apps_form/apps_form_field.tsx index 17557b9a1..016b178ae 100644 --- a/app/screens/apps_form/apps_form_field.tsx +++ b/app/screens/apps_form/apps_form_field.tsx @@ -26,12 +26,12 @@ export type Props = { theme: Theme; value: AppFormValue; - onChange: (name: string, value: string | AppSelectOption | boolean) => void; + onChange: (name: string, value: string | AppSelectOption | AppSelectOption[] | boolean) => void; performLookup: (name: string, userInput: string) => Promise; } type State = { - selected: DialogOption | null; + selected?: DialogOption | DialogOption[]; } const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { @@ -49,9 +49,11 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { }); export default class AppsFormField extends React.PureComponent { - state = { - selected: null, - }; + constructor(props:Props) { + super(props); + + this.state = {}; + } handleAutocompleteSelect = (selected: DialogOption) => { if (!selected) { @@ -71,6 +73,26 @@ export default class AppsFormField extends React.PureComponent { this.props.onChange(field.name, selectedOption); }; + handleMultioptionAutocompleteSelect = (selected: DialogOption[]) => { + if (!selected) { + return; + } + const { + field, + } = this.props; + + this.setState({selected}); + + const selectedOptions = selected.map((opt) => { + return { + label: opt.text, + value: opt.value, + }; + }); + + this.props.onChange(field.name, selectedOptions); + } + getDynamicOptions = async (userInput = ''): Promise<{data: DialogOption[]}> => { const options = await this.props.performLookup(this.props.field.name, userInput); return { @@ -188,7 +210,7 @@ export default class AppsFormField extends React.PureComponent { dataSource={dataSource} options={options} optional={!field.is_required} - onSelected={this.handleAutocompleteSelect} + onSelected={field.multiselect ? this.handleMultioptionAutocompleteSelect : this.handleAutocompleteSelect} getDynamicOptions={this.getDynamicOptions} helpText={field.description} errorText={errorText} @@ -197,6 +219,7 @@ export default class AppsFormField extends React.PureComponent { selected={this.state.selected} roundedBorders={false} disabled={field.readonly} + isMultiselect={field.multiselect} /> ); } diff --git a/app/screens/selector_screen/__snapshots__/selector_screen.test.js.snap b/app/screens/selector_screen/__snapshots__/selector_screen.test.tsx.snap similarity index 99% rename from app/screens/selector_screen/__snapshots__/selector_screen.test.js.snap rename to app/screens/selector_screen/__snapshots__/selector_screen.test.tsx.snap index 0c495aaa7..1da1f5cbd 100644 --- a/app/screens/selector_screen/__snapshots__/selector_screen.test.js.snap +++ b/app/screens/selector_screen/__snapshots__/selector_screen.test.tsx.snap @@ -269,6 +269,7 @@ exports[`SelectorScreen should match snapshot for explicit options 1`] = ` data={ Array [ Object { + "id": "value", "text": "text", "value": "value", }, diff --git a/app/screens/selector_screen/index.js b/app/screens/selector_screen/index.ts similarity index 57% rename from app/screens/selector_screen/index.js rename to app/screens/selector_screen/index.ts index b5770fa3f..21e8d5648 100644 --- a/app/screens/selector_screen/index.js +++ b/app/screens/selector_screen/index.ts @@ -2,16 +2,18 @@ // See LICENSE.txt for license information. import {connect} from 'react-redux'; -import {bindActionCreators} from 'redux'; +import {ActionCreatorsMapObject, bindActionCreators, Dispatch} from 'redux'; import {getChannels, searchChannels} from '@mm-redux/actions/channels'; import {getProfiles, searchProfiles} from '@mm-redux/actions/users'; import {getTheme} from '@mm-redux/selectors/entities/preferences'; import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams'; +import {ActionFunc, ActionResult, GenericAction} from '@mm-redux/types/actions'; +import {GlobalState} from '@mm-redux/types/store'; import SelectorScreen from './selector_screen'; -function mapStateToProps(state) { +function mapStateToProps(state: GlobalState) { const menuAction = state.views.post.selectedMenuAction || {}; const data = menuAction.options || []; @@ -26,9 +28,16 @@ function mapStateToProps(state) { }; } -function mapDispatchToProps(dispatch) { +type Actions = { + getProfiles: (page?: number, perPage?: number, options?: any) => Promise; + getChannels: (teamId: string, page?: number, perPage?: number) => Promise; + searchProfiles: (term: string, options?: any) => Promise; + searchChannels: (teamId: string, term: string, archived?: boolean | undefined) => Promise; +} + +function mapDispatchToProps(dispatch: Dispatch) { return { - actions: bindActionCreators({ + actions: bindActionCreators, Actions>({ getProfiles, getChannels, searchProfiles, diff --git a/app/screens/selector_screen/selected_options/__snapshots__/selected_options.test.tsx.snap b/app/screens/selector_screen/selected_options/__snapshots__/selected_options.test.tsx.snap new file mode 100644 index 000000000..d074c71cd --- /dev/null +++ b/app/screens/selector_screen/selected_options/__snapshots__/selected_options.test.tsx.snap @@ -0,0 +1,142 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SelectedOptions should match snapshot 1`] = ` + + + + + + + +`; diff --git a/app/screens/selector_screen/selected_options/index.ts b/app/screens/selector_screen/selected_options/index.ts new file mode 100644 index 000000000..11cb738b0 --- /dev/null +++ b/app/screens/selector_screen/selected_options/index.ts @@ -0,0 +1,17 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {connect} from 'react-redux'; + +import {getTheme} from '@mm-redux/selectors/entities/preferences'; +import {GlobalState} from '@mm-redux/types/store'; + +import SelectedOptions from './selected_options'; + +function mapStateToProps(state: GlobalState) { + return { + theme: getTheme(state), + }; +} + +export default connect(mapStateToProps, null, null, {forwardRef: true})(SelectedOptions); diff --git a/app/screens/selector_screen/selected_options/selected_option.tsx b/app/screens/selector_screen/selected_options/selected_option.tsx new file mode 100644 index 000000000..bdcdd5f15 --- /dev/null +++ b/app/screens/selector_screen/selected_options/selected_option.tsx @@ -0,0 +1,88 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback} from 'react'; +import { + Text, + TouchableOpacity, + View, +} from 'react-native'; + +import CompassIcon from '@components/compass_icon'; +import {ViewTypes} from '@constants'; +import {Channel} from '@mm-redux/types/channels'; +import {DialogOption} from '@mm-redux/types/integrations'; +import {Theme} from '@mm-redux/types/theme'; +import {UserProfile} from '@mm-redux/types/users'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; + +type Props = { + theme: Theme; + option: DialogOption | UserProfile | Channel; + dataSource: string; + onRemove: (opt: DialogOption | UserProfile | Channel) => void; +} +export default function SelectedOption(props: Props) { + const {theme, option, onRemove, dataSource} = props; + const style = getStyleFromTheme(theme); + const onPress = useCallback( + () => onRemove(option), + [option], + ); + + let text; + switch (dataSource) { + case ViewTypes.DATA_SOURCE_USERS: + text = (option as UserProfile).username; + break; + case ViewTypes.DATA_SOURCE_CHANNELS: + text = (option as Channel).display_name; + break; + default: + text = (option as DialogOption).text; + break; + } + return ( + + + {text} + + + + + + ); +} + +const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => { + return { + container: { + alignItems: 'center', + flexDirection: 'row', + height: 27, + borderRadius: 3, + backgroundColor: changeOpacity(theme.centerChannelColor, 0.2), + marginBottom: 4, + marginRight: 10, + paddingLeft: 10, + }, + remove: { + paddingHorizontal: 10, + }, + text: { + color: theme.centerChannelColor, + fontSize: 13, + maxWidth: '90%', + }, + }; +}); diff --git a/app/screens/selector_screen/selected_options/selected_options.test.tsx b/app/screens/selector_screen/selected_options/selected_options.test.tsx new file mode 100644 index 000000000..ff3f1c366 --- /dev/null +++ b/app/screens/selector_screen/selected_options/selected_options.test.tsx @@ -0,0 +1,39 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {shallow} from 'enzyme'; +import React from 'react'; + +import Preferences from '@mm-redux/constants/preferences'; + +import SelectedOptions from './selected_options'; + +describe('SelectedOptions', () => { + const baseProps = { + onRemove: jest.fn(), + selectedOptions: [ + { + text: 'text1', + value: 'value1', + }, + { + text: 'text2', + value: 'value2', + }, + { + text: 'text3', + value: 'value3', + }, + ], + dataSource: '', + theme: Preferences.THEMES.denim, + }; + + test('should match snapshot', () => { + const wrapper = shallow( + , + ); + + expect(wrapper.getElement()).toMatchSnapshot(); + }); +}); diff --git a/app/screens/selector_screen/selected_options/selected_options.tsx b/app/screens/selector_screen/selected_options/selected_options.tsx new file mode 100644 index 000000000..4dd039f1a --- /dev/null +++ b/app/screens/selector_screen/selected_options/selected_options.tsx @@ -0,0 +1,87 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {View, ScrollView} from 'react-native'; + +import {ViewTypes} from '@constants'; +import {makeStyleSheetFromTheme} from '@utils/theme'; + +import SelectedOption from './selected_option'; + +import type {Channel} from '@mm-redux/types/channels'; +import type {DialogOption} from '@mm-redux/types/integrations'; +import type {Theme} from '@mm-redux/types/theme'; +import type {UserProfile} from '@mm-redux/types/users'; + +type Props = { + theme: Theme; + selectedOptions: DialogOption[]|UserProfile[]|Channel[]; + dataSource: string; + onRemove: (opt: DialogOption|UserProfile|Channel) => void; +} + +function SelectedOptions(props: Props, ref: React.Ref) { + const {theme, selectedOptions, onRemove, dataSource} = props; + const options: React.ReactNode[] = []; + + for (const option of selectedOptions) { + let key: string; + switch (dataSource) { + case ViewTypes.DATA_SOURCE_USERS: + key = (option as UserProfile).id; + break; + case ViewTypes.DATA_SOURCE_CHANNELS: + key = (option as Channel).id; + break; + default: + key = (option as DialogOption).value; + break; + } + options.push( + , + ); + } + + if (options.length === 0) { + return null; + } + + const style = getStyleFromTheme(theme); + + return ( + + + {options} + + + ); +} + +export default React.forwardRef(SelectedOptions); + +const getStyleFromTheme = makeStyleSheetFromTheme(() => { + return { + container: { + marginLeft: 5, + marginBottom: 5, + maxHeight: 100, + flexGrow: 0, + }, + users: { + alignItems: 'flex-start', + flexDirection: 'row', + flexWrap: 'wrap', + }, + }; +}); diff --git a/app/screens/selector_screen/selector_screen.test.js b/app/screens/selector_screen/selector_screen.test.tsx similarity index 92% rename from app/screens/selector_screen/selector_screen.test.js rename to app/screens/selector_screen/selector_screen.test.tsx index daf4256e0..4b69c6275 100644 --- a/app/screens/selector_screen/selector_screen.test.js +++ b/app/screens/selector_screen/selector_screen.test.tsx @@ -5,11 +5,13 @@ import React from 'react'; import {IntlProvider} from 'react-intl'; import Preferences from '@mm-redux/constants/preferences'; +import {Channel} from '@mm-redux/types/channels'; +import {UserProfile} from '@mm-redux/types/users'; -import SelectorScreen from './selector_screen.js'; +import SelectorScreen from './selector_screen'; -const user1 = {id: 'id', username: 'username'}; -const user2 = {id: 'id2', username: 'username2'}; +const user1 = {id: 'id', username: 'username'} as UserProfile; +const user2 = {id: 'id2', username: 'username2'} as UserProfile; const getProfiles = async () => { return { @@ -25,8 +27,8 @@ const searchProfiles = async () => { }; }; -const channel1 = {id: 'id', name: 'name', display_name: 'display_name'}; -const channel2 = {id: 'id2', name: 'name2', display_name: 'display_name2'}; +const channel1 = {id: 'id', name: 'name', display_name: 'display_name'} as Channel; +const channel2 = {id: 'id2', name: 'name2', display_name: 'display_name2'} as Channel; const getChannels = async () => { return { @@ -58,7 +60,7 @@ describe('SelectorScreen', () => { currentTeamId: 'someId', onSelect: jest.fn(), data: [{text: 'text', value: 'value'}], - dataSource: null, + dataSource: '', theme: Preferences.THEMES.denim, }; @@ -139,7 +141,7 @@ describe('SelectorScreen', () => { getDynamicOptions, }; - const wrapper = shallow( + const wrapper = shallow( , {context: {intl}}, ); diff --git a/app/screens/selector_screen/selector_screen.js b/app/screens/selector_screen/selector_screen.tsx similarity index 57% rename from app/screens/selector_screen/selector_screen.js rename to app/screens/selector_screen/selector_screen.tsx index a567a88d8..2797e4f6f 100644 --- a/app/screens/selector_screen/selector_screen.js +++ b/app/screens/selector_screen/selector_screen.tsx @@ -1,13 +1,14 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import PropTypes from 'prop-types'; import React, {PureComponent} from 'react'; import {intlShape} from 'react-intl'; import { Platform, + ScrollView, View, } from 'react-native'; +import {EventSubscription, Navigation} from 'react-native-navigation'; import {SafeAreaView} from 'react-native-safe-area-context'; import {popTopScreen} from '@actions/navigation'; @@ -21,6 +22,12 @@ import StatusBar from '@components/status_bar'; import {ViewTypes} from '@constants'; import {debounce} from '@mm-redux/actions/helpers'; import {General} from '@mm-redux/constants'; +import {ActionResult} from '@mm-redux/types/actions'; +import {Channel} from '@mm-redux/types/channels'; +import {DialogOption} from '@mm-redux/types/integrations'; +import {Theme} from '@mm-redux/types/theme'; +import {UserProfile} from '@mm-redux/types/users'; +import {Dictionary} from '@mm-redux/types/utilities'; import {filterChannelsMatchingTerm} from '@mm-redux/utils/channel_utils'; import {memoizeResult} from '@mm-redux/utils/helpers'; import {filterProfilesMatchingTerm} from '@mm-redux/utils/user_utils'; @@ -32,36 +39,64 @@ import { getKeyboardAppearanceFromTheme, } from '@utils/theme'; -export default class SelectorScreen extends PureComponent { - static propTypes = { - actions: PropTypes.shape({ - getProfiles: PropTypes.func.isRequired, - getChannels: PropTypes.func.isRequired, - searchProfiles: PropTypes.func.isRequired, - searchChannels: PropTypes.func.isRequired, - }), - getDynamicOptions: PropTypes.func, - currentTeamId: PropTypes.string.isRequired, - data: PropTypes.arrayOf(PropTypes.object), - dataSource: PropTypes.string, - onSelect: PropTypes.func.isRequired, - theme: PropTypes.object.isRequired, - }; +import SelectedOptions from './selected_options'; +type DataType = DialogOption[] | Channel[] | UserProfile[]; +type Selection = DialogOption | Channel | UserProfile | DialogOption[] | Channel[] | UserProfile[]; +type MultiselectSelectedMap = Dictionary | Dictionary | Dictionary; + +type Props = { + actions: { + getProfiles: (page?: number, perPage?: number, options?: any) => Promise; + getChannels: (teamId: string, page?: number, perPage?: number) => Promise; + searchProfiles: (term: string, options?: any) => Promise; + searchChannels: (teamId: string, term: string, archived?: boolean | undefined) => Promise; + }; + getDynamicOptions?: (term: string) => Promise; + currentTeamId: string; + data?: DataType; + dataSource: string; + onSelect: (opt: Selection) => void; + isMultiselect?: boolean; + selected?: DialogOption[]; + theme: Theme; +} + +type State = { + data: DataType | {id: string; data: DataType}[]; + loading: boolean; + searchResults: DialogOption[]; + term: string; + multiselectSelected: MultiselectSelectedMap; +} + +export default class SelectorScreen extends PureComponent { static contextTypes = { intl: intlShape.isRequired, }; - constructor(props) { + private navigationEventListener?: EventSubscription; + + private searchTimeoutId = 0; + private page = -1; + private next: boolean; + private searchBarRef = React.createRef(); + private selectedScroll = React.createRef(); + constructor(props: Props) { super(props); - this.searchTimeoutId = 0; - this.page = -1; this.next = props.dataSource === ViewTypes.DATA_SOURCE_USERS || props.dataSource === ViewTypes.DATA_SOURCE_CHANNELS || props.dataSource === ViewTypes.DATA_SOURCE_DYNAMIC; - let data = []; + let data: DataType = []; if (!props.dataSource) { - data = props.data; + data = props.data || []; + } + + const multiselectSelected: MultiselectSelectedMap = {}; + if (props.isMultiselect && props.selected && !([ViewTypes.DATA_SOURCE_USERS, ViewTypes.DATA_SOURCE_CHANNELS].includes(props.dataSource))) { + props.selected.forEach((opt) => { + multiselectSelected[opt.value] = opt; + }); } this.state = { @@ -69,12 +104,13 @@ export default class SelectorScreen extends PureComponent { loading: false, searchResults: [], term: '', + multiselectSelected, }; } componentDidMount() { + this.navigationEventListener = Navigation.events().bindComponent(this); const {dataSource} = this.props; - this.mounted = true; if (dataSource === ViewTypes.DATA_SOURCE_USERS) { this.getProfiles(); } else if (dataSource === ViewTypes.DATA_SOURCE_CHANNELS) { @@ -84,14 +120,6 @@ export default class SelectorScreen extends PureComponent { } } - componentWillUnmount() { - this.mounted = false; - } - - setSearchBarRef = (ref) => { - this.searchBarRef = ref; - } - clearSearch = () => { this.setState({term: '', searchResults: []}); }; @@ -100,11 +128,97 @@ export default class SelectorScreen extends PureComponent { popTopScreen(); }; - handleSelectItem = (id, item) => { - this.props.onSelect(item); - this.close(); + handleSelectItem = (id: string, item: UserProfile | Channel | DialogOption) => { + if (!this.props.isMultiselect) { + this.props.onSelect(item); + this.close(); + return; + } + + switch (this.props.dataSource) { + case ViewTypes.DATA_SOURCE_USERS: { + const currentSelected = this.state.multiselectSelected as Dictionary; + const typedItem = item as UserProfile; + const multiselectSelected = {...currentSelected}; + if (currentSelected[typedItem.id]) { + delete multiselectSelected[typedItem.id]; + } else { + multiselectSelected[typedItem.id] = typedItem; + } + this.setState({multiselectSelected}); + break; + } + case ViewTypes.DATA_SOURCE_CHANNELS: { + const currentSelected = this.state.multiselectSelected as Dictionary; + const typedItem = item as Channel; + const multiselectSelected = {...currentSelected}; + if (currentSelected[typedItem.id]) { + delete multiselectSelected[typedItem.id]; + } else { + multiselectSelected[typedItem.id] = typedItem; + } + this.setState({multiselectSelected}); + break; + } + default: { + const currentSelected = this.state.multiselectSelected as Dictionary; + const typedItem = item as DialogOption; + const multiselectSelected = {...currentSelected}; + if (currentSelected[typedItem.value]) { + delete multiselectSelected[typedItem.value]; + } else { + multiselectSelected[typedItem.value] = typedItem; + } + this.setState({multiselectSelected}); + } + } + + setTimeout(() => { + if (this.selectedScroll.current) { + this.selectedScroll.current.scrollToEnd(); + } + }); }; + navigationButtonPressed({buttonId}: {buttonId: string}) { + switch (buttonId) { + case 'submit-form': + this.props.onSelect(Object.values(this.state.multiselectSelected)); + this.close(); + return; + case 'close-dialog': + this.close(); + } + } + + handleRemoveOption = (item: UserProfile | Channel | DialogOption) => { + switch (this.props.dataSource) { + case ViewTypes.DATA_SOURCE_USERS: { + const currentSelected = this.state.multiselectSelected as Dictionary; + const typedItem = item as UserProfile; + const multiselectSelected = {...currentSelected}; + delete multiselectSelected[typedItem.id]; + this.setState({multiselectSelected}); + return; + } + case ViewTypes.DATA_SOURCE_CHANNELS: { + const currentSelected = this.state.multiselectSelected as Dictionary; + const typedItem = item as Channel; + const multiselectSelected = {...currentSelected}; + delete multiselectSelected[typedItem.id]; + this.setState({multiselectSelected}); + return; + } + default: { + const currentSelected = this.state.multiselectSelected as Dictionary; + const typedItem = item as DialogOption; + const multiselectSelected = {...currentSelected}; + delete multiselectSelected[typedItem.value]; + this.setState({multiselectSelected}); + } + } + } + getChannels = debounce(() => { const {actions, currentTeamId} = this.props; const {loading, term} = this.state; @@ -124,7 +238,7 @@ export default class SelectorScreen extends PureComponent { const {data, searchResults, term} = this.state; const result = { - data, + data: data as any, listType: FLATLIST}; if (term) { result.data = filterSearchData(dataSource, searchResults, term); @@ -133,6 +247,12 @@ export default class SelectorScreen extends PureComponent { result.listType = SECTIONLIST; } + if (!dataSource || dataSource === ViewTypes.DATA_SOURCE_DYNAMIC) { + result.data = result.data.map((value: DialogOption) => { + return {...value, id: (value).value}; + }); + } + return result; }; @@ -157,8 +277,8 @@ export default class SelectorScreen extends PureComponent { } }, 100); - loadedChannels = ({data: channels}) => { - const {data} = this.state; + loadedChannels = ({data: channels}: {data: Channel[]}) => { + const data = this.state.data as Channel[]; if (channels && !channels.length) { this.next = false; } @@ -167,8 +287,8 @@ export default class SelectorScreen extends PureComponent { this.setState({loading: false, data: [...channels, ...data]}); }; - loadedProfiles = ({data: profiles}) => { - const {data} = this.state; + loadedProfiles = ({data: profiles}: {data: UserProfile[]}) => { + const data = this.state.data as UserProfile[]; if (profiles && !profiles.length) { this.next = false; } @@ -189,7 +309,7 @@ export default class SelectorScreen extends PureComponent { // dynamic options are not paged so are not reloaded on scroll }; - onSearch = (text) => { + onSearch = (text: string) => { if (text) { const {dataSource, data} = this.props; this.setState({term: text}); @@ -208,13 +328,13 @@ export default class SelectorScreen extends PureComponent { } else if (dataSource === ViewTypes.DATA_SOURCE_DYNAMIC) { this.searchDynamicOptions(text); } - }, General.SEARCH_TIMEOUT_MILLISECONDS); + }, General.SEARCH_TIMEOUT_MILLISECONDS) as any; } else { this.clearSearch(); } }; - searchChannels = (term) => { + searchChannels = (term: string) => { const {actions, currentTeamId} = this.props; actions.searchChannels(currentTeamId, term.toLowerCase()).then(({data}) => { @@ -222,7 +342,7 @@ export default class SelectorScreen extends PureComponent { }); }; - searchProfiles = (term) => { + searchProfiles = (term: string) => { const {actions} = this.props; this.setState({loading: true}); @@ -314,16 +434,40 @@ export default class SelectorScreen extends PureComponent { ); }; - renderChannelItem = (props) => { - return ; + renderChannelItem = (props: any) => { + const selected = Boolean(this.state.multiselectSelected[props.id]); + return ( + + ); }; - renderOptionItem = (props) => { - return ; + renderOptionItem = (props: any) => { + const selected = Boolean(this.state.multiselectSelected[props.id]); + return ( + + ); }; - renderUserItem = (props) => { - return ; + renderUserItem = (props: any) => { + const selected = Boolean(this.state.multiselectSelected[props.id]); + return ( + + ); }; render() { @@ -349,6 +493,22 @@ export default class SelectorScreen extends PureComponent { const {data, listType} = this.getDataResults(); + let selectedOptionsComponent = null; + const selectedItems = Object.values(this.state.multiselectSelected); + if (selectedItems.length > 0) { + selectedOptionsComponent = ( + <> + + + + ); + } + return ( @@ -358,7 +518,7 @@ export default class SelectorScreen extends PureComponent { > + {selectedOptionsComponent} { +const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => { return { container: { flex: 1, @@ -426,22 +587,27 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { fontSize: 26, color: changeOpacity(theme.centerChannelColor, 0.5), }, + separator: { + height: 1, + flex: 0, + backgroundColor: changeOpacity(theme.centerChannelColor, 0.1), + }, }; }); -const filterSearchData = memoizeResult((dataSource, data, term) => { +const filterSearchData = memoizeResult((dataSource: string, data: DataType, term: string) => { if (!data) { return []; } const lowerCasedTerm = term.toLowerCase(); if (dataSource === ViewTypes.DATA_SOURCE_USERS) { - return filterProfilesMatchingTerm(data, lowerCasedTerm); + return filterProfilesMatchingTerm(data as UserProfile[], lowerCasedTerm); } else if (dataSource === ViewTypes.DATA_SOURCE_CHANNELS) { - return filterChannelsMatchingTerm(data, lowerCasedTerm); + return filterChannelsMatchingTerm(data as Channel[], lowerCasedTerm); } else if (dataSource === ViewTypes.DATA_SOURCE_DYNAMIC) { return data; } - return data.filter((option) => option.text && option.text.toLowerCase().startsWith(lowerCasedTerm)); + return (data as DialogOption[]).filter((option) => option.text && option.text.toLowerCase().startsWith(lowerCasedTerm)); }); diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 31b5faf32..cb381a9e1 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -368,6 +368,7 @@ "mobile.files_paste.error_title": "Paste failed", "mobile.flagged_posts.empty_description": "Saved messages are only visible to you. Mark messages for follow-up or save something for later by long-pressing a message and choosing Save from the menu.", "mobile.flagged_posts.empty_title": "No Saved messages yet", + "mobile.forms.select.done": "Done", "mobile.gallery.title": "{index} of {total}", "mobile.general.error.title": "Error", "mobile.help.title": "Help",