From ac8a18bbfb01a77eb9eebf3de95cafd3f68fed14 Mon Sep 17 00:00:00 2001 From: Javier Aguirre Date: Wed, 23 Nov 2022 11:17:47 +0100 Subject: [PATCH] [MM-43844][MM-42809] Integration Selector (#6716) * Activating screens * Registering the screen * Adding default themes for components * Porting items rows, WIP * WIP Custom List * Pasting old code to integration_selector, WIP * No TS errors on components * Adding selector options * fix types * Adding state with hooks * Page loading with no results * fix search timeout * Getting channels, not painting them yet * searching for profiles * tuning user and channel remote calls * Fix radioButton error * channels being loaded * rendering options * Rendering users * Preparing search results * Added onPress events for everybody! * single select working for all selectors * Remove dirty empty data fix * remove unused data on custom list row * fic touchableOpacity styling * Adding extra info to userlistRow * Search results (channels and users) * filter options! * Adding i18n * Adding username as name * move code to effects * fix typing onRow * multiselect selection working, missing a "Done" button * commenting out the selector icons, moving selected options to func * Added button for multiselect submit * Fixing data types on selector * :lipstick: data sources check * cleaning custom_list_row * Fix onLoadMore bug * ordering setLoading * eslinting all the things * more eslint * multiselect * fix autocomplete format * FIx eslint * fix renderIcon * fix section type * actions not being used * now we have user avatars * Fix icon checks on multiselect * handling select for multiple selections * Moving to its respective folders * components should render * Added some test cases * Multiple fixes from @mickmister feedback * changing lock icon to padlock on channel row * Fix children lint errors * fix useEffect function eslint error * Adding useCallback to profiles, channels and multiselections * Fixing @larkox suggestions * type checking fixes * Fix onLoadMore * Multiple hook and functionality fixes * :fire: extraData and setting loading channels better * fix teammate display * Fix multiselect button selection * Fix returning selection to autocomplete selector * Using typography * Updating snapshots due to typography changes * removing UserListRow, modifying the existing one * Extract key for data sources * Multiselect selection refactor * fix setNext loop * refactoring searchprofiles and channels * Using refs for next and page * Callback and other fixes * Multiple fixes * Add callback to multiselect selected items * integration selector fixes * Filter option search * fix useCallback, timeout * Remove initial page, fix selection data type Co-authored-by: Michael Kochell <6913320+mickmister@users.noreply.github.com> --- .../autocomplete_selector/index.tsx | 2 +- app/components/user_list_row/index.tsx | 12 +- app/constants/screens.ts | 2 - app/screens/index.tsx | 3 + .../__snapshots__/index.test.tsx.snap | 482 ++++++++++++++ .../channel_list_row/index.test.tsx | 158 +++++ .../channel_list_row/index.tsx | 137 ++++ .../custom_list/index.test.tsx | 68 ++ .../custom_list/index.tsx | 231 +++++++ .../__snapshots__/index.test.tsx.snap | 84 +++ .../custom_list_row/index.test.tsx | 48 ++ .../custom_list_row/index.tsx | 100 +++ app/screens/integration_selector/index.tsx | 622 ++++++++++++++++++ .../__snapshots__/index.test.tsx.snap | 74 +++ .../option_list_row/index.test.tsx | 44 ++ .../option_list_row/index.tsx | 77 +++ .../__snapshots__/index.test.tsx.snap | 172 +++++ .../selected_option/index.test.tsx | 117 ++++ .../selected_option/index.tsx | 88 +++ .../__snapshots__/index.test.tsx.snap | 241 +++++++ .../selected_options/index.test.tsx | 119 ++++ .../selected_options/index.tsx | 79 +++ app/utils/channel/index.ts | 15 + assets/base/i18n/en.json | 3 + 24 files changed, 2972 insertions(+), 6 deletions(-) create mode 100644 app/screens/integration_selector/channel_list_row/__snapshots__/index.test.tsx.snap create mode 100644 app/screens/integration_selector/channel_list_row/index.test.tsx create mode 100644 app/screens/integration_selector/channel_list_row/index.tsx create mode 100644 app/screens/integration_selector/custom_list/index.test.tsx create mode 100644 app/screens/integration_selector/custom_list/index.tsx create mode 100644 app/screens/integration_selector/custom_list_row/__snapshots__/index.test.tsx.snap create mode 100644 app/screens/integration_selector/custom_list_row/index.test.tsx create mode 100644 app/screens/integration_selector/custom_list_row/index.tsx create mode 100644 app/screens/integration_selector/index.tsx create mode 100644 app/screens/integration_selector/option_list_row/__snapshots__/index.test.tsx.snap create mode 100644 app/screens/integration_selector/option_list_row/index.test.tsx create mode 100644 app/screens/integration_selector/option_list_row/index.tsx create mode 100644 app/screens/integration_selector/selected_option/__snapshots__/index.test.tsx.snap create mode 100644 app/screens/integration_selector/selected_option/index.test.tsx create mode 100644 app/screens/integration_selector/selected_option/index.tsx create mode 100644 app/screens/integration_selector/selected_options/__snapshots__/index.test.tsx.snap create mode 100644 app/screens/integration_selector/selected_options/index.test.tsx create mode 100644 app/screens/integration_selector/selected_options/index.tsx diff --git a/app/components/autocomplete_selector/index.tsx b/app/components/autocomplete_selector/index.tsx index 8a9c1f8b55..fd7adbfd03 100644 --- a/app/components/autocomplete_selector/index.tsx +++ b/app/components/autocomplete_selector/index.tsx @@ -137,7 +137,7 @@ function AutoCompleteSelector({ const goToSelectorScreen = useCallback(preventDoubleTap(() => { const screen = Screens.INTEGRATION_SELECTOR; - goToScreen(screen, title, {dataSource, handleSelect, options, getDynamicOptions, selected, isMultiselect}); + goToScreen(screen, title, {dataSource, handleSelect, options, getDynamicOptions, selected, isMultiselect, teammateNameDisplay}); }), [dataSource, options, getDynamicOptions]); const handleSelect = useCallback((item?: Selection) => { diff --git a/app/components/user_list_row/index.tsx b/app/components/user_list_row/index.tsx index cd12ee0ae2..f3ec54fabf 100644 --- a/app/components/user_list_row/index.tsx +++ b/app/components/user_list_row/index.tsx @@ -32,6 +32,7 @@ type Props = { onPress?: (user: UserProfile) => void; onLongPress: (user: UserProfile) => void; selectable: boolean; + disabled?: boolean; selected: boolean; tutorialWatched?: boolean; } @@ -105,6 +106,7 @@ export default function UserListRow({ onLongPress, tutorialWatched = false, selectable, + disabled, selected, }: Props) { const theme = useTheme(); @@ -154,7 +156,11 @@ export default function UserListRow({ }, [onLongPress, user]); const icon = useMemo(() => { - const iconOpacity = DEFAULT_ICON_OPACITY * (selectable ? 1 : DISABLED_OPACITY); + if (!selectable) { + return null; + } + + const iconOpacity = DEFAULT_ICON_OPACITY * (disabled ? 1 : DISABLED_OPACITY); const color = selected ? theme.buttonBg : changeOpacity(theme.centerChannelColor, iconOpacity); return ( @@ -165,7 +171,7 @@ export default function UserListRow({ /> ); - }, [selectable, selected, theme]); + }, [selectable, disabled, selected, theme]); let usernameDisplay = `@${username}`; if (isMyUser) { @@ -179,7 +185,7 @@ export default function UserListRow({ const showTeammateDisplay = teammateDisplay !== username; const userItemTestID = `${testID}.${id}`; - const opacity = selectable || selected ? 1 : DISABLED_OPACITY; + const opacity = selectable || selected || !disabled ? 1 : DISABLED_OPACITY; return ( <> diff --git a/app/constants/screens.ts b/app/constants/screens.ts index ddef79467d..8c60dfb5f6 100644 --- a/app/constants/screens.ts +++ b/app/constants/screens.ts @@ -159,6 +159,4 @@ export const NOT_READY = [ CHANNEL_ADD_PEOPLE, CHANNEL_MENTION, CREATE_TEAM, - INTEGRATION_SELECTOR, - INTERACTIVE_DIALOG, ]; diff --git a/app/screens/index.tsx b/app/screens/index.tsx index f61f5cb4c2..5548a400d5 100644 --- a/app/screens/index.tsx +++ b/app/screens/index.tsx @@ -124,6 +124,9 @@ Navigation.setLazyComponentRegistrator((screenName) => { case Screens.INTERACTIVE_DIALOG: screen = withServerDatabase(require('@screens/interactive_dialog').default); break; + case Screens.INTEGRATION_SELECTOR: + screen = withServerDatabase(require('@screens/integration_selector').default); + break; case Screens.IN_APP_NOTIFICATION: { const notificationScreen = require('@screens/in_app_notification').default; Navigation.registerComponent(Screens.IN_APP_NOTIFICATION, () => diff --git a/app/screens/integration_selector/channel_list_row/__snapshots__/index.test.tsx.snap b/app/screens/integration_selector/channel_list_row/__snapshots__/index.test.tsx.snap new file mode 100644 index 0000000000..cda0c91b2c --- /dev/null +++ b/app/screens/integration_selector/channel_list_row/__snapshots__/index.test.tsx.snap @@ -0,0 +1,482 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`components/integration_selector/channel_list_row should match snapshot with delete_at filled in 1`] = ` + + + + + + + + channel + + + + + + +`; + +exports[`components/integration_selector/channel_list_row should match snapshot with open channel icon 1`] = ` + + + + + + + + channel + + + + + + +`; + +exports[`components/integration_selector/channel_list_row should match snapshot with private channel icon 1`] = ` + + + + + + + + channel + + + + + + +`; + +exports[`components/integration_selector/channel_list_row should match snapshot with purpose filled in 1`] = ` + + + + + + + + channel + + + + My purpose + + + + + +`; + +exports[`components/integration_selector/channel_list_row should match snapshot with shared filled in 1`] = ` + + + + + + + + channel + + + + + + +`; diff --git a/app/screens/integration_selector/channel_list_row/index.test.tsx b/app/screens/integration_selector/channel_list_row/index.test.tsx new file mode 100644 index 0000000000..69cc99847e --- /dev/null +++ b/app/screens/integration_selector/channel_list_row/index.test.tsx @@ -0,0 +1,158 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import Database from '@nozbe/watermelondb/Database'; +import React from 'react'; + +import {Preferences} from '@app/constants'; +import {renderWithEverything} from '@test/intl-test-helper'; +import TestHelper from '@test/test_helper'; + +import ChannelListRow from '.'; + +describe('components/integration_selector/channel_list_row', () => { + let database: Database; + const channel: Channel = { + id: '1', + create_at: 1111, + update_at: 1111, + delete_at: 0, + team_id: 'my team', + type: 'O', + display_name: 'channel', + name: 'channel', + header: 'channel', + purpose: '', + last_post_at: 1, + total_msg_count: 1, + extra_update_at: 1, + creator_id: '1', + scheme_id: null, + group_constrained: null, + shared: true, + }; + beforeAll(async () => { + const server = await TestHelper.setupServerDatabase(); + database = server.database; + }); + + it('should match snapshot with open channel icon', () => { + const wrapper = renderWithEverything( + { + // noop + }} + > +
+
, + {database}, + ); + + expect(wrapper.toJSON()).toMatchSnapshot(); + }); + + it('should match snapshot with private channel icon', () => { + channel.type = 'P'; + + const wrapper = renderWithEverything( + { + // noop + }} + > +
+
, + {database}, + ); + + expect(wrapper.toJSON()).toMatchSnapshot(); + }); + + it('should match snapshot with delete_at filled in', () => { + channel.delete_at = 1111; + channel.shared = false; + + const wrapper = renderWithEverything( + { + // noop + }} + > +
+
, + {database}, + ); + + expect(wrapper.toJSON()).toMatchSnapshot(); + }); + + it('should match snapshot with shared filled in', () => { + channel.delete_at = 0; + channel.shared = true; + + const wrapper = renderWithEverything( + { + // noop + }} + > +
+
, + {database}, + ); + + expect(wrapper.toJSON()).toMatchSnapshot(); + }); + + it('should match snapshot with purpose filled in', () => { + channel.purpose = 'My purpose'; + + const wrapper = renderWithEverything( + { + // noop + }} + > +
+
, + {database}, + ); + + expect(wrapper.toJSON()).toMatchSnapshot(); + }); +}); diff --git a/app/screens/integration_selector/channel_list_row/index.tsx b/app/screens/integration_selector/channel_list_row/index.tsx new file mode 100644 index 0000000000..a0f0bbe7d9 --- /dev/null +++ b/app/screens/integration_selector/channel_list_row/index.tsx @@ -0,0 +1,137 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback} from 'react'; +import { + Text, + View, +} from 'react-native'; + +import {typography} from '@app/utils/typography'; +import CompassIcon from '@components/compass_icon'; +import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme'; + +import CustomListRow, {Props as CustomListRowProps} from '../custom_list_row'; + +type ChannelListRowProps = { + id: string; + theme: object; + channel: Channel; + onPress: (item: Channel) => void; +}; + +type Props = ChannelListRowProps & CustomListRowProps; + +const getIconForChannel = (selectedChannel: Channel): string => { + let icon = 'globe'; + + if (selectedChannel.type === 'P') { + icon = 'padlock'; + } + + if (selectedChannel.delete_at) { + icon = 'archive-outline'; + } else if (selectedChannel.shared) { + icon = 'circle-multiple-outline'; + } + + return icon; +}; + +const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { + return { + titleContainer: { + alignItems: 'center', + flexDirection: 'row', + }, + displayName: { + ...typography('Body', 200, 'Regular'), + color: theme.centerChannelColor, + marginLeft: 5, + }, + icon: { + ...typography('Body', 200, 'Regular'), + color: theme.centerChannelColor, + }, + container: { + flex: 1, + flexDirection: 'column', + }, + outerContainer: { + flex: 1, + flexDirection: 'row', + paddingHorizontal: 15, + overflow: 'hidden', + }, + purpose: { + marginTop: 7, + color: changeOpacity(theme.centerChannelColor, 0.5), + ...typography('Body', 100, 'Regular'), + }, + }; +}); + +const ChannelListRow = ({ + onPress, id, theme, channel, testID, enabled, selectable, selected, +}: Props) => { + const style = getStyleFromTheme(theme); + + const onPressRow = useCallback((): void => { + onPress(channel); + }, [onPress, channel]); + + const renderPurpose = (channelPurpose: string): JSX.Element | null => { + if (!channelPurpose) { + return null; + } + + return ( + + {channel.purpose} + + ); + }; + + const itemTestID = `${testID}.${id}`; + const channelDisplayNameTestID = `${testID}.display_name`; + const channelIcon = getIconForChannel(channel); + + return ( + + + + + + + {channel.display_name} + + + + {renderPurpose(channel.purpose)} + + + + ); +}; + +export default ChannelListRow; diff --git a/app/screens/integration_selector/custom_list/index.test.tsx b/app/screens/integration_selector/custom_list/index.test.tsx new file mode 100644 index 0000000000..8d2f79e7c4 --- /dev/null +++ b/app/screens/integration_selector/custom_list/index.test.tsx @@ -0,0 +1,68 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import Database from '@nozbe/watermelondb/Database'; +import React from 'react'; +import {Text} from 'react-native'; + +import {Preferences} from '@app/constants'; +import {renderWithEverything} from '@test/intl-test-helper'; +import TestHelper from '@test/test_helper'; + +import CustomList, {FLATLIST} from '.'; + +describe('components/integration_selector/custom_list', () => { + let database: Database; + beforeAll(async () => { + const server = await TestHelper.setupServerDatabase(); + database = server.database; + }); + + it('should render', () => { + const channel: Channel = { + id: '1', + create_at: 1111, + update_at: 1111, + delete_at: 1111, + team_id: 'my team', + type: 'O', + display_name: 'channel', + name: 'channel', + header: 'channel', + purpose: 'channel', + last_post_at: 1, + total_msg_count: 1, + extra_update_at: 1, + creator_id: '1', + scheme_id: null, + group_constrained: null, + shared: true, + }; + const wrapper = renderWithEverything( + { + return {'No Results'}; + }} + onLoadMore={() => { + // noop + }} + onRowPress={() => { + // noop + }} + renderItem={(props: object): JSX.Element => { + return ({props.toString()}); + }} + loadingComponent={null} + />, + {database}, + ); + + expect(wrapper.toJSON()).toBeTruthy(); + }); +}); diff --git a/app/screens/integration_selector/custom_list/index.tsx b/app/screens/integration_selector/custom_list/index.tsx new file mode 100644 index 0000000000..d9f5671e73 --- /dev/null +++ b/app/screens/integration_selector/custom_list/index.tsx @@ -0,0 +1,231 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import React, {useCallback} from 'react'; +import { + Text, Platform, FlatList, RefreshControl, View, SectionList, +} from 'react-native'; + +import {typography} from '@app/utils/typography'; +import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme'; + +export const FLATLIST = 'flat'; +export const SECTIONLIST = 'section'; +const INITIAL_BATCH_TO_RENDER = 15; + +type UserProfileSection = { + id: string; + data: UserProfile[]; +}; +type DataType = DialogOption[] | Channel[] | UserProfile[] | UserProfileSection[]; +type ListItemProps = { + id: string; + item: DialogOption | Channel | UserProfile; + selected: boolean; + selectable?: boolean; + enabled: boolean; + onPress: (item: DialogOption) => void; +} + +type Props = { + data: DataType; + canRefresh?: boolean; + listType?: string; + loading?: boolean; + loadingComponent?: React.ReactElement | null; + noResults: () => JSX.Element | null; + refreshing?: boolean; + onRefresh?: () => void; + onLoadMore: () => void; + onRowPress: (item: UserProfile | Channel | DialogOption) => void; + renderItem: (props: ListItemProps) => JSX.Element; + selectable?: boolean; + theme?: object; + shouldRenderSeparator?: boolean; + testID?: string; +} + +const keyExtractor = (item: any): string => { + return item.id || item.key || item.value || item; +}; + +const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { + return { + list: { + backgroundColor: theme.centerChannelBg, + flex: 1, + ...Platform.select({ + android: { + marginBottom: 20, + }, + }), + }, + container: { + flexGrow: 1, + }, + separator: { + height: 1, + flex: 1, + backgroundColor: changeOpacity(theme.centerChannelColor, 0.1), + }, + listView: { + flex: 1, + backgroundColor: theme.centerChannelBg, + ...Platform.select({ + android: { + marginBottom: 20, + }, + }), + }, + loadingText: { + color: changeOpacity(theme.centerChannelColor, 0.6), + }, + searching: { + backgroundColor: theme.centerChannelBg, + height: '100%', + position: 'absolute', + width: '100%', + }, + sectionContainer: { + backgroundColor: changeOpacity(theme.centerChannelColor, 0.07), + paddingLeft: 10, + paddingVertical: 2, + }, + sectionWrapper: { + backgroundColor: theme.centerChannelBg, + }, + sectionText: { + fontWeight: '600', + color: theme.centerChannelColor, + }, + noResultContainer: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + }, + noResultText: { + color: changeOpacity(theme.centerChannelColor, 0.5), + ...typography('Body', 600, 'Regular'), + }, + }; +}); + +function CustomList({ + data, shouldRenderSeparator, listType, loading, loadingComponent, noResults, + onLoadMore, onRowPress, selectable, renderItem, theme, + canRefresh = true, testID, refreshing = false, onRefresh, +}: Props) { + const style = getStyleFromTheme(theme); + + // Renders + const renderEmptyList = useCallback(() => { + return noResults || null; + }, [noResults]); + + const renderSeparator = useCallback(() => { + if (!shouldRenderSeparator) { + return null; + } + + return ( + + ); + }, [shouldRenderSeparator, style]); + + const renderListItem = useCallback(({item}: any) => { + const props: ListItemProps = { + id: item.key, + item, + selected: item.selected, + selectable, + enabled: true, + onPress: onRowPress, + }; + + if ('disableSelect' in item) { + props.enabled = !item.disableSelect; + } + + return renderItem(props); + }, [onRowPress, selectable, renderItem]); + + const renderFooter = useCallback((): React.ReactElement | null => { + if (!loading || !loadingComponent) { + return null; + } + return loadingComponent; + }, [loading, loadingComponent]); + + const renderSectionHeader = useCallback(({section}: any) => { + return ( + + + {section.id} + + + ); + }, [style]); + + const renderSectionList = () => { + return ( + + ); + }; + + const renderFlatList = () => { + let refreshControl; + if (canRefresh) { + refreshControl = ( + ); + } + + return ( + + ); + }; + + if (listType === FLATLIST) { + return renderFlatList(); + } + + return renderSectionList(); +} + +export default CustomList; diff --git a/app/screens/integration_selector/custom_list_row/__snapshots__/index.test.tsx.snap b/app/screens/integration_selector/custom_list_row/__snapshots__/index.test.tsx.snap new file mode 100644 index 0000000000..17dfeb003e --- /dev/null +++ b/app/screens/integration_selector/custom_list_row/__snapshots__/index.test.tsx.snap @@ -0,0 +1,84 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`components/integration_selector/custom_list_row should match snapshot 1`] = ` + + + + + + + + + + + + My channel + + + + + +`; diff --git a/app/screens/integration_selector/custom_list_row/index.test.tsx b/app/screens/integration_selector/custom_list_row/index.test.tsx new file mode 100644 index 0000000000..fda5607e5b --- /dev/null +++ b/app/screens/integration_selector/custom_list_row/index.test.tsx @@ -0,0 +1,48 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import Database from '@nozbe/watermelondb/Database'; +import React from 'react'; +import {Text, View} from 'react-native'; + +import CompassIcon from '@app/components/compass_icon'; +import {renderWithEverything} from '@test/intl-test-helper'; +import TestHelper from '@test/test_helper'; + +import CustomListRow from '.'; + +describe('components/integration_selector/custom_list_row', () => { + let database: Database; + beforeAll(async () => { + const server = await TestHelper.setupServerDatabase(); + database = server.database; + }); + + it('should match snapshot', () => { + const wrapper = renderWithEverything( + { + // noop + }} + enabled={true} + selectable={true} + selected={true} + > + + + + + {'My channel'} + + + + , + {database}, + ); + + expect(wrapper.toJSON()).toMatchSnapshot(); + }); +}); diff --git a/app/screens/integration_selector/custom_list_row/index.tsx b/app/screens/integration_selector/custom_list_row/index.tsx new file mode 100644 index 0000000000..4d8286580a --- /dev/null +++ b/app/screens/integration_selector/custom_list_row/index.tsx @@ -0,0 +1,100 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import { + GestureResponderEvent, + StyleSheet, + TouchableOpacity, + View, +} from 'react-native'; + +import CompassIcon from '@app/components/compass_icon'; + +export type Props = { + id: string; + onPress?: (event: GestureResponderEvent) => void; + enabled: boolean; + selectable: boolean; + selected: boolean; + children: React.ReactNode; + testID?: string; +}; + +const style = StyleSheet.create({ + touchable: { + flex: 1, + overflow: 'hidden', + }, + container: { + flexDirection: 'row', + height: 65, + flex: 1, + alignItems: 'center', + }, + children: { + flex: 1, + flexDirection: 'row', + }, + selector: { + height: 28, + width: 28, + borderRadius: 14, + borderWidth: 1, + borderColor: 'rgba(61, 60, 64, 0.32)', + alignItems: 'center', + justifyContent: 'center', + }, + selectorContainer: { + height: 50, + paddingRight: 10, + alignItems: 'center', + justifyContent: 'center', + }, + selectorDisabled: { + borderColor: 'rgba(61, 60, 64, 0.16)', + }, + selectorFilled: { + backgroundColor: '#166DE0', + borderWidth: 0, + }, +}); + +const CustomListRow = ({ + onPress, children, enabled, selectable, selected, id, testID, +}: Props) => { + return ( + + {selectable && + + + {selected && + + } + + + } + + + {children} + + + ); +}; + +export default CustomListRow; diff --git a/app/screens/integration_selector/index.tsx b/app/screens/integration_selector/index.tsx new file mode 100644 index 0000000000..3c2f109866 --- /dev/null +++ b/app/screens/integration_selector/index.tsx @@ -0,0 +1,622 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; +import withObservables from '@nozbe/with-observables'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import {useIntl} from 'react-intl'; +import {View} from 'react-native'; +import {SafeAreaView} from 'react-native-safe-area-context'; + +import {fetchChannels, searchChannels} from '@actions/remote/channel'; +import {fetchProfiles, searchProfiles} from '@actions/remote/user'; +import FormattedText from '@components/formatted_text'; +import SearchBar from '@components/search'; +import UserListRow from '@components/user_list_row'; +import {General, View as ViewConstants} from '@constants'; +import {useServerUrl} from '@context/server'; +import {useTheme} from '@context/theme'; +import {debounce} from '@helpers/api/general'; +import useNavButtonPressed from '@hooks/navigation_button_pressed'; +import {observeCurrentTeamId} from '@queries/servers/system'; +import { + buildNavigationButton, + popTopScreen, setButtons, +} from '@screens/navigation'; +import {filterChannelsMatchingTerm} from '@utils/channel'; +import {changeOpacity, getKeyboardAppearanceFromTheme, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; +import {filterProfilesMatchingTerm} from '@utils/user'; + +import {createProfilesSections} from '../create_direct_message/user_list'; + +import ChannelListRow from './channel_list_row'; +import CustomList, {FLATLIST, SECTIONLIST} from './custom_list'; +import OptionListRow from './option_list_row'; +import SelectedOptions from './selected_options'; + +import type {WithDatabaseArgs} from '@typings/database/database'; + +type DataType = DialogOption[] | Channel[] | UserProfile[]; +type Selection = DialogOption | Channel | UserProfile | DataType; +type MultiselectSelectedMap = Dictionary | Dictionary | Dictionary; +type UserProfileSection = { + id: string; + data: UserProfile[]; +}; + +const VALID_DATASOURCES = [ + ViewConstants.DATA_SOURCE_CHANNELS, + ViewConstants.DATA_SOURCE_USERS, + ViewConstants.DATA_SOURCE_DYNAMIC]; +const SUBMIT_BUTTON_ID = 'submit-integration-selector-multiselect'; + +const close = () => { + popTopScreen(); +}; + +const extractItemKey = (dataSource: string, item: Selection): string => { + switch (dataSource) { + case ViewConstants.DATA_SOURCE_USERS: { + const typedItem = item as UserProfile; + return typedItem.id; + } + case ViewConstants.DATA_SOURCE_CHANNELS: { + const typedItem = item as Channel; + return typedItem.id; + } + default: { + const typedItem = item as DialogOption; + return typedItem.value; + } + } +}; + +const filterSearchData = (source: string, searchData: DataType, searchTerm: string) => { + if (!searchData) { + return []; + } + + const lowerCasedTerm = searchTerm.toLowerCase(); + + if (source === ViewConstants.DATA_SOURCE_USERS) { + return filterProfilesMatchingTerm(searchData as UserProfile[], lowerCasedTerm); + } else if (source === ViewConstants.DATA_SOURCE_CHANNELS) { + return filterChannelsMatchingTerm(searchData as Channel[], lowerCasedTerm); + } else if (source === ViewConstants.DATA_SOURCE_DYNAMIC) { + return searchData; + } + + return (searchData as DialogOption[]).filter((option) => option.text && option.text.includes(lowerCasedTerm)); +}; + +export type Props = { + getDynamicOptions?: (userInput?: string) => Promise; + options?: PostActionOption[]; + currentTeamId: string; + data?: DataType; + dataSource: string; + handleSelect: (opt: Selection) => void; + isMultiselect?: boolean; + selected?: DialogOption[]; + theme: Theme; + teammateNameDisplay: string; + componentId: string; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + container: { + flex: 1, + }, + searchBar: { + marginVertical: 5, + height: 38, + }, + 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: { + color: changeOpacity(theme.centerChannelColor, 0.5), + ...typography('Body', 600, 'Regular'), + }, + searchBarInput: { + backgroundColor: changeOpacity(theme.centerChannelColor, 0.2), + color: theme.centerChannelColor, + ...typography('Body', 200, 'Regular'), + }, + separator: { + height: 1, + flex: 0, + backgroundColor: changeOpacity(theme.centerChannelColor, 0.1), + }, + }; +}); + +function IntegrationSelector( + {dataSource, data, isMultiselect = false, selected, handleSelect, + currentTeamId, componentId, getDynamicOptions, options, teammateNameDisplay}: Props) { + const serverUrl = useServerUrl(); + const theme = useTheme(); + const searchTimeoutId = useRef(null); + const style = getStyleSheet(theme); + const intl = useIntl(); + + // HOOKS + const [integrationData, setIntegrationData] = useState(data || []); + const [loading, setLoading] = useState(false); + const [term, setTerm] = useState(''); + const [searchResults, setSearchResults] = useState([]); + const [multiselectSelected, setMultiselectSelected] = useState({}); + const [customListData, setCustomListData] = useState([]); + + const page = useRef(-1); + const next = useRef(VALID_DATASOURCES.includes(dataSource)); + + // Callbacks + const clearSearch = useCallback(() => { + setTerm(''); + setSearchResults([]); + }, []); + + // This is the button to submit multiselect options + const rightButton = useMemo(() => { + const base = buildNavigationButton( + SUBMIT_BUTTON_ID, + 'integration_selector.multiselect.submit.button', + undefined, + intl.formatMessage({id: 'integration_selector.multiselect.submit', defaultMessage: 'Done'}), + ); + base.enabled = true; + base.showAsAction = 'always'; + base.color = theme.sidebarHeaderTextColor; + return base; + }, [theme.sidebarHeaderTextColor, intl]); + + const handleSelectItem = useCallback((item: Selection) => { + if (!isMultiselect) { + handleSelect(item); + close(); + return; + } + + const itemKey = extractItemKey(dataSource, item); + const currentSelected: Dictionary | Dictionary | Dictionary = multiselectSelected; + const multiselectSelectedItems = {...currentSelected}; + + switch (dataSource) { + case ViewConstants.DATA_SOURCE_USERS: { + if (currentSelected[itemKey]) { + delete multiselectSelectedItems[itemKey]; + } else { + multiselectSelectedItems[itemKey] = item as UserProfile; + } + + setMultiselectSelected(multiselectSelectedItems); + return; + } + case ViewConstants.DATA_SOURCE_CHANNELS: { + if (currentSelected[itemKey]) { + delete multiselectSelectedItems[itemKey]; + } else { + multiselectSelectedItems[itemKey] = item as Channel; + } + + setMultiselectSelected(multiselectSelectedItems); + return; + } + default: { + if (currentSelected[itemKey]) { + delete multiselectSelectedItems[itemKey]; + } else { + multiselectSelectedItems[itemKey] = item as DialogOption; + } + setMultiselectSelected(multiselectSelectedItems); + } + } + }, [integrationData, multiselectSelected, isMultiselect, dataSource, handleSelect]); + + const handleRemoveOption = useCallback((item: UserProfile | Channel | DialogOption) => { + const currentSelected: Dictionary | Dictionary | Dictionary = multiselectSelected; + const itemKey = extractItemKey(dataSource, item); + const multiselectSelectedItems = {...currentSelected}; + delete multiselectSelectedItems[itemKey]; + setMultiselectSelected(multiselectSelectedItems); + }, [dataSource, multiselectSelected]); + + const getChannels = useCallback(debounce(async () => { + if (next.current && !loading && !term) { + setLoading(true); + page.current += 1; + + const {channels: channelData} = await fetchChannels(serverUrl, currentTeamId, page.current); + + setLoading(false); + + if (channelData && channelData.length > 0) { + setIntegrationData([...integrationData as Channel[], ...channelData]); + } else { + next.current = false; + } + } + }, 100), [loading, term, serverUrl, currentTeamId, integrationData]); + + const getProfiles = useCallback(debounce(async () => { + if (next.current && !loading && !term) { + setLoading(true); + page.current += 1; + + const {users: profiles} = await fetchProfiles(serverUrl, page.current); + + setLoading(false); + + if (profiles && profiles.length > 0) { + setIntegrationData([...integrationData as UserProfile[], ...profiles]); + } else { + next.current = false; + } + } + }, 100), [loading, term, integrationData]); + + const loadMore = useCallback(async () => { + if (dataSource === ViewConstants.DATA_SOURCE_USERS) { + await getProfiles(); + } else if (dataSource === ViewConstants.DATA_SOURCE_CHANNELS) { + await getChannels(); + } + + // dynamic options are not paged so are not reloaded on scroll + }, [getProfiles, getChannels, dataSource]); + + const searchDynamicOptions = useCallback(async (searchTerm = '') => { + if (options && options !== integrationData && !searchTerm) { + setIntegrationData(options); + } + + if (!getDynamicOptions) { + return; + } + + const results: DialogOption[] = await getDynamicOptions(searchTerm.toLowerCase()); + const searchData = results || []; + + if (searchTerm) { + setSearchResults(searchData); + } else { + setIntegrationData(searchData); + } + }, [options, getDynamicOptions, integrationData]); + + const onHandleMultiselectSubmit = useCallback(() => { + handleSelect(getMultiselectData(multiselectSelected)); + close(); + }, [multiselectSelected, handleSelect]); + + const onSearch = useCallback((text: string) => { + if (!text) { + clearSearch(); + return; + } + + setTerm(text); + + if (searchTimeoutId.current) { + clearTimeout(searchTimeoutId.current); + } + + searchTimeoutId.current = setTimeout(async () => { + if (!dataSource) { + setSearchResults(filterSearchData('', integrationData, text)); + return; + } + + setLoading(true); + + if (dataSource === ViewConstants.DATA_SOURCE_USERS) { + const {data: userData} = await searchProfiles( + serverUrl, text.toLowerCase(), + {team_id: currentTeamId, allow_inactive: true}); + + if (userData) { + setSearchResults(userData); + } + } else if (dataSource === ViewConstants.DATA_SOURCE_CHANNELS) { + const isSearch = true; + const {channels: receivedChannels} = await searchChannels( + serverUrl, text, currentTeamId, isSearch); + + if (receivedChannels) { + setSearchResults(receivedChannels); + } + } else if (dataSource === ViewConstants.DATA_SOURCE_DYNAMIC) { + await searchDynamicOptions(text); + } + + setLoading(false); + }, General.SEARCH_TIMEOUT_MILLISECONDS); + }, [dataSource, integrationData, currentTeamId]); + + const getMultiselectData = useCallback((multiselectSelectedElems: MultiselectSelectedMap): Selection => { + let myItems; + let multiselectItems: Selection = []; + + switch (dataSource) { + case ViewConstants.DATA_SOURCE_USERS: + myItems = multiselectSelectedElems as Dictionary; + multiselectItems = multiselectItems as UserProfile[]; + // eslint-disable-next-line guard-for-in + for (const index in myItems) { + multiselectItems.push(myItems[index]); + } + return multiselectItems; + case ViewConstants.DATA_SOURCE_CHANNELS: + myItems = multiselectSelectedElems as Dictionary; + multiselectItems = multiselectItems as Channel[]; + // eslint-disable-next-line guard-for-in + for (const index in myItems) { + multiselectItems.push(myItems[index]); + } + return multiselectItems; + default: + myItems = multiselectSelectedElems as Dictionary; + multiselectItems = multiselectItems as DialogOption[]; + // eslint-disable-next-line guard-for-in + for (const index in myItems) { + multiselectItems.push(myItems[index]); + } + return multiselectItems; + } + }, [multiselectSelected, dataSource]); + + // Effects + useNavButtonPressed(SUBMIT_BUTTON_ID, componentId, onHandleMultiselectSubmit, [onHandleMultiselectSubmit]); + + useEffect(() => { + return () => { + if (searchTimeoutId.current) { + clearTimeout(searchTimeoutId.current); + searchTimeoutId.current = null; + } + }; + }, []); + + useEffect(() => { + if (dataSource === ViewConstants.DATA_SOURCE_USERS) { + getProfiles(); + } else if (dataSource === ViewConstants.DATA_SOURCE_CHANNELS) { + getChannels(); + } else { + // Static and dynamic option search + searchDynamicOptions(''); + } + }, []); + + useEffect(() => { + let listData: (DataType | UserProfileSection[]) = integrationData; + + if (term) { + listData = searchResults; + } + + if (dataSource === ViewConstants.DATA_SOURCE_USERS) { + listData = createProfilesSections(listData as UserProfile[]); + } + + if (dataSource === ViewConstants.DATA_SOURCE_DYNAMIC) { + listData = (integrationData as DialogOption[]).filter((option) => option.text && option.text.toLowerCase().includes(term)); + } + + setCustomListData(listData); + }, [searchResults, integrationData]); + + useEffect(() => { + if (!isMultiselect) { + return; + } + + setButtons(componentId, { + rightButtons: [rightButton], + }); + }, [rightButton, componentId, isMultiselect]); + + useEffect(() => { + const multiselectItems: MultiselectSelectedMap = {}; + + if (multiselectSelected) { + return; + } + + if (isMultiselect && selected && !([ViewConstants.DATA_SOURCE_USERS, ViewConstants.DATA_SOURCE_CHANNELS].includes(dataSource))) { + selected.forEach((opt) => { + multiselectItems[opt.value] = opt; + }); + + setMultiselectSelected(multiselectItems); + } + }, [multiselectSelected]); + + // Renders + const renderLoading = useCallback(() => { + if (!loading) { + return null; + } + + let text; + switch (dataSource) { + case ViewConstants.DATA_SOURCE_USERS: + text = { + id: intl.formatMessage({id: 'mobile.integration_selector.loading_users'}), + defaultMessage: 'Loading Users...', + }; + break; + case ViewConstants.DATA_SOURCE_CHANNELS: + text = { + id: intl.formatMessage({id: 'mobile.integration_selector.loading_channels'}), + defaultMessage: 'Loading Channels...', + }; + break; + default: + text = { + id: intl.formatMessage({id: 'mobile.integration_selector.loading_options'}), + defaultMessage: 'Loading Options...', + }; + break; + } + + return ( + + + + ); + }, [style, dataSource, loading, intl]); + + const renderNoResults = useCallback((): JSX.Element | null => { + if (loading || page.current === -1) { + return null; + } + + return ( + + + + ); + }, [loading, style]); + + const renderChannelItem = useCallback((itemProps: any) => { + const itemSelected = Boolean(multiselectSelected[itemProps.item.id]); + return ( + + ); + }, [multiselectSelected, theme, isMultiselect]); + + const renderOptionItem = useCallback((itemProps: any) => { + const itemSelected = Boolean(multiselectSelected[itemProps.item.value]); + return ( + + ); + }, [multiselectSelected, theme, isMultiselect]); + + const renderUserItem = useCallback((itemProps: any): JSX.Element => { + const itemSelected = Boolean(multiselectSelected[itemProps.item.id]); + + return ( + + ); + }, [multiselectSelected, theme, isMultiselect, teammateNameDisplay]); + + const getRenderItem = (): (itemProps: any) => JSX.Element => { + switch (dataSource) { + case ViewConstants.DATA_SOURCE_USERS: + return renderUserItem; + case ViewConstants.DATA_SOURCE_CHANNELS: + return renderChannelItem; + default: + return renderOptionItem; + } + }; + + const renderSelectedOptions = useCallback((): React.ReactElement | null => { + const selectedItems: any = Object.values(multiselectSelected); + + if (!selectedItems.length) { + return null; + } + + return ( + <> + + + + ); + }, [multiselectSelected, style, theme]); + + const listType = dataSource === ViewConstants.DATA_SOURCE_USERS ? SECTIONLIST : FLATLIST; + const selectedOptionsComponent = renderSelectedOptions(); + + return ( + + + + + + {selectedOptionsComponent} + + + + ); +} + +const withTeamId = withObservables([], ({database}: WithDatabaseArgs) => ({ + currentTeamId: observeCurrentTeamId(database), +})); + +export default withDatabase(withTeamId(IntegrationSelector)); diff --git a/app/screens/integration_selector/option_list_row/__snapshots__/index.test.tsx.snap b/app/screens/integration_selector/option_list_row/__snapshots__/index.test.tsx.snap new file mode 100644 index 0000000000..ef481625d8 --- /dev/null +++ b/app/screens/integration_selector/option_list_row/__snapshots__/index.test.tsx.snap @@ -0,0 +1,74 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`components/integration_selector/option_list_row should match snapshot for option 1`] = ` + + + + + + + my text + + + + + + +`; diff --git a/app/screens/integration_selector/option_list_row/index.test.tsx b/app/screens/integration_selector/option_list_row/index.test.tsx new file mode 100644 index 0000000000..79f622112c --- /dev/null +++ b/app/screens/integration_selector/option_list_row/index.test.tsx @@ -0,0 +1,44 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import Database from '@nozbe/watermelondb/Database'; +import React from 'react'; + +import {Preferences} from '@app/constants'; +import {renderWithEverything} from '@test/intl-test-helper'; +import TestHelper from '@test/test_helper'; + +import OptionListRow from '.'; + +describe('components/integration_selector/option_list_row', () => { + let database: Database; + beforeAll(async () => { + const server = await TestHelper.setupServerDatabase(); + database = server.database; + }); + + it('should match snapshot for option', () => { + const myItem = { + value: '1', + text: 'my text', + }; + const wrapper = renderWithEverything( + { + // noop + }} + > +
+
, + {database}, + ); + + expect(wrapper.toJSON()).toMatchSnapshot(); + }); +}); diff --git a/app/screens/integration_selector/option_list_row/index.tsx b/app/screens/integration_selector/option_list_row/index.tsx new file mode 100644 index 0000000000..3d330c65b4 --- /dev/null +++ b/app/screens/integration_selector/option_list_row/index.tsx @@ -0,0 +1,77 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback} from 'react'; +import { + Text, + View, +} from 'react-native'; + +import {typography} from '@app/utils/typography'; +import {makeStyleSheetFromTheme} from '@utils/theme'; + +import CustomListRow, {Props as CustomListRowProps} from '../custom_list_row'; + +type OptionListRowProps = { + id: string; + theme: object; + item: { text: string; value: string }; + onPress: (item: DialogOption) => void; +} + +type Props = OptionListRowProps & CustomListRowProps; + +const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { + return { + container: { + flexDirection: 'row', + height: 65, + paddingHorizontal: 15, + alignItems: 'center', + backgroundColor: theme.centerChannelBg, + }, + textContainer: { + marginLeft: 10, + justifyContent: 'center', + flexDirection: 'column', + flex: 1, + }, + optionText: { + color: theme.centerChannelColor, + ...typography('Body', 200, 'Regular'), + }, + }; +}); + +const OptionListRow = ({ + enabled, selectable, selected, theme, item, onPress, id, +}: Props) => { + const {text} = item; + const style = getStyleFromTheme(theme); + + const onPressRow = useCallback((): void => { + onPress(item); + }, [onPress, item]); + + return ( + + + + + + {text} + + + + + + ); +}; + +export default OptionListRow; diff --git a/app/screens/integration_selector/selected_option/__snapshots__/index.test.tsx.snap b/app/screens/integration_selector/selected_option/__snapshots__/index.test.tsx.snap new file mode 100644 index 0000000000..86293ea8ba --- /dev/null +++ b/app/screens/integration_selector/selected_option/__snapshots__/index.test.tsx.snap @@ -0,0 +1,172 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`components/integration_selector/selected_option should match snapshot for channel 1`] = ` + + + channel + + + + + +`; + +exports[`components/integration_selector/selected_option should match snapshot for option 1`] = ` + + + my text + + + + + +`; + +exports[`components/integration_selector/selected_option should match snapshot for userProfile 1`] = ` + + + johndoe + + + + + +`; diff --git a/app/screens/integration_selector/selected_option/index.test.tsx b/app/screens/integration_selector/selected_option/index.test.tsx new file mode 100644 index 0000000000..212585953a --- /dev/null +++ b/app/screens/integration_selector/selected_option/index.test.tsx @@ -0,0 +1,117 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import Database from '@nozbe/watermelondb/Database'; +import React from 'react'; + +import {Preferences} from '@app/constants'; +import {View as ViewConstants} from '@constants'; +import {renderWithEverything} from '@test/intl-test-helper'; +import TestHelper from '@test/test_helper'; + +import SelectedOption from '.'; + +describe('components/integration_selector/selected_option', () => { + let database: Database; + beforeAll(async () => { + const server = await TestHelper.setupServerDatabase(); + database = server.database; + }); + + it('should match snapshot for option', () => { + const myItem = { + value: '1', + text: 'my text', + }; + const wrapper = renderWithEverything( + { + // noop + }} + />, + {database}, + ); + + expect(wrapper.toJSON()).toMatchSnapshot(); + }); + + it('should match snapshot for userProfile', () => { + const userProfile: UserProfile = { + id: '1', + create_at: 1111, + update_at: 1111, + delete_at: 0, + username: 'johndoe', + nickname: 'johndoe', + first_name: 'johndoe', + last_name: 'johndoe', + position: 'hacker', + roles: 'admin', + locale: 'en_US', + notify_props: { + channel: 'true', + comments: 'never', + desktop: 'all', + desktop_sound: 'true', + email: 'true', + first_name: 'true', + mention_keys: 'false', + push: 'mention', + push_status: 'ooo', + }, + email: 'johndoe@me.com', + auth_service: 'dummy', + }; + const wrapper = renderWithEverything( + { + // noop + }} + />, + {database}, + ); + + expect(wrapper.toJSON()).toMatchSnapshot(); + }); + + it('should match snapshot for channel', () => { + const channel: Channel = { + id: '1', + create_at: 1111, + update_at: 1111, + delete_at: 0, + team_id: 'my team', + type: 'O', + display_name: 'channel', + name: 'channel', + header: 'channel', + purpose: '', + last_post_at: 1, + total_msg_count: 1, + extra_update_at: 1, + creator_id: '1', + scheme_id: null, + group_constrained: null, + shared: true, + }; + const wrapper = renderWithEverything( + { + // noop + }} + />, + {database}, + ); + + expect(wrapper.toJSON()).toMatchSnapshot(); + }); +}); diff --git a/app/screens/integration_selector/selected_option/index.tsx b/app/screens/integration_selector/selected_option/index.tsx new file mode 100644 index 0000000000..3bb0f6ac94 --- /dev/null +++ b/app/screens/integration_selector/selected_option/index.tsx @@ -0,0 +1,88 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback} from 'react'; +import { + Text, + TouchableOpacity, + View, +} from 'react-native'; + +import {typography} from '@app/utils/typography'; +import CompassIcon from '@components/compass_icon'; +import {View as ViewConstants} from '@constants'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; + +type Props = { + theme: Theme; + option: DialogOption | UserProfile | Channel; + dataSource: string; + onRemove: (opt: DialogOption | UserProfile | Channel) => void; +} + +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, + maxWidth: '90%', + ...typography('Body', 100, 'Regular'), + }, + }; +}); + +const SelectedOption = ({theme, option, onRemove, dataSource}: Props) => { + const style = getStyleFromTheme(theme); + const onPress = useCallback( + () => onRemove(option), + [onRemove, option], + ); + + let text; + switch (dataSource) { + case ViewConstants.DATA_SOURCE_USERS: + text = (option as UserProfile).username; + break; + case ViewConstants.DATA_SOURCE_CHANNELS: + text = (option as Channel).display_name; + break; + default: + text = (option as DialogOption).text; + break; + } + + return ( + + + {text} + + + + + + ); +}; + +export default SelectedOption; diff --git a/app/screens/integration_selector/selected_options/__snapshots__/index.test.tsx.snap b/app/screens/integration_selector/selected_options/__snapshots__/index.test.tsx.snap new file mode 100644 index 0000000000..57e7383a07 --- /dev/null +++ b/app/screens/integration_selector/selected_options/__snapshots__/index.test.tsx.snap @@ -0,0 +1,241 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`components/integration_selector/selected_options should match snapshot for channels 1`] = ` + + + + + + channel + + + + + + + + +`; + +exports[`components/integration_selector/selected_options should match snapshot for options 1`] = ` + + + + + + my text + + + + + + + + +`; + +exports[`components/integration_selector/selected_options should match snapshot for users 1`] = ` + + + + + + johndoe + + + + + + + + +`; diff --git a/app/screens/integration_selector/selected_options/index.test.tsx b/app/screens/integration_selector/selected_options/index.test.tsx new file mode 100644 index 0000000000..2216489918 --- /dev/null +++ b/app/screens/integration_selector/selected_options/index.test.tsx @@ -0,0 +1,119 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import Database from '@nozbe/watermelondb/Database'; +import React from 'react'; + +import {Preferences} from '@app/constants'; +import {View as ViewConstants} from '@constants'; +import {renderWithEverything} from '@test/intl-test-helper'; +import TestHelper from '@test/test_helper'; + +import SelectedOptions from '.'; + +describe('components/integration_selector/selected_options', () => { + let database: Database; + beforeAll(async () => { + const server = await TestHelper.setupServerDatabase(); + database = server.database; + }); + + it('should match snapshot for users', () => { + const userProfile: UserProfile = { + id: '1', + create_at: 1111, + update_at: 1111, + delete_at: 1111, + username: 'johndoe', + nickname: 'johndoe', + first_name: 'johndoe', + last_name: 'johndoe', + position: 'hacker', + roles: 'admin', + locale: 'en_US', + notify_props: { + channel: 'true', + comments: 'never', + desktop: 'all', + desktop_sound: 'true', + email: 'true', + first_name: 'true', + mention_keys: 'false', + push: 'mention', + push_status: 'ooo', + }, + email: 'johndoe@me.com', + auth_service: 'dummy', + }; + const wrapper = renderWithEverything( + { + // noop + }} + />, + {database}, + ); + + expect(wrapper.toJSON()).toMatchSnapshot(); + }); + + it('should match snapshot for channels', () => { + const channel: Channel = { + id: '1', + create_at: 1111, + update_at: 1111, + delete_at: 0, + team_id: 'my team', + type: 'O', + display_name: 'channel', + name: 'channel', + header: 'channel', + purpose: '', + last_post_at: 1, + total_msg_count: 1, + extra_update_at: 1, + creator_id: '1', + scheme_id: null, + group_constrained: null, + shared: true, + }; + + const wrapper = renderWithEverything( + { + // noop + }} + />, + {database}, + ); + + expect(wrapper.toJSON()).toMatchSnapshot(); + }); + + it('should match snapshot for options', () => { + const myItem = { + value: '1', + text: 'my text', + }; + + const wrapper = renderWithEverything( + { + // noop + }} + />, + {database}, + ); + + expect(wrapper.toJSON()).toMatchSnapshot(); + }); +}); diff --git a/app/screens/integration_selector/selected_options/index.tsx b/app/screens/integration_selector/selected_options/index.tsx new file mode 100644 index 0000000000..69ea7f640e --- /dev/null +++ b/app/screens/integration_selector/selected_options/index.tsx @@ -0,0 +1,79 @@ +// 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 {View as ViewConstants} from '@constants'; +import {makeStyleSheetFromTheme} from '@utils/theme'; + +import SelectedOption from '../selected_option'; + +type Props = { + theme: Theme; + selectedOptions: DialogOption[] | UserProfile[] | Channel[]; + dataSource: string; + onRemove: (opt: DialogOption | UserProfile | Channel) => void; +} + +const getStyleFromTheme = makeStyleSheetFromTheme(() => { + return { + container: { + marginLeft: 5, + marginBottom: 5, + maxHeight: 100, + flexGrow: 0, + }, + users: { + alignItems: 'flex-start', + flexDirection: 'row', + flexWrap: 'wrap', + }, + }; +}); + +const SelectedOptions = ({ + theme, selectedOptions, onRemove, dataSource, +}: Props) => { + const style = getStyleFromTheme(theme); + const options: React.ReactNode[] = selectedOptions.map((optionItem) => { + let key: string; + + switch (dataSource) { + case ViewConstants.DATA_SOURCE_USERS: + key = (optionItem as UserProfile).id; + break; + case ViewConstants.DATA_SOURCE_CHANNELS: + key = (optionItem as Channel).id; + break; + default: + key = (optionItem as DialogOption).value; + break; + } + + return ( + ); + }); + + // eslint-disable-next-line no-warning-comments + // TODO Consider using a Virtualized List since the number of elements is potentially unbounded. + // https://mattermost.atlassian.net/browse/MM-48420 + return ( + + + {options} + + + ); +}; + +export default SelectedOptions; diff --git a/app/utils/channel/index.ts b/app/utils/channel/index.ts index 96aeec86da..a87dd6b61d 100644 --- a/app/utils/channel/index.ts +++ b/app/utils/channel/index.ts @@ -142,3 +142,18 @@ export function compareNotifyProps(propsA: Partial, propsB: return true; } + +export function filterChannelsMatchingTerm(channels: Channel[], term: string): Channel[] { + const lowercasedTerm = term.toLowerCase(); + + return channels.filter((channel: Channel): boolean => { + if (!channel) { + return false; + } + const name = (channel.name || '').toLowerCase(); + const displayName = (channel.display_name || '').toLowerCase(); + + return name.startsWith(lowercasedTerm) || + displayName.startsWith(lowercasedTerm); + }); +} diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 7831966783..dd6ee008d1 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -448,6 +448,9 @@ "mobile.files_paste.error_dismiss": "Dismiss", "mobile.files_paste.error_title": "Paste failed", "mobile.gallery.title": "{index} of {total}", + "mobile.integration_selector.loading_users": "Loading users...", + "mobile.integration_selector.loading_channels": "Loading channels...", + "mobile.integration_selector.loading_options": "Loading options...", "mobile.ios.photos_permission_denied_description": "Upload photos and videos to your server or save them to your device. Open Settings to grant {applicationName} Read and Write access to your photo and video library.", "mobile.ios.photos_permission_denied_title": "{applicationName} would like to access your photos", "mobile.join_channel.error": "We couldn't join the channel {displayName}.",