-
Notifications
You must be signed in to change notification settings - Fork 3.2k
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
Changes from all commits
15940e0
b5935a6
072832a
17e6ec8
73582e5
84c3f69
207d8d1
2f11ac5
9702023
23b8e4e
dc92afa
a39e2af
4bb7412
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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( | ||||||||
{ | ||||||||
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) { | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||||||||
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, { | ||||||||
SzymczakJ marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||
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} | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 99% sure this is a mistake There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. App/src/components/SelectionList/index.tsx Lines 74 to 76 in c3451e1
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); |
There was a problem hiding this comment.
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! 🚀