Skip to content

Migrate Reports page to use it's own FlatList #57549

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/components/Search/SearchAutocompleteList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -532,6 +532,7 @@ function SearchAutocompleteList(
initiallyFocusedOptionKey={!shouldUseNarrowLayout ? styledRecentReports.at(0)?.keyForList : undefined}
shouldScrollToFocusedIndex={!isInitialRender}
shouldSubscribeToArrowKeyEvents={shouldSubscribeToArrowKeyEvents}
disableKeyboardShortcuts={!shouldSubscribeToArrowKeyEvents}
/>
);
}
Expand Down
350 changes: 350 additions & 0 deletions src/components/Search/SearchList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,350 @@
import {useIsFocused} from '@react-navigation/native';
import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react';
import type {ForwardedRef} from 'react';
import {View} from 'react-native';
import type {FlatList, ListRenderItemInfo, NativeSyntheticEvent, StyleProp, ViewStyle} from 'react-native';
import Animated from 'react-native-reanimated';
import type {FlatListPropsWithLayout} from 'react-native-reanimated';
import Checkbox from '@components/Checkbox';
import * as Expensicons from '@components/Icon/Expensicons';
import MenuItem from '@components/MenuItem';
import Modal from '@components/Modal';
import {PressableWithFeedback} from '@components/Pressable';
import type ChatListItem from '@components/SelectionList/ChatListItem';
import type ReportListItem from '@components/SelectionList/Search/ReportListItem';
import type TransactionListItem from '@components/SelectionList/Search/TransactionListItem';
import type {ExtendedTargetedEvent, ReportActionListItemType, ReportListItemType, TransactionListItemType} from '@components/SelectionList/types';
import Text from '@components/Text';
import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager';
import useKeyboardShortcut from '@hooks/useKeyboardShortcut';
import useKeyboardState from '@hooks/useKeyboardState';
import useLocalize from '@hooks/useLocalize';
import useMobileSelectionMode from '@hooks/useMobileSelectionMode';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useSafeAreaPaddings from '@hooks/useSafeAreaPaddings';
import useThemeStyles from '@hooks/useThemeStyles';
import {turnOffMobileSelectionMode, turnOnMobileSelectionMode} from '@libs/actions/MobileSelectionMode';
import {isMobileChrome} from '@libs/Browser';
import {addKeyDownPressListener, removeKeyDownPressListener} from '@libs/KeyboardShortcut/KeyDownPressListener';
import variables from '@styles/variables';
import CONST from '@src/CONST';

type SearchListItem = TransactionListItemType | ReportListItemType | ReportActionListItemType;
type SearchListItemComponentType = typeof TransactionListItem | typeof ChatListItem | typeof ReportListItem;

type SearchListHandle = {
scrollAndHighlightItem?: (items: string[]) => void;
scrollToIndex: (index: number, animated?: boolean) => void;
};

type SearchListProps = Pick<FlatListPropsWithLayout<SearchListItem>, 'onScroll' | 'contentContainerStyle' | 'onEndReached' | 'onEndReachedThreshold' | 'ListFooterComponent'> & {
data: SearchListItem[];

/** Default renderer for every item in the list */
ListItem: SearchListItemComponentType;

SearchTableHeader?: React.JSX.Element;

/** Callback to fire when a row is pressed */
onSelectRow: (item: SearchListItem) => void;

/** Whether this is a multi-select list */
canSelectMultiple: boolean;

/** Callback to fire when a checkbox is pressed */
onCheckboxPress: (item: SearchListItem) => void;

/** Callback to fire when "Select All" checkbox is pressed. Only use along with `canSelectMultiple` */
onAllCheckboxPress: () => void;

/** Styles to apply to SelectionList container */
containerStyle?: StyleProp<ViewStyle>;

/** Whether to prevent default focusing of options and focus the textinput when selecting an option */
shouldPreventDefaultFocusOnSelectRow?: boolean;
};

function SearchList(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This component looks much cleaner than its counterpart (SelectionList). Great refactor! 🚀

{
data,
ListItem,
SearchTableHeader,
onSelectRow,
onCheckboxPress,
canSelectMultiple,
onScroll,
onAllCheckboxPress,
contentContainerStyle,
onEndReachedThreshold,
onEndReached,
containerStyle,
ListFooterComponent,
shouldPreventDefaultFocusOnSelectRow,
}: SearchListProps,
ref: ForwardedRef<SearchListHandle>,
) {
const styles = useThemeStyles();
const selectedItemsLength = data.reduce((acc, item) => {
return item.isSelected ? acc + 1 : acc;
}, 0);
const {translate} = useLocalize();
const isFocused = useIsFocused();
const listRef = useRef<FlatList<SearchListItem>>(null);
const hasKeyBeenPressed = useRef(false);
const [itemsToHighlight, setItemsToHighlight] = useState<Set<string> | null>(null);
const itemFocusTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const {isKeyboardShown} = useKeyboardState();
const {safeAreaPaddingBottomStyle} = useSafeAreaPaddings();
// We need to use isSmallScreenWidth instead of shouldUseNarrowLayout here because there is a race condition that causes shouldUseNarrowLayout to change indefinitely in this component
// See https://github.com/Expensify/App/issues/48675 for more details
// eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth
const {isSmallScreenWidth} = useResponsiveLayout();

const [isModalVisible, setIsModalVisible] = useState(false);
const {selectionMode} = useMobileSelectionMode();
const [longPressedItem, setLongPressedItem] = useState<SearchListItem>();
// Check if selection should be on when the modal is opened
const wasSelectionOnRef = useRef(false);
// Keep track of the number of selected items to determine if we should turn off selection mode
const selectionRef = useRef(0);

useEffect(() => {
selectionRef.current = selectedItemsLength;

if (!isSmallScreenWidth) {
if (selectedItemsLength === 0) {
turnOffMobileSelectionMode();
}
return;
}
if (!isFocused) {
return;
}
if (!wasSelectionOnRef.current && selectedItemsLength > 0) {
wasSelectionOnRef.current = true;
}
if (selectedItemsLength > 0 && !selectionMode?.isEnabled) {
turnOnMobileSelectionMode();
} else if (selectedItemsLength === 0 && selectionMode?.isEnabled && !wasSelectionOnRef.current) {
turnOffMobileSelectionMode();
}
}, [selectionMode, isSmallScreenWidth, isFocused, selectedItemsLength]);

useEffect(
() => () => {
if (selectionRef.current !== 0) {
return;
}
turnOffMobileSelectionMode();
},
[],
);

const handleLongPressRow = useCallback(
(item: SearchListItem) => {
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
if (!isSmallScreenWidth || item?.isDisabled || item?.isDisabledCheckbox || !isFocused) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An early return condition was missing here for chat reports which caused the following regression issue:

this was fixed by passing a new prop shouldPreventLongPressRow when isChat (true) in order to disable long press row functionality.

return;
}
setLongPressedItem(item);
setIsModalVisible(true);
},
[isFocused, isSmallScreenWidth],
);

const turnOnSelectionMode = useCallback(() => {
turnOnMobileSelectionMode();
setIsModalVisible(false);

if (onCheckboxPress && longPressedItem) {
onCheckboxPress?.(longPressedItem);
}
}, [longPressedItem, onCheckboxPress]);

/**
* Scrolls to the desired item index in the section list
*
* @param index - the index of the item to scroll to
* @param animated - whether to animate the scroll
*/
const scrollToIndex = useCallback(
(index: number, animated = true) => {
const item = data.at(index);

if (!listRef.current || !item || index === -1) {
return;
}

listRef.current.scrollToIndex({index, animated, viewOffset: variables.contentHeaderHeight});
},

// eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps
[],
);
const setHasKeyBeenPressed = useCallback(() => {
if (hasKeyBeenPressed.current) {
return;
}
// We need to track whether a key has been pressed to enable focus syncing only if a key has been pressed.
// This is to avoid the default behavior of web showing blue border on click of items after a page refresh.
hasKeyBeenPressed.current = true;
}, []);

const [focusedIndex, setFocusedIndex] = useArrowKeyFocusManager({
initialFocusedIndex: -1,
maxIndex: data.length - 1,
isActive: isFocused,
onFocusedIndexChange: (index: number) => {
scrollToIndex(index);
},
...(!hasKeyBeenPressed.current && {setHasKeyBeenPressed}),
isFocused,
});

const selectFocusedOption = useCallback(() => {
const focusedItem = data.at(focusedIndex);

if (!focusedItem) {
return;
}

onSelectRow(focusedItem);
}, [data, focusedIndex, onSelectRow]);

useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ENTER, selectFocusedOption, {
captureOnInputs: true,
shouldBubble: false,
shouldPreventDefault: false,
isActive: isFocused,
shouldStopPropagation: true,
});

useEffect(() => {
addKeyDownPressListener(setHasKeyBeenPressed);

return () => removeKeyDownPressListener(setHasKeyBeenPressed);
}, [setHasKeyBeenPressed]);

/**
* 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[]) => {
const newItemsToHighlight = new Set<string>();
items.forEach((item) => {
newItemsToHighlight.add(item);
});
const index = data.findIndex((option) => newItemsToHighlight.has(option.keyForList ?? ''));
scrollToIndex(index);
setItemsToHighlight(newItemsToHighlight);

if (itemFocusTimeoutRef.current) {
clearTimeout(itemFocusTimeoutRef.current);
}

const duration =
CONST.ANIMATED_HIGHLIGHT_ENTRY_DELAY +
CONST.ANIMATED_HIGHLIGHT_ENTRY_DURATION +
CONST.ANIMATED_HIGHLIGHT_START_DELAY +
CONST.ANIMATED_HIGHLIGHT_START_DURATION +
CONST.ANIMATED_HIGHLIGHT_END_DELAY +
CONST.ANIMATED_HIGHLIGHT_END_DURATION;
itemFocusTimeoutRef.current = setTimeout(() => {
setItemsToHighlight(null);
}, duration);
},
[data, scrollToIndex],
);

useImperativeHandle(ref, () => ({scrollAndHighlightItem, scrollToIndex}), [scrollAndHighlightItem, scrollToIndex]);

const renderItem = useCallback(
({item, index}: ListRenderItemInfo<SearchListItem>) => {
const isItemFocused = focusedIndex === index;
const isItemHighlighted = !!itemsToHighlight?.has(item.keyForList ?? '');

return (
<ListItem
showTooltip={false}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ikevin127 @SzymczakJ Coming from #59656, I want to confirm again whether this change is intentional or just a mistake

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

99% sure this is a mistake

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree

isFocused={isItemFocused}
onSelectRow={onSelectRow}
onFocus={(event: NativeSyntheticEvent<ExtendedTargetedEvent>) => {
// Prevent unexpected scrolling on mobile Chrome after the context menu closes by ignoring programmatic focus not triggered by direct user interaction.
if (isMobileChrome() && event.nativeEvent && !event.nativeEvent.sourceCapabilities) {
return;
}
setFocusedIndex(index);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

// Ignore the focus if it's caused by a touch event on mobile chrome.
// For example, a long press will trigger a focus event on mobile chrome.
shouldIgnoreFocus={isMobileChrome() && isScreenTouched}

This edge case was missed during the migration, we fixed it in issue #59420. :)

}}
onLongPressRow={handleLongPressRow}
onCheckboxPress={onCheckboxPress}
canSelectMultiple={canSelectMultiple}
item={{
shouldAnimateInHighlight: isItemHighlighted,
...item,
}}
shouldPreventDefaultFocusOnSelectRow={shouldPreventDefaultFocusOnSelectRow}
/>
);
},
[ListItem, canSelectMultiple, focusedIndex, handleLongPressRow, itemsToHighlight, onCheckboxPress, onSelectRow, setFocusedIndex, shouldPreventDefaultFocusOnSelectRow],
);

return (
<View style={[styles.flex1, !isKeyboardShown && safeAreaPaddingBottomStyle, containerStyle]}>
{canSelectMultiple && (
<View style={[styles.searchListHeaderContainerStyle]}>
<Checkbox
accessibilityLabel={translate('workspace.people.selectAll')}
isChecked={selectedItemsLength === data.length}
onPress={() => {
onAllCheckboxPress();
}}
/>
{SearchTableHeader ?? (
<PressableWithFeedback
style={[styles.userSelectNone, styles.alignItemsCenter]}
onPress={onAllCheckboxPress}
accessibilityLabel={translate('workspace.people.selectAll')}
role="button"
accessibilityState={{checked: selectedItemsLength === data.length}}
dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}}
>
<Text style={[styles.textStrong, styles.ph3]}>{translate('workspace.people.selectAll')}</Text>
</PressableWithFeedback>
)}
</View>
)}
<Animated.FlatList
data={data}
renderItem={renderItem}
keyExtractor={(item, index) => item.keyForList ?? `${index}`}
onScroll={onScroll}
contentContainerStyle={contentContainerStyle}
showsVerticalScrollIndicator={false}
ref={listRef}
extraData={focusedIndex}
onEndReached={onEndReached}
onEndReachedThreshold={onEndReachedThreshold}
ListFooterComponent={ListFooterComponent}
removeClippedSubviews
/>
<Modal
isVisible={isModalVisible}
type={CONST.MODAL.MODAL_TYPE.BOTTOM_DOCKED}
onClose={() => setIsModalVisible(false)}
shouldPreventScrollOnFocus
>
<MenuItem
title={translate('common.select')}
icon={Expensicons.Checkmark}
onPress={turnOnSelectionMode}
/>
</Modal>
</View>
);
}

export default forwardRef(SearchList);
Loading