Refactor apps modal autocomplete into AutocompleteSelector component (#5229)

* Apps bindings support (#5012)

* Add redux related information

* Add binding loading on channel refresh

* Add channel header and post option bindings

* Fix test

* Minor fixes

* Fix snapshots

* Handle errors and show bindings only on main channel

* Update Expand Levels keys and values to match latest changes

* Add NAVIGATE call response handling.

* Add more isolation to apps related code

* Add defaults to send ephemeral

* Update variable naming

* Rename shouldProcessApps by a more meaningful appsEnabled

* Embedded forms (#5169)

* Add redux related information

* Add binding loading on channel refresh

* Add channel header and post option bindings

* Fix test

* Minor fixes

* Fix snapshots

* Handle errors and show bindings only on main channel

* Update Expand Levels keys and values to match latest changes

* Add NAVIGATE call response handling.

* Add more isolation to apps related code

* Add Embedded Forms

* Fix snapshots

* Add Embedded Forms

* Improve naming, change the root element to be a binding and improve binding handling

* Get post down on the buttons, remove unneeded variables and minor fixes from the review

* Allow undefined bindings to fillBindingsInformation and add logging for error.

* Address review feedback

* Add App Forms (#5177)

* Add App Forms

* Address feedback and self review

* Add dynamic select

* Fixes and improvements

* Add the ability to refresh on submit.

* Use AppFormValue instead of redoing the type

* Address feedback

* [MM-31508] Rename URL to Path in Call (#5186)

* Add user agent to call context (#5193)

* Remove unneeded state reference (#5196)

* Change user agent query on fetch bindings (#5201)

* Add refresh websocket event to refetch bindings (#5194)

* Add refresh websocket event to refetch bindings

* Add missing changes

* Declare socket event constant and separate the handler to a different function

* Add binding validation on binding fetch (#5200)

* Apps commands (#5107)

* Add redux related information

* Add binding loading on channel refresh

* Add channel header and post option bindings

* Fix test

* Minor fixes

* Fix snapshots

* apps modals draft

* Handle errors and show bindings only on main channel

* Update Expand Levels keys and values to match latest changes

* Add NAVIGATE call response handling.

* Add more isolation to apps related code

* reuse command parser throughout slash_suggestion component's lifecycle

* fix prop and lint

* using alert to show error message. need another way

* duplicate import

* types

* types

* types

* rename file

* copy webapp parser and test

* dependencies moved. tests pass

* move app command parser into its own folder

* converted to typescript, all tests are passing

* automated and manual tests work

* lint

* lint

* remove fall throughs with blocks

* types

* doAppCall type

* extract displayError to deps file

* test types

* lint

* fix tests

* unused import

* PR feedback

* fix imports and show errors

* types

* remove execute suggestion for mobile

* watch feature flag

* fix tests

* change form text arugment behavior to show user input and not hint

* return call response error in doAppCall

* update tests to remove hint from text field suggestions

* lint

* use new base command structure

* fix tests

* wrap appsEnabled

* typescript actions/command.ts

* update app constants

* PR feedback

* fix error handling from doAppCall action

* Use App CallRequest structure (#5212)

* error handling

* move test files

* remove unused import

Co-authored-by: Daniel Espino García <larkox@gmail.com>

* Add feature flag (#5207)

* Add feature flag

* Simplify return

* Add localization, call validation and use call type on subpath (#5221)

* Add localization, call validation and use call type on subpath

* Add more localization to the parser and bring fixes from webapp

* Fix ephemerals and channel header / post options crashes

* fix app command parser deps and alert messages

* Improve suggestion handling

* Fix test

* Fix lint

* Return errors as error

* Address PR feedback

* return error property

* fix error string wordings

Co-authored-by: Michael Kochell <6913320+mickmister@users.noreply.github.com>

* migrate app selector to AutocompleteSelector

* add if statement to avoid missing prop

* add tests for dynamic values

* clean up

* remove old files

Co-authored-by: Daniel Espino García <larkox@gmail.com>
Co-authored-by: Ben Schumacher <ben.schumacher@mattermost.com>
This commit is contained in:
Michael Kochell 2021-03-23 14:53:04 -04:00 committed by GitHub
parent d73d3460b4
commit 11a9590402
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 176 additions and 1587 deletions

View file

@ -73,13 +73,14 @@ export function sendAddToChannelEphemeralPost(user, addedUsername, message, chan
};
}
export function setAutocompleteSelector(dataSource, onSelect, options) {
export function setAutocompleteSelector(dataSource, onSelect, options, getDynamicOptions) {
return {
type: ViewTypes.SELECTED_ACTION_MENU,
data: {
dataSource,
onSelect,
options,
getDynamicOptions,
},
};
}

View file

@ -21,6 +21,7 @@ export default class AutocompleteSelector extends PureComponent {
actions: PropTypes.shape({
setAutocompleteSelector: PropTypes.func.isRequired,
}).isRequired,
getDynamicOptions: PropTypes.func,
label: PropTypes.string,
placeholder: PropTypes.string.isRequired,
dataSource: PropTypes.string,
@ -98,11 +99,11 @@ export default class AutocompleteSelector extends PureComponent {
goToSelectorScreen = preventDoubleTap(() => {
const {formatMessage} = this.context.intl;
const {actions, dataSource, options, placeholder} = this.props;
const {actions, dataSource, options, placeholder, getDynamicOptions} = this.props;
const screen = 'SelectorScreen';
const title = placeholder || formatMessage({id: 'mobile.action_menu.select', defaultMessage: 'Select an option'});
actions.setAutocompleteSelector(dataSource, this.handleSelect, options);
actions.setAutocompleteSelector(dataSource, this.handleSelect, options, getDynamicOptions);
goToScreen(screen, title);
});

View file

@ -137,6 +137,7 @@ export default {
PROFILE_PICTURE_EMOJI_SIZE: 28,
DATA_SOURCE_USERS: 'users',
DATA_SOURCE_CHANNELS: 'channels',
DATA_SOURCE_DYNAMIC: 'dynamic',
NotificationLevels,
SidebarSectionTypes,
IOS_HORIZONTAL_LANDSCAPE: 44,

View file

@ -94,6 +94,10 @@ export type DialogSubmission = {
};
cancelled: boolean;
};
export type DialogOption = {
text: string;
value: string;
};
export type DialogElement = {
display_name: string;
name: string;
@ -106,10 +110,7 @@ export type DialogElement = {
min_length: number;
max_length: number;
data_source: string;
options: Array<{
text: string;
value: any;
}>;
options: Array<DialogOption>;
};
export type InteractiveDialogConfig = {
app_id: string;

View file

@ -1,626 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SelectorScreen should match snapshot for channels 1`] = `
<RNCSafeAreaView
style={
Object {
"flex": 1,
}
}
>
<Connect(StatusBar) />
<View
style={
Object {
"height": 38,
"marginVertical": 5,
"paddingLeft": 8,
}
}
testID="selector.screen"
>
<Search
autoCapitalize="none"
backArrowSize={24}
backgroundColor="transparent"
blurOnSubmit={false}
cancelTitle="Cancel"
containerHeight={40}
deleteIconSize={20}
editable={true}
inputHeight={33}
inputStyle={
Object {
"backgroundColor": "rgba(61,60,64,0.2)",
"color": "#3d3c40",
"fontSize": 15,
}
}
keyboardAppearance="light"
keyboardShouldPersist={false}
keyboardType="default"
onBlur={[Function]}
onCancelButtonPress={[Function]}
onChangeText={[Function]}
onSearchButtonPress={[Function]}
onSelectionChange={[Function]}
placeholder="Search"
placeholderTextColor="rgba(61,60,64,0.5)"
returnKeyType="search"
searchBarRightMargin={0}
searchIconSize={24}
showArrow={false}
showCancel={true}
testID="selector.search_bar"
tintColorDelete="rgba(61,60,64,0.5)"
tintColorSearch="rgba(61,60,64,0.5)"
titleCancelColor="#3d3c40"
value=""
/>
</View>
<CustomList
canRefresh={true}
data={Array []}
listType="flat"
loading={false}
loadingComponent={null}
noResults={null}
onLoadMore={[Function]}
onRowPress={[Function]}
renderItem={[Function]}
shouldRenderSeparator={true}
showNoResults={true}
theme={
Object {
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"mentionBg": "#ffffff",
"mentionBj": "#ffffff",
"mentionColor": "#145dbf",
"mentionHighlightBg": "#ffe577",
"mentionHighlightLink": "#166de0",
"newMessageSeparator": "#ff8800",
"onlineIndicator": "#06d6a0",
"sidebarBg": "#145dbf",
"sidebarHeaderBg": "#1153ab",
"sidebarHeaderTextColor": "#ffffff",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#579eff",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#4578bf",
"sidebarUnreadText": "#ffffff",
"type": "Mattermost",
}
}
/>
</RNCSafeAreaView>
`;
exports[`SelectorScreen should match snapshot for channels 2`] = `
<RNCSafeAreaView
style={
Object {
"flex": 1,
}
}
>
<Connect(StatusBar) />
<View
style={
Object {
"height": 38,
"marginVertical": 5,
"paddingLeft": 8,
}
}
testID="selector.screen"
>
<Search
autoCapitalize="none"
backArrowSize={24}
backgroundColor="transparent"
blurOnSubmit={false}
cancelTitle="Cancel"
containerHeight={40}
deleteIconSize={20}
editable={true}
inputHeight={33}
inputStyle={
Object {
"backgroundColor": "rgba(61,60,64,0.2)",
"color": "#3d3c40",
"fontSize": 15,
}
}
keyboardAppearance="light"
keyboardShouldPersist={false}
keyboardType="default"
onBlur={[Function]}
onCancelButtonPress={[Function]}
onChangeText={[Function]}
onSearchButtonPress={[Function]}
onSelectionChange={[Function]}
placeholder="Search"
placeholderTextColor="rgba(61,60,64,0.5)"
returnKeyType="search"
searchBarRightMargin={0}
searchIconSize={24}
showArrow={false}
showCancel={true}
testID="selector.search_bar"
tintColorDelete="rgba(61,60,64,0.5)"
tintColorSearch="rgba(61,60,64,0.5)"
titleCancelColor="#3d3c40"
value=""
/>
</View>
<CustomList
canRefresh={true}
data={Array []}
listType="flat"
loading={false}
loadingComponent={null}
noResults={null}
onLoadMore={[Function]}
onRowPress={[Function]}
renderItem={[Function]}
shouldRenderSeparator={true}
showNoResults={true}
theme={
Object {
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"mentionBg": "#ffffff",
"mentionBj": "#ffffff",
"mentionColor": "#145dbf",
"mentionHighlightBg": "#ffe577",
"mentionHighlightLink": "#166de0",
"newMessageSeparator": "#ff8800",
"onlineIndicator": "#06d6a0",
"sidebarBg": "#145dbf",
"sidebarHeaderBg": "#1153ab",
"sidebarHeaderTextColor": "#ffffff",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#579eff",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#4578bf",
"sidebarUnreadText": "#ffffff",
"type": "Mattermost",
}
}
/>
</RNCSafeAreaView>
`;
exports[`SelectorScreen should match snapshot for explicit options 1`] = `
<RNCSafeAreaView
style={
Object {
"flex": 1,
}
}
>
<Connect(StatusBar) />
<View
style={
Object {
"height": 38,
"marginVertical": 5,
"paddingLeft": 8,
}
}
testID="selector.screen"
>
<Search
autoCapitalize="none"
backArrowSize={24}
backgroundColor="transparent"
blurOnSubmit={false}
cancelTitle="Cancel"
containerHeight={40}
deleteIconSize={20}
editable={true}
inputHeight={33}
inputStyle={
Object {
"backgroundColor": "rgba(61,60,64,0.2)",
"color": "#3d3c40",
"fontSize": 15,
}
}
keyboardAppearance="light"
keyboardShouldPersist={false}
keyboardType="default"
onBlur={[Function]}
onCancelButtonPress={[Function]}
onChangeText={[Function]}
onSearchButtonPress={[Function]}
onSelectionChange={[Function]}
placeholder="Search"
placeholderTextColor="rgba(61,60,64,0.5)"
returnKeyType="search"
searchBarRightMargin={0}
searchIconSize={24}
showArrow={false}
showCancel={true}
testID="selector.search_bar"
tintColorDelete="rgba(61,60,64,0.5)"
tintColorSearch="rgba(61,60,64,0.5)"
titleCancelColor="#3d3c40"
value=""
/>
</View>
<CustomList
canRefresh={true}
data={
Array [
Object {
"label": "text",
"value": "value",
},
]
}
listType="flat"
loading={false}
loadingComponent={null}
noResults={null}
onLoadMore={[Function]}
onRowPress={[Function]}
renderItem={[Function]}
shouldRenderSeparator={true}
showNoResults={true}
theme={
Object {
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"mentionBg": "#ffffff",
"mentionBj": "#ffffff",
"mentionColor": "#145dbf",
"mentionHighlightBg": "#ffe577",
"mentionHighlightLink": "#166de0",
"newMessageSeparator": "#ff8800",
"onlineIndicator": "#06d6a0",
"sidebarBg": "#145dbf",
"sidebarHeaderBg": "#1153ab",
"sidebarHeaderTextColor": "#ffffff",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#579eff",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#4578bf",
"sidebarUnreadText": "#ffffff",
"type": "Mattermost",
}
}
/>
</RNCSafeAreaView>
`;
exports[`SelectorScreen should match snapshot for searching 1`] = `
<RNCSafeAreaView
style={
Object {
"flex": 1,
}
}
>
<Connect(StatusBar) />
<View
style={
Object {
"height": 38,
"marginVertical": 5,
"paddingLeft": 8,
}
}
testID="selector.screen"
>
<Search
autoCapitalize="none"
backArrowSize={24}
backgroundColor="transparent"
blurOnSubmit={false}
cancelTitle="Cancel"
containerHeight={40}
deleteIconSize={20}
editable={true}
inputHeight={33}
inputStyle={
Object {
"backgroundColor": "rgba(61,60,64,0.2)",
"color": "#3d3c40",
"fontSize": 15,
}
}
keyboardAppearance="light"
keyboardShouldPersist={false}
keyboardType="default"
onBlur={[Function]}
onCancelButtonPress={[Function]}
onChangeText={[Function]}
onSearchButtonPress={[Function]}
onSelectionChange={[Function]}
placeholder="Search"
placeholderTextColor="rgba(61,60,64,0.5)"
returnKeyType="search"
searchBarRightMargin={0}
searchIconSize={24}
showArrow={false}
showCancel={true}
testID="selector.search_bar"
tintColorDelete="rgba(61,60,64,0.5)"
tintColorSearch="rgba(61,60,64,0.5)"
titleCancelColor="#3d3c40"
value="name2"
/>
</View>
<CustomList
canRefresh={true}
data={Array []}
listType="flat"
loading={false}
loadingComponent={null}
noResults={null}
onLoadMore={[Function]}
onRowPress={[Function]}
renderItem={[Function]}
shouldRenderSeparator={true}
showNoResults={true}
theme={
Object {
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"mentionBg": "#ffffff",
"mentionBj": "#ffffff",
"mentionColor": "#145dbf",
"mentionHighlightBg": "#ffe577",
"mentionHighlightLink": "#166de0",
"newMessageSeparator": "#ff8800",
"onlineIndicator": "#06d6a0",
"sidebarBg": "#145dbf",
"sidebarHeaderBg": "#1153ab",
"sidebarHeaderTextColor": "#ffffff",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#579eff",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#4578bf",
"sidebarUnreadText": "#ffffff",
"type": "Mattermost",
}
}
/>
</RNCSafeAreaView>
`;
exports[`SelectorScreen should match snapshot for users 1`] = `
<RNCSafeAreaView
style={
Object {
"flex": 1,
}
}
>
<Connect(StatusBar) />
<View
style={
Object {
"height": 38,
"marginVertical": 5,
"paddingLeft": 8,
}
}
testID="selector.screen"
>
<Search
autoCapitalize="none"
backArrowSize={24}
backgroundColor="transparent"
blurOnSubmit={false}
cancelTitle="Cancel"
containerHeight={40}
deleteIconSize={20}
editable={true}
inputHeight={33}
inputStyle={
Object {
"backgroundColor": "rgba(61,60,64,0.2)",
"color": "#3d3c40",
"fontSize": 15,
}
}
keyboardAppearance="light"
keyboardShouldPersist={false}
keyboardType="default"
onBlur={[Function]}
onCancelButtonPress={[Function]}
onChangeText={[Function]}
onSearchButtonPress={[Function]}
onSelectionChange={[Function]}
placeholder="Search"
placeholderTextColor="rgba(61,60,64,0.5)"
returnKeyType="search"
searchBarRightMargin={0}
searchIconSize={24}
showArrow={false}
showCancel={true}
testID="selector.search_bar"
tintColorDelete="rgba(61,60,64,0.5)"
tintColorSearch="rgba(61,60,64,0.5)"
titleCancelColor="#3d3c40"
value=""
/>
</View>
<CustomList
canRefresh={true}
data={Array []}
listType="flat"
loading={false}
loadingComponent={null}
noResults={null}
onLoadMore={[Function]}
onRowPress={[Function]}
renderItem={[Function]}
shouldRenderSeparator={true}
showNoResults={true}
theme={
Object {
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"mentionBg": "#ffffff",
"mentionBj": "#ffffff",
"mentionColor": "#145dbf",
"mentionHighlightBg": "#ffe577",
"mentionHighlightLink": "#166de0",
"newMessageSeparator": "#ff8800",
"onlineIndicator": "#06d6a0",
"sidebarBg": "#145dbf",
"sidebarHeaderBg": "#1153ab",
"sidebarHeaderTextColor": "#ffffff",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#579eff",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#4578bf",
"sidebarUnreadText": "#ffffff",
"type": "Mattermost",
}
}
/>
</RNCSafeAreaView>
`;
exports[`SelectorScreen should match snapshot for users 2`] = `
<RNCSafeAreaView
style={
Object {
"flex": 1,
}
}
>
<Connect(StatusBar) />
<View
style={
Object {
"height": 38,
"marginVertical": 5,
"paddingLeft": 8,
}
}
testID="selector.screen"
>
<Search
autoCapitalize="none"
backArrowSize={24}
backgroundColor="transparent"
blurOnSubmit={false}
cancelTitle="Cancel"
containerHeight={40}
deleteIconSize={20}
editable={true}
inputHeight={33}
inputStyle={
Object {
"backgroundColor": "rgba(61,60,64,0.2)",
"color": "#3d3c40",
"fontSize": 15,
}
}
keyboardAppearance="light"
keyboardShouldPersist={false}
keyboardType="default"
onBlur={[Function]}
onCancelButtonPress={[Function]}
onChangeText={[Function]}
onSearchButtonPress={[Function]}
onSelectionChange={[Function]}
placeholder="Search"
placeholderTextColor="rgba(61,60,64,0.5)"
returnKeyType="search"
searchBarRightMargin={0}
searchIconSize={24}
showArrow={false}
showCancel={true}
testID="selector.search_bar"
tintColorDelete="rgba(61,60,64,0.5)"
tintColorSearch="rgba(61,60,64,0.5)"
titleCancelColor="#3d3c40"
value=""
/>
</View>
<CustomList
canRefresh={true}
data={Array []}
listType="flat"
loading={false}
loadingComponent={null}
noResults={null}
onLoadMore={[Function]}
onRowPress={[Function]}
renderItem={[Function]}
shouldRenderSeparator={true}
showNoResults={true}
theme={
Object {
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"mentionBg": "#ffffff",
"mentionBj": "#ffffff",
"mentionColor": "#145dbf",
"mentionHighlightBg": "#ffe577",
"mentionHighlightLink": "#166de0",
"newMessageSeparator": "#ff8800",
"onlineIndicator": "#06d6a0",
"sidebarBg": "#145dbf",
"sidebarHeaderBg": "#1153ab",
"sidebarHeaderTextColor": "#ffffff",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#579eff",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#4578bf",
"sidebarUnreadText": "#ffffff",
"type": "Mattermost",
}
}
/>
</RNCSafeAreaView>
`;

View file

@ -1,127 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {shallow} from 'enzyme';
import {IntlProvider} from 'react-intl';
import Preferences from '@mm-redux/constants/preferences';
import AppSelectorScreen from './app_selector_screen';
import {Channel} from '@mm-redux/types/channels';
import {UserProfile} from '@mm-redux/types/users';
const user1 = {id: 'id', username: 'username'} as UserProfile;
const user2 = {id: 'id2', username: 'username2'} as UserProfile;
const getProfiles = async () => {
return {
data: [user1, user2],
error: {},
};
};
const searchProfiles = async () => {
return {
data: [user2],
error: {},
};
};
const channel1 = {id: 'id', name: 'name', display_name: 'display_name'} as Channel;
const channel2 = {id: 'id2', name: 'name2', display_name: 'display_name2'} as Channel;
const getChannels = async () => {
return {
data: [channel1, channel2],
error: {},
};
};
const searchChannels = async () => {
return {
data: [channel2],
error: {},
};
};
const intlProvider = new IntlProvider({locale: 'en'}, {});
const {intl} = intlProvider.getChildContext();
describe('SelectorScreen', () => {
const actions = {
getProfiles,
getChannels,
searchProfiles,
searchChannels,
};
const baseProps = {
actions,
currentTeamId: 'someId',
onSelect: jest.fn(),
data: [{label: 'text', value: 'value'}],
theme: Preferences.THEMES.default,
};
beforeAll(() => {
jest.useFakeTimers();
});
test('should match snapshot for explicit options', async () => {
const wrapper = shallow(
<AppSelectorScreen {...baseProps}/>,
{context: {intl}},
);
expect(wrapper.getElement()).toMatchSnapshot();
});
test('should match snapshot for users', async () => {
const props = {
...baseProps,
dataSource: 'users',
data: [user1, user2],
};
const wrapper = shallow(
<AppSelectorScreen {...props}/>,
{context: {intl}},
);
expect(wrapper.getElement()).toMatchSnapshot();
wrapper.setState({isLoading: false});
wrapper.update();
expect(wrapper.getElement()).toMatchSnapshot();
});
test('should match snapshot for channels', async () => {
const props = {
...baseProps,
dataSource: 'channels',
data: [channel1, channel2],
};
const wrapper = shallow(
<AppSelectorScreen {...props}/>,
{context: {intl}},
);
expect(wrapper.getElement()).toMatchSnapshot();
wrapper.setState({isLoading: false});
wrapper.update();
expect(wrapper.getElement()).toMatchSnapshot();
});
test('should match snapshot for searching', async () => {
const props = {
...baseProps,
dataSource: 'channels',
data: [channel1, channel2],
};
const wrapper = shallow(
<AppSelectorScreen {...props}/>,
{context: {intl}},
);
wrapper.setState({isLoading: false, searching: true, term: 'name2'});
wrapper.update();
expect(wrapper.getElement()).toMatchSnapshot();
});
});

View file

@ -1,465 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {PureComponent} from 'react';
import {intlShape} from 'react-intl';
import {
Platform,
View,
} from 'react-native';
import {SafeAreaView} from 'react-native-safe-area-context';
import {popTopScreen} from '@actions/navigation';
import CustomList, {FLATLIST} from '@components/custom_list';
import UserListRow from '@components/custom_list/user_list_row';
import ChannelListRow from '@components/custom_list/channel_list_row';
import OptionListRow from '@components/custom_list/option_list_row';
import FormattedText from '@components/formatted_text';
import SearchBar from '@components/search_bar';
import StatusBar from '@components/status_bar';
import {ViewTypes} from '@constants';
import {debounce} from '@mm-redux/actions/helpers';
import {General} from '@mm-redux/constants';
import {filterProfilesMatchingTerm} from '@mm-redux/utils/user_utils';
import {filterChannelsMatchingTerm} from '@mm-redux/utils/channel_utils';
import {memoizeResult} from '@mm-redux/utils/helpers';
import {t} from '@utils/i18n';
import {loadingText} from '@utils/member_list';
import {
changeOpacity,
makeStyleSheetFromTheme,
getKeyboardAppearanceFromTheme,
} from '@utils/theme';
import {AppSelectOption} from '@mm-redux/types/apps';
import {Theme} from '@mm-redux/types/preferences';
import {ActionResult} from '@mm-redux/types/actions';
import {Channel} from '@mm-redux/types/channels';
import {UserProfile} from '@mm-redux/types/users';
type Props = {
currentTeamId: string;
data: OptionsData;
dataSource?: string;
onSelect: (option: UserProfile | Channel | AppSelectOption) => void;
theme: Theme;
performLookupCall?: (userInput: string) => Promise<AppSelectOption[]>;
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) => Promise<ActionResult>;
}
}
type State = {
data: OptionsData;
loading: boolean;
searchResults: OptionsData;
term: string;
}
type OptionsData = UserProfile[] | Channel[] | AppSelectOption[];
type RowProps = {
id: string;
item: UserProfile | Channel | AppSelectOption;
selected: boolean;
selectable: boolean;
enabled: boolean;
onPress: (id: string, item: UserProfile | Channel | AppSelectOption) => void;
}
export default class AppSelectorScreen extends PureComponent<Props, State> {
private searchTimeoutId?: NodeJS.Timeout;
private page = -1;
private next: boolean;
static contextTypes = {
intl: intlShape.isRequired,
};
constructor(props: Props) {
super(props);
this.next = props.dataSource === ViewTypes.DATA_SOURCE_USERS ||
props.dataSource === ViewTypes.DATA_SOURCE_CHANNELS ||
props.dataSource === 'app';
let data: OptionsData = [];
if (!props.dataSource) {
data = props.data;
}
this.state = {
data,
loading: false,
searchResults: [],
term: '',
};
}
componentDidMount() {
const {dataSource} = this.props;
if (dataSource === ViewTypes.DATA_SOURCE_USERS) {
this.getProfiles();
} else if (dataSource === ViewTypes.DATA_SOURCE_CHANNELS) {
this.getChannels();
} else if (dataSource === 'app') {
this.getAppOptions();
}
}
clearSearch = () => {
this.setState({term: '', searchResults: []});
};
close = () => {
popTopScreen();
};
handleSelectItem = (id: string, item: UserProfile | Channel | AppSelectOption) => {
this.props.onSelect(item);
this.close();
};
getChannels = debounce(() => {
const {actions, currentTeamId} = this.props;
const {loading, term} = this.state;
if (this.next && !loading && !term) {
this.setState({loading: true}, () => {
actions.getChannels(
currentTeamId,
this.page += 1,
General.CHANNELS_CHUNK_SIZE,
).then(this.loadedChannels);
});
}
}, 100);
getAppOptions = debounce(() => {
const {performLookupCall} = this.props;
const {loading, term} = this.state;
if (this.next && !loading && !term) {
this.setState({loading: true}, () => {
performLookupCall?.(term).then(this.loadedAppOptions);
});
}
}, 100)
getDataResults = () => {
const {dataSource} = this.props;
const {data, searchResults, term} = this.state;
const result = {
data,
listType: FLATLIST};
if (term) {
result.data = filterSearchData(dataSource, searchResults, term);
}
// TODO re-add this to add sections by first username letter if desired
// } else if (dataSource === ViewTypes.DATA_SOURCE_USERS) {
// result.data = createProfilesSections(data);
// result.listType = SECTIONLIST;
// }
return result;
};
getProfiles = debounce(() => {
const {loading, term} = this.state;
if (this.next && !loading && !term) {
this.setState({loading: true}, () => {
const {actions} = this.props;
actions.getProfiles(
this.page + 1,
General.PROFILE_CHUNK_SIZE,
).then(this.loadedProfiles);
});
}
}, 100);
loadedChannels = ({data: channels}: {data: Channel[]}) => {
const data = this.state.data as Channel[];
if (channels && !channels.length) {
this.next = false;
}
this.page += 1;
this.setState({loading: false, data: [...channels, ...data]});
};
loadedProfiles = ({data: profiles}: {data: UserProfile[]}) => {
const data = this.state.data as UserProfile[];
if (profiles && !profiles.length) {
this.next = false;
}
this.page += 1;
this.setState({loading: false, data: [...profiles, ...data]});
};
loadedAppOptions = (options: AppSelectOption[]) => {
const data = this.state.data as AppSelectOption[];
if (options && !options.length) {
this.next = false;
}
this.page += 1;
this.setState({loading: false, data: [...options, ...data]});
}
loadMore = () => {
const {dataSource} = this.props;
if (dataSource === ViewTypes.DATA_SOURCE_USERS) {
this.getProfiles();
} else if (dataSource === ViewTypes.DATA_SOURCE_CHANNELS) {
this.getChannels();
}
};
onSearch = (text: string) => {
if (text) {
const {dataSource, data} = this.props;
this.setState({term: text});
if (this.searchTimeoutId) {
clearTimeout(this.searchTimeoutId);
}
this.searchTimeoutId = setTimeout(() => {
if (!dataSource) {
this.setState({searchResults: filterSearchData(null, data, text)});
return;
}
if (dataSource === ViewTypes.DATA_SOURCE_USERS) {
this.searchProfiles(text);
} else if (dataSource === ViewTypes.DATA_SOURCE_CHANNELS) {
this.searchChannels(text);
}
}, General.SEARCH_TIMEOUT_MILLISECONDS);
} else {
this.clearSearch();
}
};
searchChannels = (term: string) => {
const {actions, currentTeamId} = this.props;
actions.searchChannels(currentTeamId, term.toLowerCase()).then(({data}) => {
this.setState({searchResults: data, loading: false});
});
};
searchProfiles = (term: string) => {
const {actions} = this.props;
this.setState({loading: true});
actions.searchProfiles(term.toLowerCase()).then((results) => {
let data = [];
if (results.data) {
data = results.data;
}
this.setState({searchResults: data, loading: false});
});
};
renderLoading = () => {
const {dataSource, theme} = this.props;
const {loading} = this.state;
const style = getStyleFromTheme(theme);
if (!loading) {
return null;
}
let text;
switch (dataSource) {
case ViewTypes.DATA_SOURCE_USERS:
text = loadingText;
break;
case ViewTypes.DATA_SOURCE_CHANNELS:
text = {
id: t('mobile.loading_channels'),
defaultMessage: 'Loading Channels...',
};
break;
default:
text = {
id: t('mobile.loading_options'),
defaultMessage: 'Loading Options...',
};
break;
}
return (
<View style={style.loadingContainer}>
<FormattedText
{...text}
style={style.loadingText}
/>
</View>
);
};
renderNoResults = () => {
const {loading} = this.state;
const {theme} = this.props;
const style = getStyleFromTheme(theme);
if (loading || this.page === -1) {
return null;
}
return (
<View style={style.noResultContainer}>
<FormattedText
id='mobile.custom_list.no_results'
defaultMessage='No Results'
style={style.noResultText}
/>
</View>
);
};
renderChannelItem = (props: RowProps) => {
return <ChannelListRow {...props}/>;
};
renderOptionItem = (props: RowProps) => {
const item = props.item as AppSelectOption;
const newProps = {
...props,
item: {text: item.label, value: item.value},
onPress: (id: string, option: {text: string, value: string}) => {
props.onPress(id, {label: option.text, value: option.value});
},
};
return <OptionListRow {...newProps}/>;
};
renderUserItem = (props: RowProps) => {
return <UserListRow {...props}/>;
};
render() {
const {formatMessage} = this.context.intl;
const {theme, dataSource} = this.props;
const {loading, term} = this.state;
const style = getStyleFromTheme(theme);
const searchBarInput = {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.2),
color: theme.centerChannelColor,
fontSize: 15,
};
let rowComponent;
if (dataSource === ViewTypes.DATA_SOURCE_USERS) {
rowComponent = this.renderUserItem;
} else if (dataSource === ViewTypes.DATA_SOURCE_CHANNELS) {
rowComponent = this.renderChannelItem;
} else {
rowComponent = this.renderOptionItem;
}
const {data, listType} = this.getDataResults();
return (
<SafeAreaView style={style.container}>
<StatusBar/>
<View
testID='selector.screen'
style={style.searchBar}
>
<SearchBar
testID='selector.search_bar'
placeholder={formatMessage({id: 'search_bar.search', defaultMessage: 'Search'})}
cancelTitle={formatMessage({id: 'mobile.post.cancel', defaultMessage: 'Cancel'})}
backgroundColor='transparent'
inputHeight={33}
inputStyle={searchBarInput}
placeholderTextColor={changeOpacity(theme.centerChannelColor, 0.5)}
tintColorSearch={changeOpacity(theme.centerChannelColor, 0.5)}
tintColorDelete={changeOpacity(theme.centerChannelColor, 0.5)}
titleCancelColor={theme.centerChannelColor}
onChangeText={this.onSearch}
onSearchButtonPress={this.onSearch}
onCancelButtonPress={this.clearSearch}
autoCapitalize='none'
keyboardAppearance={getKeyboardAppearanceFromTheme(theme)}
value={term}
/>
</View>
<CustomList
data={data}
key='custom_list'
listType={listType}
loading={loading}
loadingComponent={this.renderLoading()}
noResults={this.renderNoResults()}
onLoadMore={this.loadMore}
onRowPress={this.handleSelectItem}
renderItem={rowComponent}
theme={theme}
/>
</SafeAreaView>
);
}
}
const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
flex: 1,
},
searchBar: {
marginVertical: 5,
height: 38,
...Platform.select({
ios: {
paddingLeft: 8,
},
}),
},
loadingContainer: {
alignItems: 'center',
backgroundColor: theme.centerChannelBg,
height: 70,
justifyContent: 'center',
},
loadingText: {
color: changeOpacity(theme.centerChannelColor, 0.6),
},
noResultContainer: {
flexGrow: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
},
noResultText: {
fontSize: 26,
color: changeOpacity(theme.centerChannelColor, 0.5),
},
};
});
const filterSearchData = memoizeResult((dataSource: string, data: OptionsData, term: string): OptionsData => {
if (!data) {
return [];
}
const lowerCasedTerm = term.toLowerCase();
if (dataSource === ViewTypes.DATA_SOURCE_USERS) {
const users = data as UserProfile[];
return filterProfilesMatchingTerm(users, lowerCasedTerm);
} else if (dataSource === ViewTypes.DATA_SOURCE_CHANNELS) {
const channels = data as Channel[];
return filterChannelsMatchingTerm(channels, lowerCasedTerm);
}
const options = data as AppSelectOption[];
return options.filter((option) => option.label && option.label.toLowerCase().startsWith(lowerCasedTerm));
});

View file

@ -1,40 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {ActionCreatorsMapObject, bindActionCreators, Dispatch} from 'redux';
import {connect} from 'react-redux';
import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams';
import {getTheme} from '@mm-redux/selectors/entities/preferences';
import {getProfiles, searchProfiles} from '@mm-redux/actions/users';
import {getChannels, searchChannels} from '@mm-redux/actions/channels';
import AppSelectorScreen from './app_selector_screen';
import {ActionFunc, ActionResult, GenericAction} from '@mm-redux/types/actions';
import {GlobalState} from '@mm-redux/types/store';
function mapStateToProps(state: GlobalState) {
return {
currentTeamId: getCurrentTeamId(state),
theme: getTheme(state),
};
}
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) => Promise<ActionResult>;
}
function mapDispatchToProps(dispatch: Dispatch<GenericAction>) {
return {
actions: bindActionCreators<ActionCreatorsMapObject<ActionFunc>, Actions>({
getProfiles,
getChannels,
searchProfiles,
searchChannels,
}, dispatch),
};
}
export default connect(mapStateToProps, mapDispatchToProps)(AppSelectorScreen);

View file

@ -1,280 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {PureComponent, ReactNode} from 'react';
import {Text, View} from 'react-native';
import {intlShape} from 'react-intl';
import {displayUsername} from '@mm-redux/utils/user_utils';
import CompassIcon from '@components/compass_icon';
import FormattedText from '@components/formatted_text';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {preventDoubleTap} from '@utils/tap';
import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme';
import {ViewTypes} from '@constants';
import {goToScreen} from '@actions/navigation';
import {AppSelectOption} from '@mm-redux/types/apps';
import {Theme} from '@mm-redux/types/preferences';
import {UserProfile} from '@mm-redux/types/users';
import {Channel} from '@mm-redux/types/channels';
type Props = {
label?: string;
placeholder?: string;
dataSource?: string;
options?: AppSelectOption[];
selected?: AppSelectOption;
optional?: boolean;
showRequiredAsterisk?: boolean;
teammateNameDisplay?: string;
theme: Theme;
onSelected?: (option: AppSelectOption) => void;
helpText?: string;
errorText?: ReactNode;
roundedBorders?: boolean;
disabled?: boolean;
performLookupCall?: (term: string) => Promise<AppSelectOption[]>;
}
export default class AppFormSelector extends PureComponent<Props> {
static contextTypes = {
intl: intlShape,
};
static defaultProps = {
optional: false,
showRequiredAsterisk: false,
roundedBorders: true,
};
handleSelect = (selected: UserProfile | Channel | AppSelectOption) => {
if (!selected) {
return;
}
const {
dataSource,
teammateNameDisplay,
} = this.props;
let selectedLabel;
let selectedValue;
if (dataSource === ViewTypes.DATA_SOURCE_USERS) {
const user = selected as UserProfile;
selectedLabel = user.username;
if (teammateNameDisplay) {
selectedLabel = displayUsername(user, teammateNameDisplay);
}
selectedValue = user.id;
} else if (dataSource === ViewTypes.DATA_SOURCE_CHANNELS) {
const channel = selected as Channel;
selectedLabel = channel.display_name;
selectedValue = channel.id;
} else {
const option = selected as AppSelectOption;
selectedLabel = option.label;
selectedValue = option.value;
}
const selectedOption = {label: selectedLabel, value: selectedValue};
if (this.props.onSelected) {
this.props.onSelected(selectedOption);
}
};
goToSelectorScreen = preventDoubleTap(() => {
const {formatMessage} = this.context.intl;
const {dataSource, options, placeholder, performLookupCall} = this.props;
const screen = 'AppSelectorScreen';
const title = placeholder || formatMessage({id: 'mobile.action_menu.select', defaultMessage: 'Select an option'});
const selectorProps = {
data: options,
dataSource,
onSelect: this.handleSelect,
performLookupCall,
};
goToScreen(screen, title, selectorProps);
});
render() {
const {intl} = this.context;
const {
placeholder,
theme,
label,
helpText,
errorText,
optional,
showRequiredAsterisk,
roundedBorders,
disabled,
selected,
} = this.props;
const style = getStyleSheet(theme);
let text = placeholder || intl.formatMessage({id: 'mobile.action_menu.select', defaultMessage: 'Select an option'});
let selectedStyle = style.dropdownPlaceholder;
if (selected) {
text = selected.label;
selectedStyle = style.dropdownSelected;
}
let inputStyle = style.input;
if (roundedBorders) {
inputStyle = style.roundedInput;
}
let optionalContent;
let asterisk;
if (optional) {
optionalContent = (
<FormattedText
style={style.optional}
id='channel_modal.optional'
defaultMessage='(optional)'
/>
);
} else if (showRequiredAsterisk) {
asterisk = <Text style={style.asterisk}>{' *'}</Text>;
}
let labelContent;
if (label) {
labelContent = (
<View style={style.labelContainer}>
<Text style={style.label}>
{label}
</Text>
{asterisk}
{optionalContent}
</View>
);
}
let helpTextContent;
if (helpText) {
helpTextContent = (
<Text style={style.helpText}>
{helpText}
</Text>
);
}
let errorTextContent;
if (errorText) {
errorTextContent = (
<Text style={style.errorText}>
{errorText}
</Text>
);
}
return (
<View style={style.container}>
{labelContent}
<TouchableWithFeedback
style={disabled ? style.disabled : null}
onPress={this.goToSelectorScreen}
type={'opacity'}
disabled={disabled}
>
<View style={inputStyle}>
<Text
style={selectedStyle}
numberOfLines={1}
>
{text}
</Text>
<CompassIcon
name='chevron-down'
color={changeOpacity(theme.centerChannelColor, 0.5)}
style={style.icon}
/>
</View>
</TouchableWithFeedback>
{helpTextContent}
{errorTextContent}
</View>
);
}
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
const input = {
borderWidth: 1,
borderColor: changeOpacity(theme.centerChannelColor, 0.1),
backgroundColor: changeOpacity(theme.centerChannelBg, 0.9),
paddingLeft: 10,
paddingRight: 30,
paddingVertical: 7,
height: 40,
};
return {
container: {
width: '100%',
marginBottom: 2,
marginRight: 8,
marginTop: 10,
},
roundedInput: {
...input,
borderRadius: 5,
},
input,
dropdownPlaceholder: {
top: 3,
marginLeft: 5,
color: changeOpacity(theme.centerChannelColor, 0.5),
},
dropdownSelected: {
top: 3,
marginLeft: 5,
color: theme.centerChannelColor,
},
icon: {
position: 'absolute',
top: 13,
right: 12,
},
labelContainer: {
flexDirection: 'row',
marginTop: 15,
marginBottom: 10,
},
label: {
fontSize: 14,
color: theme.centerChannelColor,
marginLeft: 15,
},
optional: {
color: changeOpacity(theme.centerChannelColor, 0.5),
fontSize: 14,
marginLeft: 5,
},
helpText: {
fontSize: 12,
color: changeOpacity(theme.centerChannelColor, 0.5),
marginHorizontal: 15,
marginVertical: 10,
},
errorText: {
fontSize: 12,
color: theme.errorTextColor,
marginHorizontal: 15,
marginVertical: 10,
},
asterisk: {
color: theme.errorTextColor,
fontSize: 14,
},
disabled: {
opacity: 0.5,
},
};
});

View file

@ -1,18 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {getTeammateNameDisplaySetting, getTheme} from '@mm-redux/selectors/entities/preferences';
import AppFormSelector from './app_form_selector';
import {GlobalState} from '@mm-redux/types/store';
function mapStateToProps(state: GlobalState) {
return {
teammateNameDisplay: getTeammateNameDisplaySetting(state),
theme: getTheme(state),
};
}
export default connect(mapStateToProps)(AppFormSelector);

View file

@ -1,14 +1,18 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import BoolSetting from '@components/widgets/settings/bool_setting';
import TextSetting from '@components/widgets/settings/text_setting';
import {ViewTypes} from '@constants/index';
import {AppField, AppFormValue, AppSelectOption} from '@mm-redux/types/apps';
import {Theme} from '@mm-redux/types/preferences';
import React from 'react';
import AppFormSelector from './app_form_selector';
import {Theme} from '@mm-redux/types/preferences';
import {AppField, AppFormValue, AppSelectOption} from '@mm-redux/types/apps';
import {AppFieldTypes} from '@mm-redux/constants/apps';
import {DialogOption} from '@mm-redux/types/integrations';
import {ViewTypes} from '@constants/index';
import BoolSetting from '@components/widgets/settings/bool_setting';
import TextSetting from '@components/widgets/settings/text_setting';
import AutocompleteSelector from '@components/autocomplete_selector';
const TEXT_DEFAULT_MAX_LENGTH = 150;
const TEXTAREA_DEFAULT_MAX_LENGTH = 3000;
@ -24,7 +28,43 @@ export type Props = {
performLookup: (name: string, userInput: string) => Promise<AppSelectOption[]>;
}
export default class AppsFormField extends React.PureComponent<Props> {
type State = {
selected: DialogOption | null;
}
export default class AppsFormField extends React.PureComponent<Props, State> {
state = {
selected: null,
};
handleAutocompleteSelect = (selected: DialogOption) => {
if (!selected) {
return;
}
const {
field,
} = this.props;
this.setState({selected});
const selectedOption = {
label: selected.text,
value: selected.value,
};
this.props.onChange(field.name, selectedOption);
};
getDynamicOptions = async (userInput = ''): Promise<{data: DialogOption[]}> => {
const options = await this.props.performLookup(this.props.field.name, userInput);
return {
data: options.map((option) => ({
text: option.label,
value: option.value,
})),
};
};
render() {
const {
field,
@ -101,33 +141,45 @@ export default class AppsFormField extends React.PureComponent<Props> {
disabled={field.readonly}
/>
);
} else if (field.type === 'channel' || field.type === 'user' || field.type === 'dynamic_select' || field.type === 'static_select') {
let dataSource = ViewTypes.DATA_SOURCE_CHANNELS;
if (field.type === 'user') {
} else if ([AppFieldTypes.USER, AppFieldTypes.CHANNEL, AppFieldTypes.STATIC_SELECT, AppFieldTypes.DYNAMIC_SELECT].includes(field.type)) {
let dataSource = '';
let options: DialogOption[] = [];
switch (field.type) {
case AppFieldTypes.USER:
dataSource = ViewTypes.DATA_SOURCE_USERS;
break;
case AppFieldTypes.CHANNEL:
dataSource = ViewTypes.DATA_SOURCE_CHANNELS;
break;
case AppFieldTypes.DYNAMIC_SELECT:
dataSource = ViewTypes.DATA_SOURCE_DYNAMIC;
break;
case AppFieldTypes.STATIC_SELECT:
if (field.options) {
options = field.options.map((option) => ({text: option.label, value: option.value}));
}
}
if (field.type === 'dynamic_select') {
dataSource = 'app';
}
const option = value as AppSelectOption;
return (
<AppFormSelector
<AutocompleteSelector
id={name}
label={displayName}
options={field.options}
dataSource={dataSource}
options={options}
optional={!field.is_required}
onSelected={(selected: AppSelectOption) => this.props.onChange(field.name, selected)}
onSelected={this.handleAutocompleteSelect}
getDynamicOptions={this.getDynamicOptions}
helpText={field.description}
errorText={errorText}
placeholder={placeholder}
showRequiredAsterisk={true}
selected={option}
selected={this.state.selected}
roundedBorders={false}
disabled={field.readonly}
performLookupCall={(term: string) => this.props.performLookup(field.name, term)}
/>
);
} else if (field.type === 'bool') {
} else if (field.type === AppFieldTypes.BOOL) {
const boolValue = value as boolean;
return (
<BoolSetting

View file

@ -49,9 +49,6 @@ Navigation.setLazyComponentRegistrator((screenName) => {
case 'AppForm':
screen = require('@screens/apps_form').default;
break;
case 'AppSelectorScreen':
screen = require('@screens/app_selector_screen').default;
break;
case 'ChannelAddMembers':
screen = require('@screens/channel_add_members').default;
break;

View file

@ -20,6 +20,7 @@ function mapStateToProps(state) {
data,
dataSource: menuAction.dataSource,
onSelect: menuAction.onSelect,
getDynamicOptions: menuAction.getDynamicOptions,
theme: getTheme(state),
};
}

View file

@ -40,6 +40,7 @@ export default class SelectorScreen extends PureComponent {
searchProfiles: PropTypes.func.isRequired,
searchChannels: PropTypes.func.isRequired,
}),
getDynamicOptions: PropTypes.func,
currentTeamId: PropTypes.string.isRequired,
data: PropTypes.arrayOf(PropTypes.object),
dataSource: PropTypes.string,
@ -56,7 +57,7 @@ export default class SelectorScreen extends PureComponent {
this.searchTimeoutId = 0;
this.page = -1;
this.next = props.dataSource === ViewTypes.DATA_SOURCE_USERS || props.dataSource === ViewTypes.DATA_SOURCE_CHANNELS;
this.next = props.dataSource === ViewTypes.DATA_SOURCE_USERS || props.dataSource === ViewTypes.DATA_SOURCE_CHANNELS || props.dataSource === ViewTypes.DATA_SOURCE_DYNAMIC;
let data = [];
if (!props.dataSource) {
@ -78,6 +79,8 @@ export default class SelectorScreen extends PureComponent {
this.getProfiles();
} else if (dataSource === ViewTypes.DATA_SOURCE_CHANNELS) {
this.getChannels();
} else if (dataSource === ViewTypes.DATA_SOURCE_DYNAMIC) {
this.getDynamicOptions();
}
}
@ -147,6 +150,13 @@ export default class SelectorScreen extends PureComponent {
}
}, 100);
getDynamicOptions = debounce(() => {
const {loading, term} = this.state;
if (this.next && !loading && !term) {
this.searchDynamicOptions('');
}
}, 100);
loadedChannels = ({data: channels}) => {
const {data} = this.state;
if (channels && !channels.length) {
@ -175,6 +185,8 @@ export default class SelectorScreen extends PureComponent {
} else if (dataSource === ViewTypes.DATA_SOURCE_CHANNELS) {
this.getChannels();
}
// dynamic options are not paged so are not reloaded on scroll
};
onSearch = (text) => {
@ -193,6 +205,8 @@ export default class SelectorScreen extends PureComponent {
this.searchProfiles(text);
} else if (dataSource === ViewTypes.DATA_SOURCE_CHANNELS) {
this.searchChannels(text);
} else if (dataSource === ViewTypes.DATA_SOURCE_DYNAMIC) {
this.searchDynamicOptions(text);
}
}, General.SEARCH_TIMEOUT_MILLISECONDS);
} else {
@ -221,6 +235,27 @@ export default class SelectorScreen extends PureComponent {
});
};
searchDynamicOptions = (term = '') => {
if (!this.props.getDynamicOptions) {
return;
}
this.setState({loading: true});
this.props.getDynamicOptions(term.toLowerCase()).then((results) => {
let data = [];
if (results.data) {
data = results.data;
}
if (term) {
this.setState({searchResults: data, loading: false});
} else {
this.setState({data, loading: false});
}
});
};
renderLoading = () => {
const {dataSource, theme} = this.props;
const {loading} = this.state;
@ -404,6 +439,8 @@ const filterSearchData = memoizeResult((dataSource, data, term) => {
return filterProfilesMatchingTerm(data, lowerCasedTerm);
} else if (dataSource === ViewTypes.DATA_SOURCE_CHANNELS) {
return filterChannelsMatchingTerm(data, lowerCasedTerm);
} else if (dataSource === ViewTypes.DATA_SOURCE_DYNAMIC) {
return data;
}
return data.filter((option) => option.text && option.text.toLowerCase().startsWith(lowerCasedTerm));

View file

@ -123,4 +123,58 @@ describe('SelectorScreen', () => {
wrapper.update();
expect(wrapper.getElement()).toMatchSnapshot();
});
test('should call getDynamicOptions if data source is dynamic', async () => {
const getDynamicOptions = jest.fn(async (term) => {
if (term) {
return {data: [{text: 'With Query Text', value: 'with_query'}]};
}
return {data: [{text: 'Without Query Text', value: 'without_query'}]};
});
const props = {
...baseProps,
dataSource: 'dynamic',
getDynamicOptions,
};
const wrapper = shallow(
<SelectorScreen {...props}/>,
{context: {intl}},
);
jest.runAllTimers();
await (() => new Promise(setImmediate))();
expect(props.getDynamicOptions).toHaveBeenCalledWith('');
expect(wrapper.state().data).toEqual([
{text: 'Without Query Text', value: 'without_query'},
]);
expect(wrapper.state().searchResults).toEqual([]);
let customList = wrapper.find('CustomList');
expect(customList.props().data).toEqual([
{text: 'Without Query Text', value: 'without_query'},
]);
// Search for value
wrapper.instance().onSearch('mysearch');
jest.runAllTimers();
await (() => new Promise(setImmediate))();
expect(props.getDynamicOptions).toHaveBeenCalledWith('mysearch');
expect(wrapper.state().data).toEqual([
{text: 'Without Query Text', value: 'without_query'},
]);
expect(wrapper.state().searchResults).toEqual([
{text: 'With Query Text', value: 'with_query'},
]);
customList = wrapper.find('CustomList');
expect(customList.props().data).toEqual([
{text: 'With Query Text', value: 'with_query'},
]);
});
});