New User List for Integration selector (#6806)

* WIP New User List for int selector

* user selection

* searchusers by term

* Remove old user list related code on selector

* Fix linting issues

* Submitting user list selection

* currentUserId

* multiselect showing after the bar

* useState instead of reducer

* Remoce empty line

* add callbacks

* Fix lint
This commit is contained in:
Javier Aguirre 2022-12-01 17:10:55 +01:00 committed by GitHub
parent 0e5d63a7c3
commit fc44e8f90b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 281 additions and 190 deletions

View file

@ -0,0 +1,150 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {fetchProfiles, searchProfiles} from '@actions/remote/user';
import UserList from '@components/user_list';
import {General} from '@constants';
import {useServerUrl} from '@context/server';
import {debounce} from '@helpers/api/general';
import {filterProfilesMatchingTerm} from '@utils/user';
type Props = {
currentTeamId: string;
currentUserId: string;
teammateNameDisplay: string;
tutorialWatched: boolean;
handleSelectProfile: (user: UserProfile) => void;
term: string;
}
function handleIdSelection(currentIds: {[id: string]: UserProfile}, user: UserProfile) {
const newSelectedIds = {...currentIds};
const wasSelected = currentIds[user.id];
if (wasSelected) {
Reflect.deleteProperty(newSelectedIds, user.id);
} else {
newSelectedIds[user.id] = user;
}
return newSelectedIds;
}
export default function ServerUserList({
currentTeamId,
currentUserId,
teammateNameDisplay,
tutorialWatched,
handleSelectProfile,
term,
}: Props) {
const serverUrl = useServerUrl();
const next = useRef(true);
const page = useRef(-1);
const mounted = useRef(false);
const [profiles, setProfiles] = useState<UserProfile[]>([]);
const [searchResults, setSearchResults] = useState<UserProfile[]>([]);
const [loading, setLoading] = useState(false);
const [selectedIds, setSelectedIds] = useState<{[id: string]: UserProfile}>({});
const selectedCount = Object.keys(selectedIds).length;
const isSearch = Boolean(term);
const loadedProfiles = ({users}: {users?: UserProfile[]}) => {
if (mounted.current) {
if (users && !users.length) {
next.current = false;
}
page.current += 1;
setLoading(false);
setProfiles((current) => {
if (users?.length) {
return [...current, ...users];
}
return current;
});
}
};
const getProfiles = useCallback(debounce(() => {
if (next.current && !loading && !term && mounted.current) {
setLoading(true);
fetchProfiles(serverUrl, page.current + 1, General.PROFILE_CHUNK_SIZE).then(loadedProfiles);
}
}, 100), [loading, isSearch, serverUrl, currentTeamId]);
const onHandleSelectProfile = useCallback((user: UserProfile) => {
handleSelectProfile(user);
setSelectedIds((current) => handleIdSelection(current, user));
}, [handleSelectProfile]);
const searchUsers = useCallback(async (searchTerm: string) => {
const lowerCasedTerm = searchTerm.toLowerCase();
setLoading(true);
const results = await searchProfiles(serverUrl, lowerCasedTerm, {allow_inactive: true});
let data: UserProfile[] = [];
if (results.data) {
data = results.data;
}
setSearchResults(data);
setLoading(false);
}, [serverUrl, currentTeamId]);
useEffect(() => {
searchUsers(term);
}, [term]);
useEffect(() => {
mounted.current = true;
getProfiles();
return () => {
mounted.current = false;
};
}, []);
const data = useMemo(() => {
if (term) {
const exactMatches: UserProfile[] = [];
const filterByTerm = (p: UserProfile) => {
if (selectedCount > 0 && p.id === currentUserId) {
return false;
}
if (p.username === term || p.username.startsWith(term)) {
exactMatches.push(p);
return false;
}
return true;
};
const results = filterProfilesMatchingTerm(searchResults, term).filter(filterByTerm);
return [...exactMatches, ...results];
}
return profiles;
}, [term, isSearch && selectedCount, isSearch && searchResults, profiles]);
return (
<UserList
currentUserId={currentUserId}
handleSelectProfile={onHandleSelectProfile}
loading={loading}
profiles={data}
selectedIds={selectedIds}
showNoResults={!loading && page.current !== -1}
teammateNameDisplay={teammateNameDisplay}
fetchMore={getProfiles}
term={term}
testID='create_direct_message.user_list'
tutorialWatched={tutorialWatched}
/>
);
}

View file

@ -9,7 +9,7 @@ import {Preferences} from '@constants';
import {renderWithEverything} from '@test/intl-test-helper';
import TestHelper from '@test/test_helper';
import CustomList, {FLATLIST} from '.';
import CustomList from '.';
describe('components/integration_selector/custom_list', () => {
let database: Database;
@ -42,7 +42,6 @@ describe('components/integration_selector/custom_list', () => {
<CustomList
data={[channel]}
key='custom_list'
listType={FLATLIST}
loading={false}
theme={Preferences.THEMES.denim}
testID='ChannelListRow'

View file

@ -2,24 +2,18 @@
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {
Text, Platform, FlatList, RefreshControl, View, SectionList,
Platform, FlatList, RefreshControl, View,
} from 'react-native';
import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme';
import {typography} from '@utils/typography';
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 DataType = DialogOption[] | Channel[];
type ListItemProps = {
id: string;
item: DialogOption | Channel | UserProfile;
item: DialogOption | Channel;
selected: boolean;
selectable?: boolean;
enabled: boolean;
@ -29,14 +23,13 @@ type ListItemProps = {
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;
onRowPress: (item: Channel | DialogOption) => void;
renderItem: (props: ListItemProps) => JSX.Element;
selectable?: boolean;
theme?: object;
@ -111,7 +104,7 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
});
function CustomList({
data, shouldRenderSeparator, listType, loading, loadingComponent, noResults,
data, shouldRenderSeparator, loading, loadingComponent, noResults,
onLoadMore, onRowPress, selectable, renderItem, theme,
canRefresh = true, testID, refreshing = false, onRefresh,
}: Props) {
@ -156,76 +149,35 @@ function CustomList({
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();
let refreshControl;
if (canRefresh) {
refreshControl = (
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
/>);
}
return renderSectionList();
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}
/>
);
}
export default CustomList;

View file

@ -3,13 +3,14 @@
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import {observeCurrentTeamId} from '@queries/servers/system';
import {observeCurrentTeamId, observeCurrentUserId} from '@queries/servers/system';
import IntegrationSelector from './integration_selector';
import type {WithDatabaseArgs} from '@typings/database/database';
const withTeamId = withObservables([], ({database}: WithDatabaseArgs) => ({
currentUserId: observeCurrentUserId(database),
currentTeamId: observeCurrentTeamId(database),
}));

View file

@ -7,12 +7,10 @@ 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 ServerUserList from '@app/components/server_user_list';
import {t} from '@app/i18n';
import FormattedText from '@components/formatted_text';
import SearchBar from '@components/search';
import {createProfilesSections} from '@components/user_list';
import UserListRow from '@components/user_list_row';
import {General, View as ViewConstants} from '@constants';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
@ -25,20 +23,16 @@ import {
import {filterChannelsMatchingTerm} from '@utils/channel';
import {changeOpacity, getKeyboardAppearanceFromTheme, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import {filterProfilesMatchingTerm} from '@utils/user';
import ChannelListRow from './channel_list_row';
import CustomList, {FLATLIST, SECTIONLIST} from './custom_list';
import CustomList from './custom_list';
import OptionListRow from './option_list_row';
import SelectedOptions from './selected_options';
type DataType = DialogOption[] | Channel[] | UserProfile[];
type Selection = DialogOption | Channel | UserProfile | DataType;
type DataType = DialogOption | Channel | UserProfile;
type DataTypeList = DialogOption[] | Channel[] | UserProfile[];
type Selection = DataType | DataTypeList;
type MultiselectSelectedMap = Dictionary<DialogOption> | Dictionary<Channel> | Dictionary<UserProfile>;
type UserProfileSection = {
id: string;
data: UserProfile[];
};
const VALID_DATASOURCES = [
ViewConstants.DATA_SOURCE_CHANNELS,
@ -50,7 +44,7 @@ const close = () => {
popTopScreen();
};
const extractItemKey = (dataSource: string, item: Selection): string => {
const extractItemKey = (dataSource: string, item: DataType): string => {
switch (dataSource) {
case ViewConstants.DATA_SOURCE_USERS: {
const typedItem = item as UserProfile;
@ -79,16 +73,14 @@ const toggleFromMap = <T extends DialogOption | Channel | UserProfile>(current:
return newMap;
};
const filterSearchData = (source: string, searchData: DataType, searchTerm: string) => {
const filterSearchData = (source: string, searchData: DataTypeList, 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) {
if (source === ViewConstants.DATA_SOURCE_CHANNELS) {
return filterChannelsMatchingTerm(searchData as Channel[], lowerCasedTerm);
} else if (source === ViewConstants.DATA_SOURCE_DYNAMIC) {
return searchData;
@ -97,11 +89,26 @@ const filterSearchData = (source: string, searchData: DataType, searchTerm: stri
return (searchData as DialogOption[]).filter((option) => option.text && option.text.includes(lowerCasedTerm));
};
const handleIdSelection = (dataSource: string, currentIds: {[id: string]: DataType}, item: DataType) => {
const newSelectedIds = {...currentIds};
const key = extractItemKey(dataSource, item);
const wasSelected = currentIds[key];
if (wasSelected) {
Reflect.deleteProperty(newSelectedIds, key);
} else {
newSelectedIds[key] = item;
}
return newSelectedIds;
};
export type Props = {
getDynamicOptions?: (userInput?: string) => Promise<DialogOption[]>;
options?: PostActionOption[];
currentTeamId: string;
data?: DataType;
currentUserId: string;
data?: DataTypeList;
dataSource: string;
handleSelect: (opt: Selection) => void;
isMultiselect?: boolean;
@ -154,7 +161,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
function IntegrationSelector(
{dataSource, data, isMultiselect = false, selected, handleSelect,
currentTeamId, componentId, getDynamicOptions, options, teammateNameDisplay}: Props) {
currentTeamId, currentUserId, componentId, getDynamicOptions, options, teammateNameDisplay}: Props) {
const serverUrl = useServerUrl();
const theme = useTheme();
const searchTimeoutId = useRef<NodeJS.Timeout | null>(null);
@ -162,12 +169,17 @@ function IntegrationSelector(
const intl = useIntl();
// HOOKS
const [integrationData, setIntegrationData] = useState<DataType>(data || []);
const [integrationData, setIntegrationData] = useState<DataTypeList>(data || []);
const [loading, setLoading] = useState<boolean>(false);
const [term, setTerm] = useState<string>('');
const [searchResults, setSearchResults] = useState<DataType>([]);
const [searchResults, setSearchResults] = useState<DataTypeList>([]);
// Channels and DialogOptions, will be removed
const [multiselectSelected, setMultiselectSelected] = useState<MultiselectSelectedMap>({});
const [customListData, setCustomListData] = useState<DataType | UserProfileSection[]>([]);
// Users selection and in the future Channels and DialogOptions
const [selectedIds, setSelectedIds] = useState<{[id: string]: DataType}>({});
const [customListData, setCustomListData] = useState<DataTypeList>([]);
const page = useRef<number>(-1);
const next = useRef<boolean>(VALID_DATASOURCES.includes(dataSource));
@ -199,24 +211,20 @@ function IntegrationSelector(
return;
}
const itemKey = extractItemKey(dataSource, item);
switch (dataSource) {
case ViewConstants.DATA_SOURCE_USERS: {
setMultiselectSelected((current) => toggleFromMap(current, itemKey, item as UserProfile));
return;
}
case ViewConstants.DATA_SOURCE_CHANNELS: {
const itemKey = extractItemKey(dataSource, item as Channel);
setMultiselectSelected((current) => toggleFromMap(current, itemKey, item as Channel));
return;
}
default: {
const itemKey = extractItemKey(dataSource, item as DialogOption);
setMultiselectSelected((current) => toggleFromMap(current, itemKey, item as DialogOption));
}
}
}, [isMultiselect, dataSource, handleSelect]);
const handleRemoveOption = useCallback((item: UserProfile | Channel | DialogOption) => {
const handleRemoveOption = useCallback((item: Channel | DialogOption) => {
const itemKey = extractItemKey(dataSource, item);
setMultiselectSelected((current) => {
const multiselectSelectedItems = {...current};
@ -242,32 +250,13 @@ function IntegrationSelector(
}
}, 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) {
if (dataSource === ViewConstants.DATA_SOURCE_CHANNELS) {
await getChannels();
}
// dynamic options are not paged so are not reloaded on scroll
}, [getProfiles, getChannels, dataSource]);
}, [getChannels, dataSource]);
const searchDynamicOptions = useCallback(async (searchTerm = '') => {
if (options && options !== integrationData && !searchTerm) {
@ -288,10 +277,25 @@ function IntegrationSelector(
}
}, [options, getDynamicOptions, integrationData]);
const handleSelectProfile = useCallback((user: UserProfile): void => {
if (!isMultiselect) {
handleSelect(user);
close();
}
setSelectedIds((current) => handleIdSelection(dataSource, current, user));
}, [isMultiselect, handleIdSelection, handleSelect, close, dataSource]);
const onHandleMultiselectSubmit = useCallback(() => {
handleSelect(Object.values(multiselectSelected));
if (dataSource === ViewConstants.DATA_SOURCE_USERS) {
// New multiselect
handleSelect(Object.values(selectedIds) as UserProfile[]);
} else {
// Legacy multiselect
handleSelect(Object.values(multiselectSelected));
}
close();
}, [multiselectSelected, handleSelect]);
}, [multiselectSelected, selectedIds, handleSelect]);
const onSearch = useCallback((text: string) => {
if (!text) {
@ -313,15 +317,7 @@ function IntegrationSelector(
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) {
if (dataSource === ViewConstants.DATA_SOURCE_CHANNELS) {
const isSearch = true;
const {channels: receivedChannels} = await searchChannels(
serverUrl, text, currentTeamId, isSearch);
@ -350,9 +346,7 @@ function IntegrationSelector(
}, []);
useEffect(() => {
if (dataSource === ViewConstants.DATA_SOURCE_USERS) {
getProfiles();
} else if (dataSource === ViewConstants.DATA_SOURCE_CHANNELS) {
if (dataSource === ViewConstants.DATA_SOURCE_CHANNELS) {
getChannels();
} else {
// Static and dynamic option search
@ -361,16 +355,12 @@ function IntegrationSelector(
}, []);
useEffect(() => {
let listData: (DataType | UserProfileSection[]) = integrationData;
let listData: DataTypeList = 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));
}
@ -411,12 +401,6 @@ function IntegrationSelector(
let text;
switch (dataSource) {
case ViewConstants.DATA_SOURCE_USERS:
text = {
id: t('mobile.integration_selector.loading_users'),
defaultMessage: 'Loading Users...',
};
break;
case ViewConstants.DATA_SOURCE_CHANNELS:
text = {
id: t('mobile.integration_selector.loading_channels'),
@ -485,26 +469,8 @@ function IntegrationSelector(
);
}, [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:
@ -513,7 +479,12 @@ function IntegrationSelector(
};
const renderSelectedOptions = useCallback((): React.ReactElement<string> | null => {
const selectedItems: Channel[] | DialogOption[] | UserProfile[] = Object.values(multiselectSelected);
let selectedItems: Channel[] | DialogOption[] | UserProfile[] = Object.values(multiselectSelected);
if (dataSource === ViewConstants.DATA_SOURCE_USERS) {
// New multiselect
selectedItems = Object.values(selectedIds) as UserProfile[];
}
if (!selectedItems.length) {
return null;
@ -530,9 +501,38 @@ function IntegrationSelector(
<View style={style.separator}/>
</>
);
}, [multiselectSelected, style, theme]);
}, [multiselectSelected, selectedIds, style, theme]);
const renderDataTypeList = () => {
switch (dataSource) {
case ViewConstants.DATA_SOURCE_USERS:
return (
<ServerUserList
currentTeamId={currentTeamId}
currentUserId={currentUserId}
teammateNameDisplay={teammateNameDisplay}
term={term}
tutorialWatched={true}
handleSelectProfile={handleSelectProfile}
/>
);
default:
return (
<CustomList
data={customListData as (Channel[] | DialogOption[])}
key='custom_list'
loading={loading}
loadingComponent={renderLoading()}
noResults={renderNoResults}
onLoadMore={loadMore}
onRowPress={handleSelectItem}
renderItem={getRenderItem()}
theme={theme}
/>
);
}
};
const listType = dataSource === ViewConstants.DATA_SOURCE_USERS ? SECTIONLIST : FLATLIST;
const selectedOptionsComponent = renderSelectedOptions();
return (
@ -555,18 +555,7 @@ function IntegrationSelector(
{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}
/>
{renderDataTypeList()}
</SafeAreaView>
);
}