MM-34194 Add multiselect to Apps Forms (#5400)
* Add multiselect to Apps Forms * Address feedback * Add selected information and change button name * Fix styles * Add missing semicolon and change currentList to currentSelected. * Address UX feedback * Fix test * Address feedback * Fix snapshots * Potential fix for flaky test * Fix iOS back button * Address feedback * Fix tests * Add separator and scroll to bottom on selected items * Use setTimeout for scroll and also scroll on unselected * Fix lint * Fix tsc * Fix tests
This commit is contained in:
parent
434f4c970e
commit
76284a78d5
20 changed files with 883 additions and 176 deletions
|
|
@ -155,13 +155,15 @@ const SlashSuggestionItem = (props: Props) => {
|
|||
</View>
|
||||
<View style={style.suggestionContainer}>
|
||||
<Text style={style.suggestionName}>{`${suggestionText}`}</Text>
|
||||
<Text
|
||||
ellipsizeMode='tail'
|
||||
numberOfLines={1}
|
||||
style={style.suggestionDescription}
|
||||
>
|
||||
{description}
|
||||
</Text>
|
||||
{Boolean(description) &&
|
||||
<Text
|
||||
ellipsizeMode='tail'
|
||||
numberOfLines={1}
|
||||
style={style.suggestionDescription}
|
||||
>
|
||||
{description}
|
||||
</Text>
|
||||
}
|
||||
</View>
|
||||
</View>
|
||||
</TouchableWithFeedback>
|
||||
|
|
|
|||
|
|
@ -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<ActionResult>;
|
||||
};
|
||||
getDynamicOptions?: (term: string) => Promise<ActionResult>;
|
||||
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<Props, State> {
|
||||
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}
|
||||
</Text>
|
||||
<CompassIcon
|
||||
name='chevron-down'
|
||||
color={changeOpacity(theme.centerChannelColor, 0.5)}
|
||||
name={chevron}
|
||||
color={changeOpacity(theme.centerChannelColor, 0.32)}
|
||||
style={style.icon}
|
||||
/>
|
||||
</View>
|
||||
|
|
@ -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',
|
||||
|
|
@ -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<ActionResult>;
|
||||
}
|
||||
function mapDispatchToProps(dispatch: Dispatch<GenericAction>) {
|
||||
return {
|
||||
actions: bindActionCreators({
|
||||
actions: bindActionCreators<ActionCreatorsMapObject<Action>, Actions>({
|
||||
setAutocompleteSelector,
|
||||
}, dispatch),
|
||||
};
|
||||
|
|
@ -52,33 +52,35 @@ export default class ChannelListRow extends React.PureComponent {
|
|||
}
|
||||
|
||||
return (
|
||||
<CustomListRow
|
||||
id={this.props.id}
|
||||
onPress={this.props.onPress ? this.onPress : null}
|
||||
enabled={this.props.enabled}
|
||||
selectable={this.props.selectable}
|
||||
selected={this.props.selected}
|
||||
testID={testID}
|
||||
>
|
||||
<View
|
||||
style={style.container}
|
||||
testID={itemTestID}
|
||||
<View style={style.outerContainer}>
|
||||
<CustomListRow
|
||||
id={this.props.id}
|
||||
onPress={this.props.onPress ? this.onPress : null}
|
||||
enabled={this.props.enabled}
|
||||
selectable={this.props.selectable}
|
||||
selected={this.props.selected}
|
||||
testID={testID}
|
||||
>
|
||||
<View style={style.titleContainer}>
|
||||
<CompassIcon
|
||||
name={icon}
|
||||
style={style.icon}
|
||||
/>
|
||||
<Text
|
||||
style={style.displayName}
|
||||
testID={channelDisplayNameTestID}
|
||||
>
|
||||
{this.props.channel.display_name}
|
||||
</Text>
|
||||
<View
|
||||
style={style.container}
|
||||
testID={itemTestID}
|
||||
>
|
||||
<View style={style.titleContainer}>
|
||||
<CompassIcon
|
||||
name={icon}
|
||||
style={style.icon}
|
||||
/>
|
||||
<Text
|
||||
style={style.displayName}
|
||||
testID={channelDisplayNameTestID}
|
||||
>
|
||||
{this.props.channel.display_name}
|
||||
</Text>
|
||||
</View>
|
||||
{purpose}
|
||||
</View>
|
||||
{purpose}
|
||||
</View>
|
||||
</CustomListRow>
|
||||
</CustomListRow>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -42,21 +42,23 @@ export default class OptionListRow extends React.PureComponent {
|
|||
const style = getStyleFromTheme(theme);
|
||||
|
||||
return (
|
||||
<CustomListRow
|
||||
id={value}
|
||||
onPress={this.onPress}
|
||||
enabled={enabled}
|
||||
selectable={selectable}
|
||||
selected={selected}
|
||||
>
|
||||
<View style={style.textContainer}>
|
||||
<View>
|
||||
<Text style={style.optionText}>
|
||||
{text}
|
||||
</Text>
|
||||
<View style={style.container}>
|
||||
<CustomListRow
|
||||
id={value}
|
||||
onPress={this.onPress}
|
||||
enabled={enabled}
|
||||
selectable={selectable}
|
||||
selected={selected}
|
||||
>
|
||||
<View style={style.textContainer}>
|
||||
<View>
|
||||
<Text style={style.optionText}>
|
||||
{text}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</CustomListRow>
|
||||
</CustomListRow>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -165,7 +165,7 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
|
|||
container: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
marginHorizontal: 10,
|
||||
paddingHorizontal: 15,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
profileContainer: {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -419,6 +419,7 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
|
|||
return {
|
||||
container: {
|
||||
backgroundColor: changeOpacity(theme.centerChannelColor, 0.03),
|
||||
height: '100%',
|
||||
},
|
||||
errorContainer: {
|
||||
marginTop: 15,
|
||||
|
|
|
|||
|
|
@ -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<AppSelectOption[]>;
|
||||
}
|
||||
|
||||
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<Props, State> {
|
||||
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<Props, State> {
|
|||
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<Props, State> {
|
|||
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<Props, State> {
|
|||
selected={this.state.selected}
|
||||
roundedBorders={false}
|
||||
disabled={field.readonly}
|
||||
isMultiselect={field.multiselect}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -269,6 +269,7 @@ exports[`SelectorScreen should match snapshot for explicit options 1`] = `
|
|||
data={
|
||||
Array [
|
||||
Object {
|
||||
"id": "value",
|
||||
"text": "text",
|
||||
"value": "value",
|
||||
},
|
||||
|
|
@ -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<ActionResult>;
|
||||
getChannels: (teamId: string, page?: number, perPage?: number) => Promise<ActionResult>;
|
||||
searchProfiles: (term: string, options?: any) => Promise<ActionResult>;
|
||||
searchChannels: (teamId: string, term: string, archived?: boolean | undefined) => Promise<ActionResult>;
|
||||
}
|
||||
|
||||
function mapDispatchToProps(dispatch: Dispatch<GenericAction>) {
|
||||
return {
|
||||
actions: bindActionCreators({
|
||||
actions: bindActionCreators<ActionCreatorsMapObject<ActionFunc>, Actions>({
|
||||
getProfiles,
|
||||
getChannels,
|
||||
searchProfiles,
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`SelectedOptions should match snapshot 1`] = `
|
||||
<ScrollView
|
||||
style={
|
||||
Object {
|
||||
"flexGrow": 0,
|
||||
"marginBottom": 5,
|
||||
"marginLeft": 5,
|
||||
"maxHeight": 100,
|
||||
}
|
||||
}
|
||||
>
|
||||
<View
|
||||
style={
|
||||
Object {
|
||||
"alignItems": "flex-start",
|
||||
"flexDirection": "row",
|
||||
"flexWrap": "wrap",
|
||||
}
|
||||
}
|
||||
>
|
||||
<SelectedOption
|
||||
dataSource=""
|
||||
onRemove={[MockFunction]}
|
||||
option={
|
||||
Object {
|
||||
"text": "text1",
|
||||
"value": "value1",
|
||||
}
|
||||
}
|
||||
theme={
|
||||
Object {
|
||||
"awayIndicator": "#ffbc1f",
|
||||
"buttonBg": "#1c58d9",
|
||||
"buttonColor": "#ffffff",
|
||||
"centerChannelBg": "#ffffff",
|
||||
"centerChannelColor": "#3f4350",
|
||||
"codeTheme": "github",
|
||||
"dndIndicator": "#d24b4e",
|
||||
"errorTextColor": "#d24b4e",
|
||||
"linkColor": "#386fe5",
|
||||
"mentionBg": "#ffffff",
|
||||
"mentionColor": "#1e325c",
|
||||
"mentionHighlightBg": "#ffd470",
|
||||
"mentionHighlightLink": "#1b1d22",
|
||||
"newMessageSeparator": "#cc8f00",
|
||||
"onlineIndicator": "#3db887",
|
||||
"sidebarBg": "#1e325c",
|
||||
"sidebarHeaderBg": "#192a4d",
|
||||
"sidebarHeaderTextColor": "#ffffff",
|
||||
"sidebarTeamBarBg": "#14213e",
|
||||
"sidebarText": "#ffffff",
|
||||
"sidebarTextActiveBorder": "#5d89ea",
|
||||
"sidebarTextActiveColor": "#ffffff",
|
||||
"sidebarTextHoverBg": "#28427b",
|
||||
"sidebarUnreadText": "#ffffff",
|
||||
"type": "Denim",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<SelectedOption
|
||||
dataSource=""
|
||||
onRemove={[MockFunction]}
|
||||
option={
|
||||
Object {
|
||||
"text": "text2",
|
||||
"value": "value2",
|
||||
}
|
||||
}
|
||||
theme={
|
||||
Object {
|
||||
"awayIndicator": "#ffbc1f",
|
||||
"buttonBg": "#1c58d9",
|
||||
"buttonColor": "#ffffff",
|
||||
"centerChannelBg": "#ffffff",
|
||||
"centerChannelColor": "#3f4350",
|
||||
"codeTheme": "github",
|
||||
"dndIndicator": "#d24b4e",
|
||||
"errorTextColor": "#d24b4e",
|
||||
"linkColor": "#386fe5",
|
||||
"mentionBg": "#ffffff",
|
||||
"mentionColor": "#1e325c",
|
||||
"mentionHighlightBg": "#ffd470",
|
||||
"mentionHighlightLink": "#1b1d22",
|
||||
"newMessageSeparator": "#cc8f00",
|
||||
"onlineIndicator": "#3db887",
|
||||
"sidebarBg": "#1e325c",
|
||||
"sidebarHeaderBg": "#192a4d",
|
||||
"sidebarHeaderTextColor": "#ffffff",
|
||||
"sidebarTeamBarBg": "#14213e",
|
||||
"sidebarText": "#ffffff",
|
||||
"sidebarTextActiveBorder": "#5d89ea",
|
||||
"sidebarTextActiveColor": "#ffffff",
|
||||
"sidebarTextHoverBg": "#28427b",
|
||||
"sidebarUnreadText": "#ffffff",
|
||||
"type": "Denim",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<SelectedOption
|
||||
dataSource=""
|
||||
onRemove={[MockFunction]}
|
||||
option={
|
||||
Object {
|
||||
"text": "text3",
|
||||
"value": "value3",
|
||||
}
|
||||
}
|
||||
theme={
|
||||
Object {
|
||||
"awayIndicator": "#ffbc1f",
|
||||
"buttonBg": "#1c58d9",
|
||||
"buttonColor": "#ffffff",
|
||||
"centerChannelBg": "#ffffff",
|
||||
"centerChannelColor": "#3f4350",
|
||||
"codeTheme": "github",
|
||||
"dndIndicator": "#d24b4e",
|
||||
"errorTextColor": "#d24b4e",
|
||||
"linkColor": "#386fe5",
|
||||
"mentionBg": "#ffffff",
|
||||
"mentionColor": "#1e325c",
|
||||
"mentionHighlightBg": "#ffd470",
|
||||
"mentionHighlightLink": "#1b1d22",
|
||||
"newMessageSeparator": "#cc8f00",
|
||||
"onlineIndicator": "#3db887",
|
||||
"sidebarBg": "#1e325c",
|
||||
"sidebarHeaderBg": "#192a4d",
|
||||
"sidebarHeaderTextColor": "#ffffff",
|
||||
"sidebarTeamBarBg": "#14213e",
|
||||
"sidebarText": "#ffffff",
|
||||
"sidebarTextActiveBorder": "#5d89ea",
|
||||
"sidebarTextActiveColor": "#ffffff",
|
||||
"sidebarTextHoverBg": "#28427b",
|
||||
"sidebarUnreadText": "#ffffff",
|
||||
"type": "Denim",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</ScrollView>
|
||||
`;
|
||||
17
app/screens/selector_screen/selected_options/index.ts
Normal file
17
app/screens/selector_screen/selected_options/index.ts
Normal file
|
|
@ -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);
|
||||
|
|
@ -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 (
|
||||
<View style={style.container}>
|
||||
<Text
|
||||
style={style.text}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{text}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={style.remove}
|
||||
onPress={onPress}
|
||||
>
|
||||
<CompassIcon
|
||||
name='close'
|
||||
size={14}
|
||||
color={theme.centerChannelColor}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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%',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
@ -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(
|
||||
<SelectedOptions {...baseProps}/>,
|
||||
);
|
||||
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
|
@ -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<ScrollView>) {
|
||||
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(
|
||||
<SelectedOption
|
||||
key={key}
|
||||
option={option}
|
||||
theme={theme}
|
||||
dataSource={dataSource}
|
||||
onRemove={onRemove}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
if (options.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const style = getStyleFromTheme(theme);
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
ref={ref}
|
||||
style={style.container}
|
||||
contentContainerStyle={style.scrollViewContent}
|
||||
>
|
||||
<View style={style.users} >
|
||||
{options}
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
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',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
@ -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<SelectorScreen>(
|
||||
<SelectorScreen {...props}/>,
|
||||
{context: {intl}},
|
||||
);
|
||||
|
|
@ -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<DialogOption> | Dictionary<Channel> | Dictionary<UserProfile>;
|
||||
|
||||
type Props = {
|
||||
actions: {
|
||||
getProfiles: (page?: number, perPage?: number, options?: any) => Promise<ActionResult>;
|
||||
getChannels: (teamId: string, page?: number, perPage?: number) => Promise<ActionResult>;
|
||||
searchProfiles: (term: string, options?: any) => Promise<ActionResult>;
|
||||
searchChannels: (teamId: string, term: string, archived?: boolean | undefined) => Promise<ActionResult>;
|
||||
};
|
||||
getDynamicOptions?: (term: string) => Promise<ActionResult>;
|
||||
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<Props, State> {
|
||||
static contextTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
private navigationEventListener?: EventSubscription;
|
||||
|
||||
private searchTimeoutId = 0;
|
||||
private page = -1;
|
||||
private next: boolean;
|
||||
private searchBarRef = React.createRef<SearchBar>();
|
||||
private selectedScroll = React.createRef<ScrollView>();
|
||||
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<UserProfile>;
|
||||
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<Channel>;
|
||||
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<DialogOption>;
|
||||
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<UserProfile>;
|
||||
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<Channel>;
|
||||
const typedItem = item as Channel;
|
||||
const multiselectSelected = {...currentSelected};
|
||||
delete multiselectSelected[typedItem.id];
|
||||
this.setState({multiselectSelected});
|
||||
return;
|
||||
}
|
||||
default: {
|
||||
const currentSelected = this.state.multiselectSelected as Dictionary<DialogOption>;
|
||||
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 <ChannelListRow {...props}/>;
|
||||
renderChannelItem = (props: any) => {
|
||||
const selected = Boolean(this.state.multiselectSelected[props.id]);
|
||||
return (
|
||||
<ChannelListRow
|
||||
key={props.id}
|
||||
{...props}
|
||||
selectable={true}
|
||||
selected={selected}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
renderOptionItem = (props) => {
|
||||
return <OptionListRow {...props}/>;
|
||||
renderOptionItem = (props: any) => {
|
||||
const selected = Boolean(this.state.multiselectSelected[props.id]);
|
||||
return (
|
||||
<OptionListRow
|
||||
key={props.id}
|
||||
{...props}
|
||||
selectable={true}
|
||||
selected={selected}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
renderUserItem = (props) => {
|
||||
return <UserListRow {...props}/>;
|
||||
renderUserItem = (props: any) => {
|
||||
const selected = Boolean(this.state.multiselectSelected[props.id]);
|
||||
return (
|
||||
<UserListRow
|
||||
key={props.id}
|
||||
{...props}
|
||||
selectable={true}
|
||||
selected={selected}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
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 = (
|
||||
<>
|
||||
<SelectedOptions
|
||||
ref={this.selectedScroll}
|
||||
selectedOptions={selectedItems}
|
||||
dataSource={this.props.dataSource}
|
||||
onRemove={this.handleRemoveOption}
|
||||
/>
|
||||
<View style={style.separator}/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={style.container}>
|
||||
<StatusBar/>
|
||||
|
|
@ -358,7 +518,7 @@ export default class SelectorScreen extends PureComponent {
|
|||
>
|
||||
<SearchBar
|
||||
testID='selector.search_bar'
|
||||
ref={this.setSearchBarRef}
|
||||
ref={this.searchBarRef}
|
||||
placeholder={formatMessage({id: 'search_bar.search', defaultMessage: 'Search'})}
|
||||
cancelTitle={formatMessage({id: 'mobile.post.cancel', defaultMessage: 'Cancel'})}
|
||||
backgroundColor='transparent'
|
||||
|
|
@ -376,6 +536,7 @@ export default class SelectorScreen extends PureComponent {
|
|||
value={term}
|
||||
/>
|
||||
</View>
|
||||
{selectedOptionsComponent}
|
||||
<CustomList
|
||||
data={data}
|
||||
key='custom_list'
|
||||
|
|
@ -393,7 +554,7 @@ export default class SelectorScreen extends PureComponent {
|
|||
}
|
||||
}
|
||||
|
||||
const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
|
||||
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));
|
||||
});
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue