diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index 9dc3e4aa2c7e..db6204c8c1ef 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -1,7 +1,7 @@ import {useFocusEffect, useIsFocused} from '@react-navigation/native'; import isEmpty from 'lodash/isEmpty'; import type {ForwardedRef} from 'react'; -import React, {forwardRef, useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; import type {LayoutChangeEvent, SectionList as RNSectionList, TextInput as RNTextInput, SectionListRenderItemInfo} from 'react-native'; import {View} from 'react-native'; import ArrowKeyFocusManager from '@components/ArrowKeyFocusManager'; @@ -23,7 +23,7 @@ import Log from '@libs/Log'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import type {BaseSelectionListProps, ButtonOrCheckBoxRoles, FlattenedSectionsReturn, ListItem, Section, SectionListDataType} from './types'; +import type {BaseSelectionListProps, ButtonOrCheckBoxRoles, FlattenedSectionsReturn, ListItem, Section, SectionListDataType, SelectionListHandle} from './types'; function BaseSelectionList( { @@ -66,13 +66,14 @@ function BaseSelectionList( customListHeader, listHeaderWrapperStyle, isRowMultilineSupported = false, + textInputRef, }: BaseSelectionListProps, - inputRef: ForwardedRef, + ref: ForwardedRef, ) { const styles = useThemeStyles(); const {translate} = useLocalize(); const listRef = useRef>>(null); - const textInputRef = useRef(null); + const innerTextInputRef = useRef(null); const focusTimeoutRef = useRef(null); const shouldShowTextInput = !!textInputLabel; const shouldShowSelectAll = !!onSelectAll; @@ -80,6 +81,8 @@ function BaseSelectionList( const isFocused = useIsFocused(); const [maxToRenderPerBatch, setMaxToRenderPerBatch] = useState(shouldUseDynamicMaxToRenderPerBatch ? 0 : CONST.MAX_TO_RENDER_PER_BATCH.DEFAULT); const [isInitialSectionListRender, setIsInitialSectionListRender] = useState(true); + const [itemsToHighlight, setItemsToHighlight] = useState | null>(null); + const itemFocusTimeoutRef = useRef(null); const [currentPage, setCurrentPage] = useState(1); const incrementPage = () => setCurrentPage((prev) => prev + 1); @@ -235,16 +238,16 @@ function BaseSelectionList( onSelectRow(item); - if (shouldShowTextInput && shouldPreventDefaultFocusOnSelectRow && textInputRef.current) { - textInputRef.current.focus(); + if (shouldShowTextInput && shouldPreventDefaultFocusOnSelectRow && innerTextInputRef.current) { + innerTextInputRef.current.focus(); } }; const selectAllRow = () => { onSelectAll?.(); - if (shouldShowTextInput && shouldPreventDefaultFocusOnSelectRow && textInputRef.current) { - textInputRef.current.focus(); + if (shouldShowTextInput && shouldPreventDefaultFocusOnSelectRow && innerTextInputRef.current) { + innerTextInputRef.current.focus(); } }; @@ -310,7 +313,7 @@ function BaseSelectionList( const indexOffset = section.indexOffset ? section.indexOffset : 0; const normalizedIndex = index + indexOffset; const isDisabled = !!section.isDisabled || item.isDisabled; - const isItemFocused = !isDisabled && focusedIndex === normalizedIndex; + const isItemFocused = !isDisabled && (focusedIndex === normalizedIndex || itemsToHighlight?.has(item.keyForList ?? '')); // We only create tooltips for the first 10 users or so since some reports have hundreds of users, causing performance to degrade. const showTooltip = shouldShowTooltips && normalizedIndex < 10; @@ -370,10 +373,10 @@ function BaseSelectionList( useCallback(() => { if (shouldShowTextInput) { focusTimeoutRef.current = setTimeout(() => { - if (!textInputRef.current) { + if (!innerTextInputRef.current) { return; } - textInputRef.current.focus(); + innerTextInputRef.current.focus(); }, CONST.ANIMATED_TRANSITION); } return () => { @@ -399,6 +402,46 @@ function BaseSelectionList( updateAndScrollToFocusedIndex(newSelectedIndex); }, [canSelectMultiple, flattenedSections.allOptions.length, prevTextInputValue, textInputValue, updateAndScrollToFocusedIndex]); + useEffect( + () => () => { + if (!itemFocusTimeoutRef.current) { + return; + } + clearTimeout(itemFocusTimeoutRef.current); + }, + [], + ); + + /** + * Highlights the items and scrolls to the first item present in the items list. + * + * @param items - The list of items to highlight. + * @param timeout - The timeout in milliseconds before removing the highlight. + */ + const scrollAndHighlightItem = useCallback( + (items: string[], timeout: number) => { + const newItemsToHighlight = new Set(); + items.forEach((item) => { + newItemsToHighlight.add(item); + }); + const index = flattenedSections.allOptions.findIndex((option) => newItemsToHighlight.has(option.keyForList ?? '')); + updateAndScrollToFocusedIndex(index); + setItemsToHighlight(newItemsToHighlight); + + if (itemFocusTimeoutRef.current) { + clearTimeout(itemFocusTimeoutRef.current); + } + + itemFocusTimeoutRef.current = setTimeout(() => { + setFocusedIndex(-1); + setItemsToHighlight(null); + }, timeout); + }, + [flattenedSections.allOptions, updateAndScrollToFocusedIndex], + ); + + useImperativeHandle(ref, () => ({scrollAndHighlightItem}), [scrollAndHighlightItem]); + /** Selects row when pressing Enter */ useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ENTER, selectFocusedOption, { captureOnInputs: true, @@ -428,15 +471,14 @@ function BaseSelectionList( { - textInputRef.current = element as RNTextInput; + innerTextInputRef.current = element as RNTextInput; - if (!inputRef) { + if (!textInputRef) { return; } - if (typeof inputRef === 'function') { - inputRef(element as RNTextInput); - } + // eslint-disable-next-line no-param-reassign + textInputRef.current = element as RNTextInput; }} label={textInputLabel} accessibilityLabel={textInputLabel} diff --git a/src/components/SelectionList/index.android.tsx b/src/components/SelectionList/index.android.tsx index 46f2af8356f6..f8e54b219f5b 100644 --- a/src/components/SelectionList/index.android.tsx +++ b/src/components/SelectionList/index.android.tsx @@ -1,11 +1,10 @@ import React, {forwardRef} from 'react'; import type {ForwardedRef} from 'react'; import {Keyboard} from 'react-native'; -import type {TextInput} from 'react-native'; import BaseSelectionList from './BaseSelectionList'; -import type {BaseSelectionListProps, ListItem} from './types'; +import type {BaseSelectionListProps, ListItem, SelectionListHandle} from './types'; -function SelectionList(props: BaseSelectionListProps, ref: ForwardedRef) { +function SelectionList(props: BaseSelectionListProps, ref: ForwardedRef) { return ( (props: BaseSelectionListProps, ref: ForwardedRef) { +function SelectionList(props: BaseSelectionListProps, ref: ForwardedRef) { return ( // eslint-disable-next-line react/jsx-props-no-spreading diff --git a/src/components/SelectionList/index.tsx b/src/components/SelectionList/index.tsx index 2446e1b4f5c1..a6fd636cc215 100644 --- a/src/components/SelectionList/index.tsx +++ b/src/components/SelectionList/index.tsx @@ -1,12 +1,11 @@ import React, {forwardRef, useEffect, useState} from 'react'; import type {ForwardedRef} from 'react'; import {Keyboard} from 'react-native'; -import type {TextInput} from 'react-native'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import BaseSelectionList from './BaseSelectionList'; -import type {BaseSelectionListProps, ListItem} from './types'; +import type {BaseSelectionListProps, ListItem, SelectionListHandle} from './types'; -function SelectionList(props: BaseSelectionListProps, ref: ForwardedRef) { +function SelectionList(props: BaseSelectionListProps, ref: ForwardedRef) { const [isScreenTouched, setIsScreenTouched] = useState(false); const touchStart = () => setIsScreenTouched(true); diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts index a9cd3dacc1a7..152a44996fea 100644 --- a/src/components/SelectionList/types.ts +++ b/src/components/SelectionList/types.ts @@ -1,5 +1,5 @@ -import type {ReactElement, ReactNode} from 'react'; -import type {GestureResponderEvent, InputModeOptions, LayoutChangeEvent, SectionListData, StyleProp, TextStyle, ViewStyle} from 'react-native'; +import type {MutableRefObject, ReactElement, ReactNode} from 'react'; +import type {GestureResponderEvent, InputModeOptions, LayoutChangeEvent, SectionListData, StyleProp, TextInput, TextStyle, ViewStyle} from 'react-native'; import type {MaybePhraseKey} from '@libs/Localize'; import type CONST from '@src/CONST'; import type {Errors, Icon, PendingAction} from '@src/types/onyx/OnyxCommon'; @@ -284,6 +284,13 @@ type BaseSelectionListProps = Partial & { /** Whether to wrap long text up to 2 lines */ isRowMultilineSupported?: boolean; + + /** Ref for textInput */ + textInputRef?: MutableRefObject; +}; + +type SelectionListHandle = { + scrollAndHighlightItem?: (items: string[], timeout: number) => void; }; type ItemLayout = { @@ -317,4 +324,5 @@ export type { ItemLayout, ButtonOrCheckBoxRoles, SectionListDataType, + SelectionListHandle, }; diff --git a/src/pages/workspace/WorkspaceInviteMessagePage.tsx b/src/pages/workspace/WorkspaceInviteMessagePage.tsx index 72f08095b58a..df1d3cd63011 100644 --- a/src/pages/workspace/WorkspaceInviteMessagePage.tsx +++ b/src/pages/workspace/WorkspaceInviteMessagePage.tsx @@ -85,7 +85,6 @@ function WorkspaceInviteMessagePage({workspaceInviteMessageDraft, invitedEmailsT Keyboard.dismiss(); // Please see https://github.com/Expensify/App/blob/main/README.md#Security for more details Policy.addMembersToWorkspace(invitedEmailsToAccountIDsDraft ?? {}, welcomeNote ?? '', route.params.policyID); - Policy.setWorkspaceInviteMembersDraft(route.params.policyID, {}); SearchInputManager.searchInput = ''; // Pop the invite message page before navigating to the members page. Navigation.goBack(); diff --git a/src/pages/workspace/WorkspaceMembersPage.tsx b/src/pages/workspace/WorkspaceMembersPage.tsx index 818edc9c389e..20be7913e31e 100644 --- a/src/pages/workspace/WorkspaceMembersPage.tsx +++ b/src/pages/workspace/WorkspaceMembersPage.tsx @@ -1,3 +1,4 @@ +import {useIsFocused} from '@react-navigation/native'; import type {StackScreenProps} from '@react-navigation/stack'; import lodashIsEqual from 'lodash/isEqual'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; @@ -18,7 +19,7 @@ import MessagesRow from '@components/MessagesRow'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; import TableListItem from '@components/SelectionList/TableListItem'; -import type {ListItem} from '@components/SelectionList/types'; +import type {ListItem, SelectionListHandle} from '@components/SelectionList/types'; import Text from '@components/Text'; import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails'; import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; @@ -41,7 +42,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; -import type {PersonalDetailsList, PolicyMember, PolicyMembers, Session} from '@src/types/onyx'; +import type {InvitedEmailsToAccountIDs, PersonalDetailsList, PolicyMember, PolicyMembers, Session} from '@src/types/onyx'; import type {Errors, PendingAction} from '@src/types/onyx/OnyxCommon'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {WithPolicyAndFullscreenLoadingProps} from './withPolicyAndFullscreenLoading'; @@ -50,8 +51,12 @@ import withPolicyAndFullscreenLoading from './withPolicyAndFullscreenLoading'; type WorkspaceMembersPageOnyxProps = { /** Personal details of all users */ personalDetails: OnyxEntry; + /** Session info for the currently logged in user. */ session: OnyxEntry; + + /** An object containing the accountID for every invited user email */ + invitedEmailsToAccountIDsDraft: OnyxEntry; }; type WorkspaceMembersPageProps = WithPolicyAndFullscreenLoadingProps & @@ -70,7 +75,16 @@ function invertObject(object: Record): Record { type MemberOption = Omit & {accountID: number}; -function WorkspaceMembersPage({policyMembers, personalDetails, route, policy, session, currentUserPersonalDetails, isLoadingReportData = true}: WorkspaceMembersPageProps) { +function WorkspaceMembersPage({ + policyMembers, + personalDetails, + invitedEmailsToAccountIDsDraft, + route, + policy, + session, + currentUserPersonalDetails, + isLoadingReportData = true, +}: WorkspaceMembersPageProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const [selectedEmployees, setSelectedEmployees] = useState([]); @@ -91,6 +105,8 @@ function WorkspaceMembersPage({policyMembers, personalDetails, route, policy, se () => !isOfflineAndNoMemberDataAvailable && (!OptionsListUtils.isPersonalDetailsReady(personalDetails) || isEmptyObject(policyMembers)), [isOfflineAndNoMemberDataAvailable, personalDetails, policyMembers], ); + const selectionListRef = useRef(null); + const isFocused = useIsFocused(); /** * Get filtered personalDetails list with current policyMembers @@ -288,15 +304,17 @@ function WorkspaceMembersPage({policyMembers, personalDetails, route, policy, se /** * Check if the policy member is deleted from the workspace */ - const isDeletedPolicyMember = (policyMember: PolicyMember): boolean => - !isOffline && policyMember.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && isEmptyObject(policyMember.errors); + const isDeletedPolicyMember = useCallback( + (policyMember: PolicyMember): boolean => !isOffline && policyMember.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && isEmptyObject(policyMember.errors), + [isOffline], + ); const policyOwner = policy?.owner; const currentUserLogin = currentUserPersonalDetails.login; const policyID = route.params.policyID; const invitedPrimaryToSecondaryLogins = invertObject(policy?.primaryLoginsInvited ?? {}); - const getUsers = (): MemberOption[] => { + const getUsers = useCallback((): MemberOption[] => { let result: MemberOption[] = []; Object.entries(policyMembers ?? {}).forEach(([accountIDKey, policyMember]) => { @@ -369,8 +387,40 @@ function WorkspaceMembersPage({policyMembers, personalDetails, route, policy, se result = result.sort((a, b) => (a.text ?? '').toLowerCase().localeCompare((b.text ?? '').toLowerCase())); return result; - }; - const data = getUsers(); + }, [ + StyleUtils, + currentUserLogin, + formatPhoneNumber, + invitedPrimaryToSecondaryLogins, + isDeletedPolicyMember, + isPolicyAdmin, + personalDetails, + policy?.owner, + policy?.ownerAccountID, + policyMembers, + policyOwner, + selectedEmployees, + session?.accountID, + styles.activeItemBadge, + styles.badgeBordered, + styles.justifyContentCenter, + styles.textStrong, + translate, + ]); + + const data = useMemo(() => getUsers(), [getUsers]); + + useEffect(() => { + if (!isFocused) { + return; + } + if (isEmptyObject(invitedEmailsToAccountIDsDraft) || accountIDs === prevAccountIDs) { + return; + } + const invitedEmails = Object.values(invitedEmailsToAccountIDsDraft).map(String); + selectionListRef.current?.scrollAndHighlightItem?.(invitedEmails, 1500); + Policy.setWorkspaceInviteMembersDraft(route.params.policyID, {}); + }, [invitedEmailsToAccountIDsDraft, route.params.policyID, isFocused, accountIDs, prevAccountIDs]); const getHeaderMessage = () => { if (isOfflineAndNoMemberDataAvailable) { @@ -537,6 +587,7 @@ function WorkspaceMembersPage({policyMembers, personalDetails, route, policy, se /> @@ -568,6 +619,9 @@ export default withCurrentUserPersonalDetails( personalDetails: { key: ONYXKEYS.PERSONAL_DETAILS_LIST, }, + invitedEmailsToAccountIDsDraft: { + key: ({route}) => `${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MEMBERS_DRAFT}${route.params.policyID.toString()}`, + }, session: { key: ONYXKEYS.SESSION, },