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