[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

* 💄 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

* 🔥 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>
This commit is contained in:
Javier Aguirre
2022-11-23 11:17:47 +01:00
committed by GitHub
parent d20da35205
commit ac8a18bbfb
24 changed files with 2972 additions and 6 deletions

View File

@@ -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) => {

View File

@@ -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 (
<View style={style.selector}>
@@ -165,7 +171,7 @@ export default function UserListRow({
/>
</View>
);
}, [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 (
<>

View File

@@ -159,6 +159,4 @@ export const NOT_READY = [
CHANNEL_ADD_PEOPLE,
CHANNEL_MENTION,
CREATE_TEAM,
INTEGRATION_SELECTOR,
INTERACTIVE_DIALOG,
];

View File

@@ -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, () =>

View File

@@ -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`] = `
<View
style={
{
"flex": 1,
"flexDirection": "row",
"overflow": "hidden",
"paddingHorizontal": 15,
}
}
>
<View
accessible={true}
collapsable={false}
focusable={true}
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderRelease={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
style={
{
"alignItems": "center",
"flex": 1,
"flexDirection": "row",
"height": 65,
"opacity": 1,
}
}
testID="ChannelListRow"
>
<View
style={
{
"flex": 1,
"flexDirection": "row",
}
}
testID="1234"
>
<View
style={
{
"flex": 1,
"flexDirection": "column",
}
}
testID="ChannelListRow.1234"
>
<View
style={
{
"alignItems": "center",
"flexDirection": "row",
}
}
>
<Icon
name="archive-outline"
style={
{
"color": "#3f4350",
"fontFamily": "OpenSans",
"fontSize": 16,
"fontWeight": "400",
"lineHeight": 24,
}
}
/>
<Text
style={
{
"color": "#3f4350",
"fontFamily": "OpenSans",
"fontSize": 16,
"fontWeight": "400",
"lineHeight": 24,
"marginLeft": 5,
}
}
testID="ChannelListRow.display_name"
>
channel
</Text>
</View>
</View>
</View>
</View>
</View>
`;
exports[`components/integration_selector/channel_list_row should match snapshot with open channel icon 1`] = `
<View
style={
{
"flex": 1,
"flexDirection": "row",
"overflow": "hidden",
"paddingHorizontal": 15,
}
}
>
<View
accessible={true}
collapsable={false}
focusable={true}
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderRelease={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
style={
{
"alignItems": "center",
"flex": 1,
"flexDirection": "row",
"height": 65,
"opacity": 1,
}
}
testID="ChannelListRow"
>
<View
style={
{
"flex": 1,
"flexDirection": "row",
}
}
testID="1234"
>
<View
style={
{
"flex": 1,
"flexDirection": "column",
}
}
testID="ChannelListRow.1234"
>
<View
style={
{
"alignItems": "center",
"flexDirection": "row",
}
}
>
<Icon
name="circle-multiple-outline"
style={
{
"color": "#3f4350",
"fontFamily": "OpenSans",
"fontSize": 16,
"fontWeight": "400",
"lineHeight": 24,
}
}
/>
<Text
style={
{
"color": "#3f4350",
"fontFamily": "OpenSans",
"fontSize": 16,
"fontWeight": "400",
"lineHeight": 24,
"marginLeft": 5,
}
}
testID="ChannelListRow.display_name"
>
channel
</Text>
</View>
</View>
</View>
</View>
</View>
`;
exports[`components/integration_selector/channel_list_row should match snapshot with private channel icon 1`] = `
<View
style={
{
"flex": 1,
"flexDirection": "row",
"overflow": "hidden",
"paddingHorizontal": 15,
}
}
>
<View
accessible={true}
collapsable={false}
focusable={true}
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderRelease={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
style={
{
"alignItems": "center",
"flex": 1,
"flexDirection": "row",
"height": 65,
"opacity": 1,
}
}
testID="ChannelListRow"
>
<View
style={
{
"flex": 1,
"flexDirection": "row",
}
}
testID="1234"
>
<View
style={
{
"flex": 1,
"flexDirection": "column",
}
}
testID="ChannelListRow.1234"
>
<View
style={
{
"alignItems": "center",
"flexDirection": "row",
}
}
>
<Icon
name="circle-multiple-outline"
style={
{
"color": "#3f4350",
"fontFamily": "OpenSans",
"fontSize": 16,
"fontWeight": "400",
"lineHeight": 24,
}
}
/>
<Text
style={
{
"color": "#3f4350",
"fontFamily": "OpenSans",
"fontSize": 16,
"fontWeight": "400",
"lineHeight": 24,
"marginLeft": 5,
}
}
testID="ChannelListRow.display_name"
>
channel
</Text>
</View>
</View>
</View>
</View>
</View>
`;
exports[`components/integration_selector/channel_list_row should match snapshot with purpose filled in 1`] = `
<View
style={
{
"flex": 1,
"flexDirection": "row",
"overflow": "hidden",
"paddingHorizontal": 15,
}
}
>
<View
accessible={true}
collapsable={false}
focusable={true}
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderRelease={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
style={
{
"alignItems": "center",
"flex": 1,
"flexDirection": "row",
"height": 65,
"opacity": 1,
}
}
testID="ChannelListRow"
>
<View
style={
{
"flex": 1,
"flexDirection": "row",
}
}
testID="1234"
>
<View
style={
{
"flex": 1,
"flexDirection": "column",
}
}
testID="ChannelListRow.1234"
>
<View
style={
{
"alignItems": "center",
"flexDirection": "row",
}
}
>
<Icon
name="circle-multiple-outline"
style={
{
"color": "#3f4350",
"fontFamily": "OpenSans",
"fontSize": 16,
"fontWeight": "400",
"lineHeight": 24,
}
}
/>
<Text
style={
{
"color": "#3f4350",
"fontFamily": "OpenSans",
"fontSize": 16,
"fontWeight": "400",
"lineHeight": 24,
"marginLeft": 5,
}
}
testID="ChannelListRow.display_name"
>
channel
</Text>
</View>
<Text
ellipsizeMode="tail"
numberOfLines={1}
style={
{
"color": "rgba(63,67,80,0.5)",
"fontFamily": "OpenSans",
"fontSize": 14,
"fontWeight": "400",
"lineHeight": 20,
"marginTop": 7,
}
}
>
My purpose
</Text>
</View>
</View>
</View>
</View>
`;
exports[`components/integration_selector/channel_list_row should match snapshot with shared filled in 1`] = `
<View
style={
{
"flex": 1,
"flexDirection": "row",
"overflow": "hidden",
"paddingHorizontal": 15,
}
}
>
<View
accessible={true}
collapsable={false}
focusable={true}
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderRelease={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
style={
{
"alignItems": "center",
"flex": 1,
"flexDirection": "row",
"height": 65,
"opacity": 1,
}
}
testID="ChannelListRow"
>
<View
style={
{
"flex": 1,
"flexDirection": "row",
}
}
testID="1234"
>
<View
style={
{
"flex": 1,
"flexDirection": "column",
}
}
testID="ChannelListRow.1234"
>
<View
style={
{
"alignItems": "center",
"flexDirection": "row",
}
}
>
<Icon
name="circle-multiple-outline"
style={
{
"color": "#3f4350",
"fontFamily": "OpenSans",
"fontSize": 16,
"fontWeight": "400",
"lineHeight": 24,
}
}
/>
<Text
style={
{
"color": "#3f4350",
"fontFamily": "OpenSans",
"fontSize": 16,
"fontWeight": "400",
"lineHeight": 24,
"marginLeft": 5,
}
}
testID="ChannelListRow.display_name"
>
channel
</Text>
</View>
</View>
</View>
</View>
</View>
`;

View File

@@ -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(
<ChannelListRow
id='1234'
theme={Preferences.THEMES.denim}
channel={channel}
selected={false}
selectable={false}
enabled={true}
testID='ChannelListRow'
onPress={() => {
// noop
}}
>
<br/>
</ChannelListRow>,
{database},
);
expect(wrapper.toJSON()).toMatchSnapshot();
});
it('should match snapshot with private channel icon', () => {
channel.type = 'P';
const wrapper = renderWithEverything(
<ChannelListRow
id='1234'
theme={Preferences.THEMES.denim}
channel={channel}
selected={false}
selectable={false}
enabled={true}
testID='ChannelListRow'
onPress={() => {
// noop
}}
>
<br/>
</ChannelListRow>,
{database},
);
expect(wrapper.toJSON()).toMatchSnapshot();
});
it('should match snapshot with delete_at filled in', () => {
channel.delete_at = 1111;
channel.shared = false;
const wrapper = renderWithEverything(
<ChannelListRow
id='1234'
theme={Preferences.THEMES.denim}
channel={channel}
testID='ChannelListRow'
enabled={true}
selectable={false}
selected={false}
onPress={() => {
// noop
}}
>
<br/>
</ChannelListRow>,
{database},
);
expect(wrapper.toJSON()).toMatchSnapshot();
});
it('should match snapshot with shared filled in', () => {
channel.delete_at = 0;
channel.shared = true;
const wrapper = renderWithEverything(
<ChannelListRow
id='1234'
theme={Preferences.THEMES.denim}
channel={channel}
testID='ChannelListRow'
enabled={true}
selectable={false}
selected={false}
onPress={() => {
// noop
}}
>
<br/>
</ChannelListRow>,
{database},
);
expect(wrapper.toJSON()).toMatchSnapshot();
});
it('should match snapshot with purpose filled in', () => {
channel.purpose = 'My purpose';
const wrapper = renderWithEverything(
<ChannelListRow
id='1234'
theme={Preferences.THEMES.denim}
channel={channel}
testID='ChannelListRow'
enabled={true}
selectable={false}
selected={false}
onPress={() => {
// noop
}}
>
<br/>
</ChannelListRow>,
{database},
);
expect(wrapper.toJSON()).toMatchSnapshot();
});
});

View File

@@ -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 (
<Text
style={style.purpose}
ellipsizeMode='tail'
numberOfLines={1}
>
{channel.purpose}
</Text>
);
};
const itemTestID = `${testID}.${id}`;
const channelDisplayNameTestID = `${testID}.display_name`;
const channelIcon = getIconForChannel(channel);
return (
<View style={style.outerContainer}>
<CustomListRow
id={id}
onPress={onPressRow}
enabled={enabled}
selectable={selectable}
selected={selected}
testID={testID}
>
<View
style={style.container}
testID={itemTestID}
>
<View style={style.titleContainer}>
<CompassIcon
name={channelIcon}
style={style.icon}
/>
<Text
style={style.displayName}
testID={channelDisplayNameTestID}
>
{channel.display_name}
</Text>
</View>
{renderPurpose(channel.purpose)}
</View>
</CustomListRow>
</View>
);
};
export default ChannelListRow;

View File

@@ -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(
<CustomList
data={[channel]}
key='custom_list'
listType={FLATLIST}
loading={false}
theme={Preferences.THEMES.denim}
testID='ChannelListRow'
noResults={() => {
return <Text>{'No Results'}</Text>;
}}
onLoadMore={() => {
// noop
}}
onRowPress={() => {
// noop
}}
renderItem={(props: object): JSX.Element => {
return (<Text>{props.toString()}</Text>);
}}
loadingComponent={null}
/>,
{database},
);
expect(wrapper.toJSON()).toBeTruthy();
});
});

View File

@@ -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<any, string> | 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 (
<View style={style.separator}/>
);
}, [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<any, string> | null => {
if (!loading || !loadingComponent) {
return null;
}
return loadingComponent;
}, [loading, loadingComponent]);
const renderSectionHeader = useCallback(({section}: any) => {
return (
<View style={style.sectionWrapper}>
<View style={style.sectionContainer}>
<Text style={style.sectionText}>{section.id}</Text>
</View>
</View>
);
}, [style]);
const renderSectionList = () => {
return (
<SectionList
contentContainerStyle={style.container}
keyExtractor={keyExtractor}
initialNumToRender={INITIAL_BATCH_TO_RENDER}
ItemSeparatorComponent={renderSeparator}
ListEmptyComponent={renderEmptyList()}
ListFooterComponent={renderFooter}
maxToRenderPerBatch={INITIAL_BATCH_TO_RENDER + 1}
onEndReached={onLoadMore}
removeClippedSubviews={true}
renderItem={renderListItem}
renderSectionHeader={renderSectionHeader}
scrollEventThrottle={60}
sections={data as UserProfileSection[]}
style={style.list}
stickySectionHeadersEnabled={false}
testID={testID}
/>
);
};
const renderFlatList = () => {
let refreshControl;
if (canRefresh) {
refreshControl = (
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
/>);
}
return (
<FlatList
contentContainerStyle={style.container}
data={data}
keyboardShouldPersistTaps='always'
keyExtractor={keyExtractor}
initialNumToRender={INITIAL_BATCH_TO_RENDER}
ItemSeparatorComponent={renderSeparator}
ListEmptyComponent={renderEmptyList()}
ListFooterComponent={renderFooter}
maxToRenderPerBatch={INITIAL_BATCH_TO_RENDER + 1}
onEndReached={onLoadMore}
refreshControl={refreshControl}
removeClippedSubviews={true}
renderItem={renderListItem}
scrollEventThrottle={60}
style={style.list}
testID={testID}
/>
);
};
if (listType === FLATLIST) {
return renderFlatList();
}
return renderSectionList();
}
export default CustomList;

View File

@@ -0,0 +1,84 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/integration_selector/custom_list_row should match snapshot 1`] = `
<View
accessible={true}
collapsable={false}
focusable={true}
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderRelease={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
style={
{
"alignItems": "center",
"flex": 1,
"flexDirection": "row",
"height": 65,
"opacity": 1,
}
}
>
<View
style={
{
"alignItems": "center",
"height": 50,
"justifyContent": "center",
"paddingRight": 10,
}
}
>
<View
style={
[
{
"alignItems": "center",
"borderColor": "rgba(61, 60, 64, 0.32)",
"borderRadius": 14,
"borderWidth": 1,
"height": 28,
"justifyContent": "center",
"width": 28,
},
{
"backgroundColor": "#166DE0",
"borderWidth": 0,
},
false,
]
}
testID="1"
>
<Icon
color="#fff"
name="check"
size={24}
/>
</View>
</View>
<View
style={
{
"flex": 1,
"flexDirection": "row",
}
}
testID="1"
>
<View>
<View>
<Icon
name="globe"
/>
<Text>
My channel
</Text>
</View>
</View>
</View>
</View>
`;

View File

@@ -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(
<CustomListRow
id='1'
onPress={() => {
// noop
}}
enabled={true}
selectable={true}
selected={true}
>
<View>
<View>
<CompassIcon
name={'globe'}
/>
<Text>
{'My channel'}
</Text>
</View>
</View>
</CustomListRow>,
{database},
);
expect(wrapper.toJSON()).toMatchSnapshot();
});
});

View File

@@ -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 (
<TouchableOpacity
style={style.container}
testID={testID}
onPress={onPress}
>
{selectable &&
<View style={style.selectorContainer}>
<View
testID={id}
style={[style.selector, (selected && style.selectorFilled), (!enabled &&
style.selectorDisabled)]}
>
{selected &&
<CompassIcon
name='check'
size={24}
color='#fff'
/>
}
</View>
</View>
}
<View
testID={id}
style={style.children}
>
{children}
</View>
</TouchableOpacity>
);
};
export default CustomListRow;

View File

@@ -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<DialogOption> | Dictionary<Channel> | Dictionary<UserProfile>;
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<DialogOption[]>;
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<NodeJS.Timeout | null>(null);
const style = getStyleSheet(theme);
const intl = useIntl();
// HOOKS
const [integrationData, setIntegrationData] = useState<DataType>(data || []);
const [loading, setLoading] = useState<boolean>(false);
const [term, setTerm] = useState<string>('');
const [searchResults, setSearchResults] = useState<DataType>([]);
const [multiselectSelected, setMultiselectSelected] = useState<MultiselectSelectedMap>({});
const [customListData, setCustomListData] = useState<DataType | UserProfileSection[]>([]);
const page = useRef<number>(-1);
const next = useRef<boolean>(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<UserProfile> | Dictionary<DialogOption> | Dictionary<Channel> = 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<UserProfile> | Dictionary<DialogOption> | Dictionary<Channel> = 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<UserProfile>;
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<Channel>;
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<DialogOption>;
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 (
<View style={style.loadingContainer}>
<FormattedText
{...text}
style={style.loadingText}
/>
</View>
);
}, [style, dataSource, loading, intl]);
const renderNoResults = useCallback((): JSX.Element | null => {
if (loading || page.current === -1) {
return null;
}
return (
<View style={style.noResultContainer}>
<FormattedText
id='mobile.custom_list.no_results'
defaultMessage='No Results'
style={style.noResultText}
/>
</View>
);
}, [loading, style]);
const renderChannelItem = useCallback((itemProps: any) => {
const itemSelected = Boolean(multiselectSelected[itemProps.item.id]);
return (
<ChannelListRow
key={itemProps.id}
{...itemProps}
theme={theme}
channel={itemProps.item as Channel}
selectable={isMultiselect || false}
selected={itemSelected}
/>
);
}, [multiselectSelected, theme, isMultiselect]);
const renderOptionItem = useCallback((itemProps: any) => {
const itemSelected = Boolean(multiselectSelected[itemProps.item.value]);
return (
<OptionListRow
key={itemProps.id}
{...itemProps}
theme={theme}
selectable={isMultiselect}
selected={itemSelected}
/>
);
}, [multiselectSelected, theme, isMultiselect]);
const renderUserItem = useCallback((itemProps: any): JSX.Element => {
const itemSelected = Boolean(multiselectSelected[itemProps.item.id]);
return (
<UserListRow
key={itemProps.id}
{...itemProps}
theme={theme}
selectable={isMultiselect}
user={itemProps.item}
teammateNameDisplay={teammateNameDisplay}
selected={itemSelected}
/>
);
}, [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<any, string> | null => {
const selectedItems: any = Object.values(multiselectSelected);
if (!selectedItems.length) {
return null;
}
return (
<>
<SelectedOptions
theme={theme}
selectedOptions={selectedItems}
dataSource={dataSource}
onRemove={handleRemoveOption}
/>
<View style={style.separator}/>
</>
);
}, [multiselectSelected, style, theme]);
const listType = dataSource === ViewConstants.DATA_SOURCE_USERS ? SECTIONLIST : FLATLIST;
const selectedOptionsComponent = renderSelectedOptions();
return (
<SafeAreaView style={style.container}>
<View
testID='integration_selector.screen'
style={style.searchBar}
>
<SearchBar
testID='selector.search_bar'
placeholder={intl.formatMessage({id: 'search_bar.search', defaultMessage: 'Search'})}
inputStyle={style.searchBarInput}
placeholderTextColor={changeOpacity(theme.centerChannelColor, 0.5)}
onChangeText={onSearch}
autoCapitalize='none'
keyboardAppearance={getKeyboardAppearanceFromTheme(theme)}
value={term}
/>
</View>
{selectedOptionsComponent}
<CustomList
data={customListData as DataType}
key='custom_list'
listType={listType}
loading={loading}
loadingComponent={renderLoading()}
noResults={renderNoResults}
onLoadMore={loadMore}
onRowPress={handleSelectItem}
renderItem={getRenderItem()}
theme={theme}
/>
</SafeAreaView>
);
}
const withTeamId = withObservables([], ({database}: WithDatabaseArgs) => ({
currentTeamId: observeCurrentTeamId(database),
}));
export default withDatabase(withTeamId(IntegrationSelector));

View File

@@ -0,0 +1,74 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/integration_selector/option_list_row should match snapshot for option 1`] = `
<View
style={
{
"alignItems": "center",
"backgroundColor": "#ffffff",
"flexDirection": "row",
"height": 65,
"paddingHorizontal": 15,
}
}
>
<View
accessible={true}
collapsable={false}
focusable={true}
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderRelease={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
style={
{
"alignItems": "center",
"flex": 1,
"flexDirection": "row",
"height": 65,
"opacity": 1,
}
}
>
<View
style={
{
"flex": 1,
"flexDirection": "row",
}
}
testID="1"
>
<View
style={
{
"flex": 1,
"flexDirection": "column",
"justifyContent": "center",
"marginLeft": 10,
}
}
>
<View>
<Text
style={
{
"color": "#3f4350",
"fontFamily": "OpenSans",
"fontSize": 16,
"fontWeight": "400",
"lineHeight": 24,
}
}
>
my text
</Text>
</View>
</View>
</View>
</View>
</View>
`;

View File

@@ -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(
<OptionListRow
enabled={true}
selectable={false}
selected={false}
theme={Preferences.THEMES.denim}
item={myItem}
id='1'
onPress={() => {
// noop
}}
>
<br/>
</OptionListRow>,
{database},
);
expect(wrapper.toJSON()).toMatchSnapshot();
});
});

View File

@@ -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 (
<View style={style.container}>
<CustomListRow
id={id}
onPress={onPressRow}
enabled={enabled}
selectable={selectable}
selected={selected}
>
<View style={style.textContainer}>
<View>
<Text style={style.optionText}>
{text}
</Text>
</View>
</View>
</CustomListRow>
</View>
);
};
export default OptionListRow;

View File

@@ -0,0 +1,172 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/integration_selector/selected_option should match snapshot for channel 1`] = `
<View
style={
{
"alignItems": "center",
"backgroundColor": "rgba(63,67,80,0.2)",
"borderRadius": 3,
"flexDirection": "row",
"height": 27,
"marginBottom": 4,
"marginRight": 10,
"paddingLeft": 10,
}
}
>
<Text
numberOfLines={1}
style={
{
"color": "#3f4350",
"fontFamily": "OpenSans",
"fontSize": 14,
"fontWeight": "400",
"lineHeight": 20,
"maxWidth": "90%",
}
}
>
channel
</Text>
<View
accessible={true}
collapsable={false}
focusable={true}
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderRelease={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
style={
{
"opacity": 1,
"paddingHorizontal": 10,
}
}
>
<Icon
color="#3f4350"
name="close"
size={14}
/>
</View>
</View>
`;
exports[`components/integration_selector/selected_option should match snapshot for option 1`] = `
<View
style={
{
"alignItems": "center",
"backgroundColor": "rgba(63,67,80,0.2)",
"borderRadius": 3,
"flexDirection": "row",
"height": 27,
"marginBottom": 4,
"marginRight": 10,
"paddingLeft": 10,
}
}
>
<Text
numberOfLines={1}
style={
{
"color": "#3f4350",
"fontFamily": "OpenSans",
"fontSize": 14,
"fontWeight": "400",
"lineHeight": 20,
"maxWidth": "90%",
}
}
>
my text
</Text>
<View
accessible={true}
collapsable={false}
focusable={true}
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderRelease={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
style={
{
"opacity": 1,
"paddingHorizontal": 10,
}
}
>
<Icon
color="#3f4350"
name="close"
size={14}
/>
</View>
</View>
`;
exports[`components/integration_selector/selected_option should match snapshot for userProfile 1`] = `
<View
style={
{
"alignItems": "center",
"backgroundColor": "rgba(63,67,80,0.2)",
"borderRadius": 3,
"flexDirection": "row",
"height": 27,
"marginBottom": 4,
"marginRight": 10,
"paddingLeft": 10,
}
}
>
<Text
numberOfLines={1}
style={
{
"color": "#3f4350",
"fontFamily": "OpenSans",
"fontSize": 14,
"fontWeight": "400",
"lineHeight": 20,
"maxWidth": "90%",
}
}
>
johndoe
</Text>
<View
accessible={true}
collapsable={false}
focusable={true}
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderRelease={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
style={
{
"opacity": 1,
"paddingHorizontal": 10,
}
}
>
<Icon
color="#3f4350"
name="close"
size={14}
/>
</View>
</View>
`;

View File

@@ -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(
<SelectedOption
theme={Preferences.THEMES.denim}
option={myItem}
dataSource={ViewConstants.DATA_SOURCE_DYNAMIC}
onRemove={() => {
// 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(
<SelectedOption
theme={Preferences.THEMES.denim}
option={userProfile}
dataSource={ViewConstants.DATA_SOURCE_USERS}
onRemove={() => {
// 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(
<SelectedOption
theme={Preferences.THEMES.denim}
option={channel}
dataSource={ViewConstants.DATA_SOURCE_CHANNELS}
onRemove={() => {
// noop
}}
/>,
{database},
);
expect(wrapper.toJSON()).toMatchSnapshot();
});
});

View File

@@ -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 (
<View style={style.container}>
<Text
style={style.text}
numberOfLines={1}
>
{text}
</Text>
<TouchableOpacity
style={style.remove}
onPress={onPress}
>
<CompassIcon
name='close'
size={14}
color={theme.centerChannelColor}
/>
</TouchableOpacity>
</View>
);
};
export default SelectedOption;

View File

@@ -0,0 +1,241 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/integration_selector/selected_options should match snapshot for channels 1`] = `
<RCTScrollView
style={
{
"flexGrow": 0,
"marginBottom": 5,
"marginLeft": 5,
"maxHeight": 100,
}
}
>
<View>
<View
style={
{
"alignItems": "flex-start",
"flexDirection": "row",
"flexWrap": "wrap",
}
}
>
<View
style={
{
"alignItems": "center",
"backgroundColor": "rgba(63,67,80,0.2)",
"borderRadius": 3,
"flexDirection": "row",
"height": 27,
"marginBottom": 4,
"marginRight": 10,
"paddingLeft": 10,
}
}
>
<Text
numberOfLines={1}
style={
{
"color": "#3f4350",
"fontFamily": "OpenSans",
"fontSize": 14,
"fontWeight": "400",
"lineHeight": 20,
"maxWidth": "90%",
}
}
>
channel
</Text>
<View
accessible={true}
collapsable={false}
focusable={true}
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderRelease={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
style={
{
"opacity": 1,
"paddingHorizontal": 10,
}
}
>
<Icon
color="#3f4350"
name="close"
size={14}
/>
</View>
</View>
</View>
</View>
</RCTScrollView>
`;
exports[`components/integration_selector/selected_options should match snapshot for options 1`] = `
<RCTScrollView
style={
{
"flexGrow": 0,
"marginBottom": 5,
"marginLeft": 5,
"maxHeight": 100,
}
}
>
<View>
<View
style={
{
"alignItems": "flex-start",
"flexDirection": "row",
"flexWrap": "wrap",
}
}
>
<View
style={
{
"alignItems": "center",
"backgroundColor": "rgba(63,67,80,0.2)",
"borderRadius": 3,
"flexDirection": "row",
"height": 27,
"marginBottom": 4,
"marginRight": 10,
"paddingLeft": 10,
}
}
>
<Text
numberOfLines={1}
style={
{
"color": "#3f4350",
"fontFamily": "OpenSans",
"fontSize": 14,
"fontWeight": "400",
"lineHeight": 20,
"maxWidth": "90%",
}
}
>
my text
</Text>
<View
accessible={true}
collapsable={false}
focusable={true}
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderRelease={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
style={
{
"opacity": 1,
"paddingHorizontal": 10,
}
}
>
<Icon
color="#3f4350"
name="close"
size={14}
/>
</View>
</View>
</View>
</View>
</RCTScrollView>
`;
exports[`components/integration_selector/selected_options should match snapshot for users 1`] = `
<RCTScrollView
style={
{
"flexGrow": 0,
"marginBottom": 5,
"marginLeft": 5,
"maxHeight": 100,
}
}
>
<View>
<View
style={
{
"alignItems": "flex-start",
"flexDirection": "row",
"flexWrap": "wrap",
}
}
>
<View
style={
{
"alignItems": "center",
"backgroundColor": "rgba(63,67,80,0.2)",
"borderRadius": 3,
"flexDirection": "row",
"height": 27,
"marginBottom": 4,
"marginRight": 10,
"paddingLeft": 10,
}
}
>
<Text
numberOfLines={1}
style={
{
"color": "#3f4350",
"fontFamily": "OpenSans",
"fontSize": 14,
"fontWeight": "400",
"lineHeight": 20,
"maxWidth": "90%",
}
}
>
johndoe
</Text>
<View
accessible={true}
collapsable={false}
focusable={true}
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderRelease={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
style={
{
"opacity": 1,
"paddingHorizontal": 10,
}
}
>
<Icon
color="#3f4350"
name="close"
size={14}
/>
</View>
</View>
</View>
</View>
</RCTScrollView>
`;

View File

@@ -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(
<SelectedOptions
theme={Preferences.THEMES.denim}
selectedOptions={[userProfile]}
dataSource={ViewConstants.DATA_SOURCE_USERS}
onRemove={() => {
// 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(
<SelectedOptions
theme={Preferences.THEMES.denim}
selectedOptions={[channel]}
dataSource={ViewConstants.DATA_SOURCE_CHANNELS}
onRemove={() => {
// noop
}}
/>,
{database},
);
expect(wrapper.toJSON()).toMatchSnapshot();
});
it('should match snapshot for options', () => {
const myItem = {
value: '1',
text: 'my text',
};
const wrapper = renderWithEverything(
<SelectedOptions
theme={Preferences.THEMES.denim}
selectedOptions={[myItem]}
dataSource={ViewConstants.DATA_SOURCE_DYNAMIC}
onRemove={() => {
// noop
}}
/>,
{database},
);
expect(wrapper.toJSON()).toMatchSnapshot();
});
});

View File

@@ -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 (
<SelectedOption
key={key}
option={optionItem}
theme={theme}
dataSource={dataSource}
onRemove={onRemove}
/>);
});
// 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 (
<ScrollView
style={style.container}
contentContainerStyle={style.scrollViewContent}
>
<View style={style.users}>
{options}
</View>
</ScrollView>
);
};
export default SelectedOptions;

View File

@@ -142,3 +142,18 @@ export function compareNotifyProps(propsA: Partial<ChannelNotifyProps>, 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);
});
}

View File

@@ -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}.",