From 6d0f1651c60ecfd62e6d047f9aac4a1acdc5d28b Mon Sep 17 00:00:00 2001 From: Shubham Agrawal Date: Mon, 11 Mar 2024 17:07:03 +0530 Subject: [PATCH 1/4] Scroll highlight feature for workspace member page --- .../SelectionList/BaseSelectionList.tsx | 58 ++++++++++----- .../SelectionList/index.android.tsx | 5 +- src/components/SelectionList/index.ios.tsx | 5 +- src/components/SelectionList/index.tsx | 5 +- src/components/SelectionList/types.ts | 12 +++- .../workspace/WorkspaceInviteMessagePage.tsx | 1 - src/pages/workspace/WorkspaceMembersPage.tsx | 70 ++++++++++++++++--- 7 files changed, 119 insertions(+), 37 deletions(-) diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index bcf45fb2e2f4..c7ddeaad7030 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -1,6 +1,6 @@ import {useFocusEffect, useIsFocused} from '@react-navigation/native'; 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'; @@ -21,7 +21,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( { @@ -64,13 +64,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; @@ -78,6 +79,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 [itemToHighlight, setItemToHighlight] = useState | null>(null); + const itemFocusTimeoutRef = useRef(null); /** * Iterates through the sections and items inside each section, and builds 3 arrays along the way: @@ -203,16 +206,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(); } }; @@ -278,7 +281,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 || itemToHighlight?.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; @@ -338,10 +341,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 () => { @@ -365,6 +368,30 @@ function BaseSelectionList( updateAndScrollToFocusedIndex(newSelectedIndex); }, [canSelectMultiple, flattenedSections.allOptions.length, prevTextInputValue, textInputValue, updateAndScrollToFocusedIndex]); + 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); + setItemToHighlight(newItemsToHighlight); + + if (itemFocusTimeoutRef.current) { + clearTimeout(itemFocusTimeoutRef.current); + } + + itemFocusTimeoutRef.current = setTimeout(() => { + setFocusedIndex(-1); + setItemToHighlight(null); + }, timeout); + }, + [flattenedSections.allOptions, updateAndScrollToFocusedIndex], + ); + + useImperativeHandle(ref, () => ({scrollAndHighlightItem}), [scrollAndHighlightItem]); + /** Selects row when pressing Enter */ useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ENTER, selectFocusedOption, { captureOnInputs: true, @@ -394,15 +421,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 005a8ab21cc1..cbc93da10bd8 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 {Errors, Icon, PendingAction} from '@src/types/onyx/OnyxCommon'; import type {ReceiptErrors} from '@src/types/onyx/Transaction'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; @@ -276,6 +276,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 = { @@ -309,4 +316,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 2715a008c991..197cd5d4aef5 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'; @@ -52,6 +53,8 @@ type WorkspaceMembersPageOnyxProps = { 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 +73,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 +103,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 +302,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 +385,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 +585,7 @@ function WorkspaceMembersPage({policyMembers, personalDetails, route, policy, se /> @@ -568,6 +617,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, }, From abf13f6b54855ad6b1765c35aedec1d360a1f735 Mon Sep 17 00:00:00 2001 From: Shubham Agrawal Date: Mon, 11 Mar 2024 18:06:24 +0530 Subject: [PATCH 2/4] Removed timeout on unmount --- src/components/SelectionList/BaseSelectionList.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index c7ddeaad7030..dbec2aa8e37f 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -368,6 +368,16 @@ function BaseSelectionList( updateAndScrollToFocusedIndex(newSelectedIndex); }, [canSelectMultiple, flattenedSections.allOptions.length, prevTextInputValue, textInputValue, updateAndScrollToFocusedIndex]); + useEffect( + () => () => { + if (!itemFocusTimeoutRef.current) { + return; + } + clearTimeout(itemFocusTimeoutRef.current); + }, + [], + ); + const scrollAndHighlightItem = useCallback( (items: string[], timeout: number) => { const newItemsToHighlight = new Set(); From 1d8ff48daca06f9ee2b0718e9597956ead8d6d6a Mon Sep 17 00:00:00 2001 From: Shubham Agrawal Date: Mon, 11 Mar 2024 19:23:09 +0530 Subject: [PATCH 3/4] Added comments --- src/components/SelectionList/BaseSelectionList.tsx | 6 ++++++ src/pages/workspace/WorkspaceMembersPage.tsx | 2 ++ 2 files changed, 8 insertions(+) diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index dbec2aa8e37f..cf54c3d91ffc 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -378,6 +378,12 @@ function BaseSelectionList( [], ); + /** + * 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(); diff --git a/src/pages/workspace/WorkspaceMembersPage.tsx b/src/pages/workspace/WorkspaceMembersPage.tsx index 197cd5d4aef5..33975128af81 100644 --- a/src/pages/workspace/WorkspaceMembersPage.tsx +++ b/src/pages/workspace/WorkspaceMembersPage.tsx @@ -51,8 +51,10 @@ 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; }; From 4bee35b0b70d12a03c8c69e6b807f7dbbe9ff2ef Mon Sep 17 00:00:00 2001 From: Shubham Agrawal Date: Tue, 12 Mar 2024 07:30:53 +0530 Subject: [PATCH 4/4] lint fix --- src/components/SelectionList/BaseSelectionList.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index d451168c664f..8c352a4d084a 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -79,7 +79,7 @@ function BaseSelectionList( const isFocused = useIsFocused(); const [maxToRenderPerBatch, setMaxToRenderPerBatch] = useState(shouldUseDynamicMaxToRenderPerBatch ? 0 : CONST.MAX_TO_RENDER_PER_BATCH.DEFAULT); const [isInitialSectionListRender, setIsInitialSectionListRender] = useState(true); - const [itemToHighlight, setItemToHighlight] = useState | null>(null); + const [itemsToHighlight, setItemsToHighlight] = useState | null>(null); const itemFocusTimeoutRef = useRef(null); /** @@ -281,7 +281,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 || itemToHighlight?.has(item.keyForList)); + 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; @@ -390,9 +390,9 @@ function BaseSelectionList( items.forEach((item) => { newItemsToHighlight.add(item); }); - const index = flattenedSections.allOptions.findIndex((option) => newItemsToHighlight.has(option.keyForList)); + const index = flattenedSections.allOptions.findIndex((option) => newItemsToHighlight.has(option.keyForList ?? '')); updateAndScrollToFocusedIndex(index); - setItemToHighlight(newItemsToHighlight); + setItemsToHighlight(newItemsToHighlight); if (itemFocusTimeoutRef.current) { clearTimeout(itemFocusTimeoutRef.current); @@ -400,7 +400,7 @@ function BaseSelectionList( itemFocusTimeoutRef.current = setTimeout(() => { setFocusedIndex(-1); - setItemToHighlight(null); + setItemsToHighlight(null); }, timeout); }, [flattenedSections.allOptions, updateAndScrollToFocusedIndex],