diff --git a/app/actions/views/post.js b/app/actions/views/post.js index f2d08a651..2e684b7ef 100644 --- a/app/actions/views/post.js +++ b/app/actions/views/post.js @@ -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, }, }; } diff --git a/app/components/autocomplete_selector/autocomplete_selector.js b/app/components/autocomplete_selector/autocomplete_selector.js index bc378c5ed..c4b5dec83 100644 --- a/app/components/autocomplete_selector/autocomplete_selector.js +++ b/app/components/autocomplete_selector/autocomplete_selector.js @@ -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); }); diff --git a/app/constants/view.js b/app/constants/view.js index 187cc3967..9aa51134a 100644 --- a/app/constants/view.js +++ b/app/constants/view.js @@ -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, diff --git a/app/mm-redux/types/integrations.ts b/app/mm-redux/types/integrations.ts index cf18c214b..f770f1814 100644 --- a/app/mm-redux/types/integrations.ts +++ b/app/mm-redux/types/integrations.ts @@ -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; }; export type InteractiveDialogConfig = { app_id: string; diff --git a/app/screens/app_selector_screen/__snapshots__/app_selector_screen.test.tsx.snap b/app/screens/app_selector_screen/__snapshots__/app_selector_screen.test.tsx.snap deleted file mode 100644 index 4d657a46c..000000000 --- a/app/screens/app_selector_screen/__snapshots__/app_selector_screen.test.tsx.snap +++ /dev/null @@ -1,626 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`SelectorScreen should match snapshot for channels 1`] = ` - - - - - - - -`; - -exports[`SelectorScreen should match snapshot for channels 2`] = ` - - - - - - - -`; - -exports[`SelectorScreen should match snapshot for explicit options 1`] = ` - - - - - - - -`; - -exports[`SelectorScreen should match snapshot for searching 1`] = ` - - - - - - - -`; - -exports[`SelectorScreen should match snapshot for users 1`] = ` - - - - - - - -`; - -exports[`SelectorScreen should match snapshot for users 2`] = ` - - - - - - - -`; diff --git a/app/screens/app_selector_screen/app_selector_screen.test.tsx b/app/screens/app_selector_screen/app_selector_screen.test.tsx deleted file mode 100644 index 5a4003e6f..000000000 --- a/app/screens/app_selector_screen/app_selector_screen.test.tsx +++ /dev/null @@ -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( - , - {context: {intl}}, - ); - expect(wrapper.getElement()).toMatchSnapshot(); - }); - - test('should match snapshot for users', async () => { - const props = { - ...baseProps, - dataSource: 'users', - data: [user1, user2], - }; - - const wrapper = shallow( - , - {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( - , - {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( - , - {context: {intl}}, - ); - wrapper.setState({isLoading: false, searching: true, term: 'name2'}); - wrapper.update(); - expect(wrapper.getElement()).toMatchSnapshot(); - }); -}); diff --git a/app/screens/app_selector_screen/app_selector_screen.tsx b/app/screens/app_selector_screen/app_selector_screen.tsx deleted file mode 100644 index 3c7c3f0b9..000000000 --- a/app/screens/app_selector_screen/app_selector_screen.tsx +++ /dev/null @@ -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; - actions: { - getProfiles: (page?: number, perPage?: number, options?: any) => Promise; - getChannels: (teamId: string, page?: number, perPage?: number) => Promise; - searchProfiles: (term: string, options?: any) => Promise; - searchChannels: (teamId: string, term: string, archived?: boolean) => Promise; - } -} - -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 { - 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 ( - - - - ); - }; - - renderNoResults = () => { - const {loading} = this.state; - const {theme} = this.props; - const style = getStyleFromTheme(theme); - - if (loading || this.page === -1) { - return null; - } - - return ( - - - - ); - }; - - renderChannelItem = (props: RowProps) => { - return ; - }; - - 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 ; - }; - - renderUserItem = (props: RowProps) => { - return ; - }; - - 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 ( - - - - - - - - ); - } -} - -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)); -}); diff --git a/app/screens/app_selector_screen/index.ts b/app/screens/app_selector_screen/index.ts deleted file mode 100644 index 93a170de8..000000000 --- a/app/screens/app_selector_screen/index.ts +++ /dev/null @@ -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; - getChannels: (teamId: string, page?: number, perPage?: number) => Promise; - searchProfiles: (term: string, options?: any) => Promise; - searchChannels: (teamId: string, term: string, archived?: boolean) => Promise; -} - -function mapDispatchToProps(dispatch: Dispatch) { - return { - actions: bindActionCreators, Actions>({ - getProfiles, - getChannels, - searchProfiles, - searchChannels, - }, dispatch), - }; -} - -export default connect(mapStateToProps, mapDispatchToProps)(AppSelectorScreen); diff --git a/app/screens/apps_form/app_form_selector/app_form_selector.tsx b/app/screens/apps_form/app_form_selector/app_form_selector.tsx deleted file mode 100644 index 2dad08442..000000000 --- a/app/screens/apps_form/app_form_selector/app_form_selector.tsx +++ /dev/null @@ -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; -} - -export default class AppFormSelector extends PureComponent { - 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 = ( - - ); - } else if (showRequiredAsterisk) { - asterisk = {' *'}; - } - - let labelContent; - if (label) { - labelContent = ( - - - {label} - - {asterisk} - {optionalContent} - - ); - } - - let helpTextContent; - if (helpText) { - helpTextContent = ( - - {helpText} - - ); - } - - let errorTextContent; - if (errorText) { - errorTextContent = ( - - {errorText} - - ); - } - - return ( - - {labelContent} - - - - {text} - - - - - {helpTextContent} - {errorTextContent} - - ); - } -} - -const getStyleSheet = makeStyleSheetFromTheme((theme: 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, - }, - }; -}); diff --git a/app/screens/apps_form/app_form_selector/index.ts b/app/screens/apps_form/app_form_selector/index.ts deleted file mode 100644 index a18fa1f46..000000000 --- a/app/screens/apps_form/app_form_selector/index.ts +++ /dev/null @@ -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); diff --git a/app/screens/apps_form/apps_form_field.tsx b/app/screens/apps_form/apps_form_field.tsx index fcf43f18f..4358e30ee 100644 --- a/app/screens/apps_form/apps_form_field.tsx +++ b/app/screens/apps_form/apps_form_field.tsx @@ -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; } -export default class AppsFormField extends React.PureComponent { +type State = { + selected: DialogOption | null; +} + +export default class AppsFormField extends React.PureComponent { + 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 { 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 ( - 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 ( { 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; diff --git a/app/screens/selector_screen/index.js b/app/screens/selector_screen/index.js index c526698a0..a2f20885c 100644 --- a/app/screens/selector_screen/index.js +++ b/app/screens/selector_screen/index.js @@ -20,6 +20,7 @@ function mapStateToProps(state) { data, dataSource: menuAction.dataSource, onSelect: menuAction.onSelect, + getDynamicOptions: menuAction.getDynamicOptions, theme: getTheme(state), }; } diff --git a/app/screens/selector_screen/selector_screen.js b/app/screens/selector_screen/selector_screen.js index 6abf858a8..521d8c72a 100644 --- a/app/screens/selector_screen/selector_screen.js +++ b/app/screens/selector_screen/selector_screen.js @@ -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)); diff --git a/app/screens/selector_screen/selector_screen.test.js b/app/screens/selector_screen/selector_screen.test.js index e1a078dac..4621c9b48 100644 --- a/app/screens/selector_screen/selector_screen.test.js +++ b/app/screens/selector_screen/selector_screen.test.js @@ -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( + , + {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'}, + ]); + }); });