Skip to content

Commit

Permalink
Merge pull request #38036 from shubham1206agra/scroll-highlight
Browse files Browse the repository at this point in the history
  • Loading branch information
luacmartins authored Mar 13, 2024
2 parents 65ad0a9 + f5bf3ce commit 5ec1325
Show file tree
Hide file tree
Showing 7 changed files with 137 additions and 37 deletions.
74 changes: 58 additions & 16 deletions src/components/SelectionList/BaseSelectionList.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<TItem extends ListItem>(
{
Expand Down Expand Up @@ -66,20 +66,23 @@ function BaseSelectionList<TItem extends ListItem>(
customListHeader,
listHeaderWrapperStyle,
isRowMultilineSupported = false,
textInputRef,
}: BaseSelectionListProps<TItem>,
inputRef: ForwardedRef<RNTextInput>,
ref: ForwardedRef<SelectionListHandle>,
) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const listRef = useRef<RNSectionList<TItem, Section<TItem>>>(null);
const textInputRef = useRef<RNTextInput | null>(null);
const innerTextInputRef = useRef<RNTextInput | null>(null);
const focusTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const shouldShowTextInput = !!textInputLabel;
const shouldShowSelectAll = !!onSelectAll;
const activeElementRole = useActiveElementRole();
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<Set<string> | null>(null);
const itemFocusTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const [currentPage, setCurrentPage] = useState(1);

const incrementPage = () => setCurrentPage((prev) => prev + 1);
Expand Down Expand Up @@ -235,16 +238,16 @@ function BaseSelectionList<TItem extends ListItem>(

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();
}
};

Expand Down Expand Up @@ -310,7 +313,7 @@ function BaseSelectionList<TItem extends ListItem>(
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;

Expand Down Expand Up @@ -370,10 +373,10 @@ function BaseSelectionList<TItem extends ListItem>(
useCallback(() => {
if (shouldShowTextInput) {
focusTimeoutRef.current = setTimeout(() => {
if (!textInputRef.current) {
if (!innerTextInputRef.current) {
return;
}
textInputRef.current.focus();
innerTextInputRef.current.focus();
}, CONST.ANIMATED_TRANSITION);
}
return () => {
Expand All @@ -399,6 +402,46 @@ function BaseSelectionList<TItem extends ListItem>(
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<string>();
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,
Expand Down Expand Up @@ -428,15 +471,14 @@ function BaseSelectionList<TItem extends ListItem>(
<View style={[styles.ph4, styles.pb3]}>
<TextInput
ref={(element) => {
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}
Expand Down
5 changes: 2 additions & 3 deletions src/components/SelectionList/index.android.tsx
Original file line number Diff line number Diff line change
@@ -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<TItem extends ListItem>(props: BaseSelectionListProps<TItem>, ref: ForwardedRef<TextInput>) {
function SelectionList<TItem extends ListItem>(props: BaseSelectionListProps<TItem>, ref: ForwardedRef<SelectionListHandle>) {
return (
<BaseSelectionList
// eslint-disable-next-line react/jsx-props-no-spreading
Expand Down
5 changes: 2 additions & 3 deletions src/components/SelectionList/index.ios.tsx
Original file line number Diff line number Diff line change
@@ -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<TItem extends ListItem>(props: BaseSelectionListProps<TItem>, ref: ForwardedRef<TextInput>) {
function SelectionList<TItem extends ListItem>(props: BaseSelectionListProps<TItem>, ref: ForwardedRef<SelectionListHandle>) {
return (
<BaseSelectionList<TItem>
// eslint-disable-next-line react/jsx-props-no-spreading
Expand Down
5 changes: 2 additions & 3 deletions src/components/SelectionList/index.tsx
Original file line number Diff line number Diff line change
@@ -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<TItem extends ListItem>(props: BaseSelectionListProps<TItem>, ref: ForwardedRef<TextInput>) {
function SelectionList<TItem extends ListItem>(props: BaseSelectionListProps<TItem>, ref: ForwardedRef<SelectionListHandle>) {
const [isScreenTouched, setIsScreenTouched] = useState(false);

const touchStart = () => setIsScreenTouched(true);
Expand Down
12 changes: 10 additions & 2 deletions src/components/SelectionList/types.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -284,6 +284,13 @@ type BaseSelectionListProps<TItem extends ListItem> = Partial<ChildrenProps> & {

/** Whether to wrap long text up to 2 lines */
isRowMultilineSupported?: boolean;

/** Ref for textInput */
textInputRef?: MutableRefObject<TextInput | null>;
};

type SelectionListHandle = {
scrollAndHighlightItem?: (items: string[], timeout: number) => void;
};

type ItemLayout = {
Expand Down Expand Up @@ -317,4 +324,5 @@ export type {
ItemLayout,
ButtonOrCheckBoxRoles,
SectionListDataType,
SelectionListHandle,
};
1 change: 0 additions & 1 deletion src/pages/workspace/WorkspaceInviteMessagePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
72 changes: 63 additions & 9 deletions src/pages/workspace/WorkspaceMembersPage.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand All @@ -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';
Expand All @@ -50,8 +51,12 @@ import withPolicyAndFullscreenLoading from './withPolicyAndFullscreenLoading';
type WorkspaceMembersPageOnyxProps = {
/** Personal details of all users */
personalDetails: OnyxEntry<PersonalDetailsList>;

/** Session info for the currently logged in user. */
session: OnyxEntry<Session>;

/** An object containing the accountID for every invited user email */
invitedEmailsToAccountIDsDraft: OnyxEntry<InvitedEmailsToAccountIDs>;
};

type WorkspaceMembersPageProps = WithPolicyAndFullscreenLoadingProps &
Expand All @@ -70,7 +75,16 @@ function invertObject(object: Record<string, string>): Record<string, string> {

type MemberOption = Omit<ListItem, 'accountID'> & {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<number[]>([]);
Expand All @@ -91,6 +105,8 @@ function WorkspaceMembersPage({policyMembers, personalDetails, route, policy, se
() => !isOfflineAndNoMemberDataAvailable && (!OptionsListUtils.isPersonalDetailsReady(personalDetails) || isEmptyObject(policyMembers)),
[isOfflineAndNoMemberDataAvailable, personalDetails, policyMembers],
);
const selectionListRef = useRef<SelectionListHandle>(null);
const isFocused = useIsFocused();

/**
* Get filtered personalDetails list with current policyMembers
Expand Down Expand Up @@ -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]) => {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -537,6 +587,7 @@ function WorkspaceMembersPage({policyMembers, personalDetails, route, policy, se
/>
<View style={[styles.w100, styles.flex1]}>
<SelectionList
ref={selectionListRef}
canSelectMultiple={isPolicyAdmin}
sections={[{data, indexOffset: 0, isDisabled: false}]}
ListItem={TableListItem}
Expand All @@ -550,7 +601,7 @@ function WorkspaceMembersPage({policyMembers, personalDetails, route, policy, se
showLoadingPlaceholder={isLoading}
showScrollIndicator
shouldPreventDefaultFocusOnSelectRow={!DeviceCapabilities.canUseTouchScreen()}
ref={textInputRef}
textInputRef={textInputRef}
customListHeader={getCustomListHeader()}
listHeaderWrapperStyle={[styles.ph9, styles.pv3, styles.pb5]}
/>
Expand All @@ -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,
},
Expand Down

0 comments on commit 5ec1325

Please sign in to comment.