From fc44e8f90b2215b93c590a79d75e48ef6fae2c59 Mon Sep 17 00:00:00 2001 From: Javier Aguirre Date: Thu, 1 Dec 2022 17:10:55 +0100 Subject: [PATCH] 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 --- app/components/server_user_list/index.tsx | 150 +++++++++++++ .../custom_list/index.test.tsx | 3 +- .../custom_list/index.tsx | 112 +++------- app/screens/integration_selector/index.ts | 3 +- .../integration_selector.tsx | 203 +++++++++--------- 5 files changed, 281 insertions(+), 190 deletions(-) create mode 100644 app/components/server_user_list/index.tsx diff --git a/app/components/server_user_list/index.tsx b/app/components/server_user_list/index.tsx new file mode 100644 index 000000000..e2ddc1418 --- /dev/null +++ b/app/components/server_user_list/index.tsx @@ -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([]); + const [searchResults, setSearchResults] = useState([]); + 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 ( + + ); +} diff --git a/app/screens/integration_selector/custom_list/index.test.tsx b/app/screens/integration_selector/custom_list/index.test.tsx index 6542e89a1..66cd540c0 100644 --- a/app/screens/integration_selector/custom_list/index.test.tsx +++ b/app/screens/integration_selector/custom_list/index.test.tsx @@ -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', () => { | 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 ( - - - {section.id} - - - ); - }, [style]); - - const renderSectionList = () => { - return ( - - ); - }; - - const renderFlatList = () => { - let refreshControl; - if (canRefresh) { - refreshControl = ( - ); - } - - return ( - - ); - }; - - if (listType === FLATLIST) { - return renderFlatList(); + let refreshControl; + if (canRefresh) { + refreshControl = ( + ); } - return renderSectionList(); + return ( + + ); } export default CustomList; diff --git a/app/screens/integration_selector/index.ts b/app/screens/integration_selector/index.ts index a0b2dcee8..5c5006b78 100644 --- a/app/screens/integration_selector/index.ts +++ b/app/screens/integration_selector/index.ts @@ -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), })); diff --git a/app/screens/integration_selector/integration_selector.tsx b/app/screens/integration_selector/integration_selector.tsx index b7d895c04..7275ee271 100644 --- a/app/screens/integration_selector/integration_selector.tsx +++ b/app/screens/integration_selector/integration_selector.tsx @@ -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 | Dictionary | Dictionary; -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 = (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; 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(null); @@ -162,12 +169,17 @@ function IntegrationSelector( const intl = useIntl(); // HOOKS - const [integrationData, setIntegrationData] = useState(data || []); + const [integrationData, setIntegrationData] = useState(data || []); const [loading, setLoading] = useState(false); const [term, setTerm] = useState(''); - const [searchResults, setSearchResults] = useState([]); + const [searchResults, setSearchResults] = useState([]); + + // Channels and DialogOptions, will be removed const [multiselectSelected, setMultiselectSelected] = useState({}); - const [customListData, setCustomListData] = useState([]); + + // Users selection and in the future Channels and DialogOptions + const [selectedIds, setSelectedIds] = useState<{[id: string]: DataType}>({}); + const [customListData, setCustomListData] = useState([]); const page = useRef(-1); const next = useRef(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 ( - - ); - }, [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 | 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( ); - }, [multiselectSelected, style, theme]); + }, [multiselectSelected, selectedIds, style, theme]); + + const renderDataTypeList = () => { + switch (dataSource) { + case ViewConstants.DATA_SOURCE_USERS: + return ( + + ); + default: + return ( + + ); + } + }; - const listType = dataSource === ViewConstants.DATA_SOURCE_USERS ? SECTIONLIST : FLATLIST; const selectedOptionsComponent = renderSelectedOptions(); return ( @@ -555,18 +555,7 @@ function IntegrationSelector( {selectedOptionsComponent} - + {renderDataTypeList()} ); }