From 730978ae5db8c2e802650a7b11b89e2e467de2bf Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Fri, 1 May 2026 20:49:43 +0530 Subject: [PATCH 1/3] feat: make expanded group table header sticky using stickyHeaderIndices Signed-off-by: krishna2323 --- .../BaseSearchList/index.native.tsx | 10 +- .../SearchList/BaseSearchList/index.tsx | 12 +- .../Search/SearchList/BaseSearchList/types.ts | 14 +- .../ListItem/TransactionGroupListExpanded.tsx | 88 ++++++-- .../ListItem/TransactionGroupListItem.tsx | 103 +++++---- .../Search/SearchList/ListItem/types.ts | 29 +++ src/components/Search/SearchList/index.tsx | 212 ++++++++++++++++-- 7 files changed, 381 insertions(+), 87 deletions(-) diff --git a/src/components/Search/SearchList/BaseSearchList/index.native.tsx b/src/components/Search/SearchList/BaseSearchList/index.native.tsx index ad5d017c708f..0d6dd3f033f0 100644 --- a/src/components/Search/SearchList/BaseSearchList/index.native.tsx +++ b/src/components/Search/SearchList/BaseSearchList/index.native.tsx @@ -1,10 +1,10 @@ import {FlashList} from '@shopify/flash-list'; import React, {useCallback} from 'react'; import Animated from 'react-native-reanimated'; -import type {SearchListItem} from '@components/Search/SearchList/ListItem/types'; +import type {SearchFlashListItem} from '@components/Search/SearchList/ListItem/types'; import type BaseSearchListProps from './types'; -const AnimatedFlashListComponent = Animated.createAnimatedComponent(FlashList); +const AnimatedFlashListComponent = Animated.createAnimatedComponent(FlashList); function BaseSearchList({ data, @@ -18,9 +18,11 @@ function BaseSearchList({ onViewableItemsChanged, onLayout, contentContainerStyle, + stickyHeaderIndices, + getItemType, }: BaseSearchListProps) { const renderItemWithoutKeyboardFocus = useCallback( - ({item, index}: {item: SearchListItem; index: number}) => { + ({item, index}: {item: SearchFlashListItem; index: number}) => { return renderItem(item, index, false, undefined); }, [renderItem], @@ -43,6 +45,8 @@ function BaseSearchList({ drawDistance={250} contentContainerStyle={contentContainerStyle} maintainVisibleContentPosition={{disabled: true}} + stickyHeaderIndices={stickyHeaderIndices} + getItemType={getItemType} /> ); } diff --git a/src/components/Search/SearchList/BaseSearchList/index.tsx b/src/components/Search/SearchList/BaseSearchList/index.tsx index 9ae212784330..4b7a89d2bfe5 100644 --- a/src/components/Search/SearchList/BaseSearchList/index.tsx +++ b/src/components/Search/SearchList/BaseSearchList/index.tsx @@ -3,7 +3,7 @@ import {FlashList} from '@shopify/flash-list'; import React, {useCallback, useEffect, useMemo, useRef} from 'react'; import type {NativeSyntheticEvent} from 'react-native'; import Animated from 'react-native-reanimated'; -import type {SearchListItem} from '@components/Search/SearchList/ListItem/types'; +import type {SearchFlashListItem} from '@components/Search/SearchList/ListItem/types'; import type {ExtendedTargetedEvent} from '@components/SelectionList/ListItem/types'; import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager'; import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; @@ -12,7 +12,7 @@ import {addKeyDownPressListener, removeKeyDownPressListener} from '@libs/Keyboar import CONST from '@src/CONST'; import type BaseSearchListProps from './types'; -const AnimatedFlashListComponent = Animated.createAnimatedComponent(FlashList); +const AnimatedFlashListComponent = Animated.createAnimatedComponent(FlashList); function BaseSearchList({ data, @@ -34,6 +34,8 @@ function BaseSearchList({ selectedTransactions, policyForMovingExpenses, nonPersonalAndWorkspaceCards, + stickyHeaderIndices, + getItemType, }: BaseSearchListProps) { const hasKeyBeenPressed = useRef(false); const isFocused = useIsFocused(); @@ -60,7 +62,7 @@ function BaseSearchList({ }); const renderItemWithKeyboardFocus = useCallback( - ({item, index}: {item: SearchListItem; index: number}) => { + ({item, index}: {item: SearchFlashListItem; index: number}) => { const isItemFocused = focusedIndex === index; const onFocus = (event: NativeSyntheticEvent) => { @@ -85,7 +87,7 @@ function BaseSearchList({ const selectFocusedOption = useCallback(() => { const focusedItem = data.at(focusedIndex); - if (!focusedItem) { + if (!focusedItem || ('itemType' in focusedItem && typeof focusedItem.itemType === 'string')) { return; } @@ -129,6 +131,8 @@ function BaseSearchList({ drawDistance={250} contentContainerStyle={contentContainerStyle} maintainVisibleContentPosition={{disabled: true}} + stickyHeaderIndices={stickyHeaderIndices} + getItemType={getItemType} /> ); } diff --git a/src/components/Search/SearchList/BaseSearchList/types.ts b/src/components/Search/SearchList/BaseSearchList/types.ts index b0c05aeb9e58..081f1306eae0 100644 --- a/src/components/Search/SearchList/BaseSearchList/types.ts +++ b/src/components/Search/SearchList/BaseSearchList/types.ts @@ -1,13 +1,13 @@ import type {FlashListProps, FlashListRef} from '@shopify/flash-list'; import type {ForwardedRef} from 'react'; import type {NativeSyntheticEvent} from 'react-native'; -import type {SearchListItem} from '@components/Search/SearchList/ListItem/types'; +import type {SearchFlashListItem} from '@components/Search/SearchList/ListItem/types'; import type {SearchColumnType, SelectedTransactions} from '@components/Search/types'; import type {ExtendedTargetedEvent} from '@components/SelectionList/ListItem/types'; import type {CardList, Policy, Transaction} from '@src/types/onyx'; type BaseSearchListProps = Pick< - FlashListProps, + FlashListProps, | 'onScroll' | 'contentContainerStyle' | 'onEndReached' @@ -17,12 +17,14 @@ type BaseSearchListProps = Pick< | 'keyExtractor' | 'showsVerticalScrollIndicator' | 'onLayout' + | 'stickyHeaderIndices' + | 'getItemType' > & { /** The data to display in the list */ - data: SearchListItem[]; + data: SearchFlashListItem[]; /** The function to render each item in the list */ - renderItem: (item: SearchListItem, index: number, isItemFocused: boolean, onFocus?: (event: NativeSyntheticEvent) => void) => React.JSX.Element; + renderItem: (item: SearchFlashListItem, index: number, isItemFocused: boolean, onFocus?: (event: NativeSyntheticEvent) => void) => React.JSX.Element | null; /** The columns that might change to trigger re-render via extraData */ columns: SearchColumnType[]; @@ -34,10 +36,10 @@ type BaseSearchListProps = Pick< flattenedItemsLength: number; /** The callback, which is run when a row is pressed */ - onSelectRow: (item: SearchListItem) => void; + onSelectRow: (item: SearchFlashListItem) => void; /** The ref to the list */ - ref: ForwardedRef>; + ref: ForwardedRef>; /** The function to scroll to an index */ scrollToIndex?: (index: number, animated?: boolean) => void; diff --git a/src/components/Search/SearchList/ListItem/TransactionGroupListExpanded.tsx b/src/components/Search/SearchList/ListItem/TransactionGroupListExpanded.tsx index 087288f8149b..8eff435eb0fa 100644 --- a/src/components/Search/SearchList/ListItem/TransactionGroupListExpanded.tsx +++ b/src/components/Search/SearchList/ListItem/TransactionGroupListExpanded.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, {useCallback} from 'react'; import {View} from 'react-native'; import ActivityIndicator from '@components/ActivityIndicator'; import Button from '@components/Button'; @@ -10,6 +10,7 @@ import type {ListItem} from '@components/SelectionList/types'; import Text from '@components/Text'; import TransactionItemRow from '@components/TransactionItemRow'; import {useWideRHPActions} from '@components/WideRHPContextProvider'; +import useActionLoadingReportIDs from '@hooks/useActionLoadingReportIDs'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; @@ -17,12 +18,13 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; +import {search} from '@libs/actions/Search'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {getReportIDForTransaction} from '@libs/MoneyRequestReportUtils'; import Navigation from '@libs/Navigation/Navigation'; import {getReportAction} from '@libs/ReportActionsUtils'; import {getReportOrDraftReport} from '@libs/ReportUtils'; -import {createAndOpenSearchTransactionThread, getColumnsToShow, getTableMinWidth} from '@libs/SearchUIUtils'; +import {createAndOpenSearchTransactionThread, getColumnsToShow, getSections, getTableMinWidth} from '@libs/SearchUIUtils'; import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; import {getTransactionViolations, isDeletedTransaction} from '@libs/TransactionUtils'; import type {TransactionPreviewData} from '@userActions/Search'; @@ -31,6 +33,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import {columnsSelector} from '@src/selectors/AdvancedSearchFiltersForm'; +import type SearchResults from '@src/types/onyx/SearchResults'; import type {TransactionGroupListExpandedProps, TransactionListItemType} from './types'; function TransactionGroupListExpanded({ @@ -57,21 +60,71 @@ function TransactionGroupListExpanded({ onLongPress, nonPersonalAndWorkspaceCards, onUndelete, + shouldHideTableHeader, }: TransactionGroupListExpandedProps) { const theme = useTheme(); const styles = useThemeStyles(); const {windowWidth} = useWindowDimensions(); const currentUserDetails = useCurrentUserPersonalDetails(); - const {translate} = useLocalize(); + const {translate, formatPhoneNumber} = useLocalize(); const [isMobileSelectionModeEnabled] = useOnyx(ONYXKEYS.RAM_ONLY_MOBILE_SELECTION_MODE); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [betas] = useOnyx(ONYXKEYS.BETAS); const [visibleColumns] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM, {selector: columnsSelector}); const [allTransactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION); + const isSelfFetching = !transactionsSnapshot; + const [fetchedSnapshot] = useOnyx(`${ONYXKEYS.COLLECTION.SNAPSHOT}${transactionsQueryJSON?.hash ?? ''}`); + const resolvedSnapshot = transactionsSnapshot ?? (isSelfFetching ? (fetchedSnapshot as SearchResults | undefined) : undefined); - const transactionsSnapshotMetadata = transactionsSnapshot?.search; + const isActionLoadingSet = useActionLoadingReportIDs(); + const [allReportMetadata] = useOnyx(ONYXKEYS.COLLECTION.REPORT_METADATA); + const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); + const [cardFeeds] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER); + const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); - const visibleTransactions = isExpenseReportType ? transactions.slice(0, transactionsVisibleLimit) : transactions; + let resolvedTransactions: TransactionListItemType[]; + if (!isSelfFetching || isExpenseReportType) { + resolvedTransactions = transactions; + } else if (!resolvedSnapshot?.data) { + resolvedTransactions = []; + } else { + const [sectionData] = getSections({ + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + data: resolvedSnapshot.data, + currentAccountID: currentUserDetails.accountID, + currentUserEmail: currentUserDetails.email ?? '', + translate, + formatPhoneNumber, + bankAccountList, + isActionLoadingSet, + allReportMetadata, + cardFeeds, + conciergeReportID, + }) as [TransactionListItemType[], number, boolean]; + resolvedTransactions = sectionData; + } + + const resolvedSearchTransactions = useCallback( + (pageSize = 0) => { + if (!isSelfFetching || !transactionsQueryJSON) { + searchTransactions(pageSize); + return; + } + search({ + queryJSON: transactionsQueryJSON, + searchKey: undefined, + offset: (resolvedSnapshot?.search?.offset ?? 0) + pageSize, + shouldCalculateTotals: false, + isLoading: !!resolvedSnapshot?.search?.isLoading, + isOffline, + }); + }, + [isSelfFetching, transactionsQueryJSON, searchTransactions, resolvedSnapshot?.search?.offset, resolvedSnapshot?.search?.isLoading, isOffline], + ); + + const transactionsSnapshotMetadata = resolvedSnapshot?.search; + + const visibleTransactions = isExpenseReportType ? resolvedTransactions.slice(0, transactionsVisibleLimit) : resolvedTransactions; const isLastTransaction = (index: number) => { return index === visibleTransactions.length - 1; @@ -79,28 +132,27 @@ function TransactionGroupListExpanded({ let currentColumns = columns ?? []; if (!isExpenseReportType) { - if (!transactionsSnapshot?.data) { + if (!resolvedSnapshot?.data) { currentColumns = []; } else { - currentColumns = getColumnsToShow({currentAccountID: accountID, data: transactionsSnapshot?.data, visibleColumns, type: transactionsSnapshot?.search.type}); + currentColumns = getColumnsToShow({currentAccountID: accountID, data: resolvedSnapshot?.data, visibleColumns, type: resolvedSnapshot?.search.type}); } } - // Currently only the transaction report groups have transactions where the empty view makes sense - const shouldDisplayShowMoreButton = isExpenseReportType ? transactions.length > transactionsVisibleLimit : !!transactionsSnapshotMetadata?.hasMoreResults && !isOffline; + const shouldDisplayShowMoreButton = isExpenseReportType ? resolvedTransactions.length > transactionsVisibleLimit : !!transactionsSnapshotMetadata?.hasMoreResults && !isOffline; const currentOffset = transactionsSnapshotMetadata?.offset ?? 0; - const shouldShowLoadingOnSearch = !!(!transactions?.length && transactionsSnapshotMetadata?.isLoading) || currentOffset > 0; + const shouldShowLoadingOnSearch = !!(!resolvedTransactions?.length && transactionsSnapshotMetadata?.isLoading) || currentOffset > 0; const shouldDisplayLoadingIndicator = !isExpenseReportType && !!transactionsSnapshotMetadata?.isLoading && shouldShowLoadingOnSearch; const {isLargeScreenWidth} = useResponsiveLayout(); - const isAmountColumnWide = transactions.some((transaction) => transaction.isAmountColumnWide); - const isTaxAmountColumnWide = transactions.some((transaction) => transaction.isTaxAmountColumnWide); - const shouldShowYearForSomeTransaction = transactions.some((transaction) => transaction.shouldShowYear); + const isAmountColumnWide = resolvedTransactions.some((transaction) => transaction.isAmountColumnWide); + const isTaxAmountColumnWide = resolvedTransactions.some((transaction) => transaction.isTaxAmountColumnWide); + const shouldShowYearForSomeTransaction = resolvedTransactions.some((transaction) => transaction.shouldShowYear); const amountColumnSize = isAmountColumnWide ? CONST.SEARCH.TABLE_COLUMN_SIZES.WIDE : CONST.SEARCH.TABLE_COLUMN_SIZES.NORMAL; const taxAmountColumnSize = isTaxAmountColumnWide ? CONST.SEARCH.TABLE_COLUMN_SIZES.WIDE : CONST.SEARCH.TABLE_COLUMN_SIZES.NORMAL; const dateColumnSize = shouldShowYearForSomeTransaction ? CONST.SEARCH.TABLE_COLUMN_SIZES.WIDE : CONST.SEARCH.TABLE_COLUMN_SIZES.NORMAL; - const isActionColumnWide = transactions.some((transaction) => !!transaction.isActionColumnWide || isDeletedTransaction(transaction)); + const isActionColumnWide = resolvedTransactions.some((transaction) => !!transaction.isActionColumnWide || isDeletedTransaction(transaction)); const {markReportIDAsExpense} = useWideRHPActions(); const selectRow = onSelectRow as (item: TItem, transactionPreviewData?: TransactionPreviewData) => void; @@ -145,7 +197,7 @@ function TransactionGroupListExpanded({ return; } - const siblingTransactionIDs = transactions + const siblingTransactionIDs = resolvedTransactions .filter((transaction) => transaction.reportAction?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) .map((transaction) => transaction.transactionID); @@ -161,7 +213,7 @@ function TransactionGroupListExpanded({ if (isExpenseReportType) { setTransactionsVisibleLimit((currentPageSize) => currentPageSize + CONST.TRANSACTION.RESULTS_PAGE_SIZE); } else if (!isOffline && transactionsQueryJSON) { - searchTransactions(CONST.SEARCH.RESULTS_PAGE_SIZE); + resolvedSearchTransactions(CONST.SEARCH.RESULTS_PAGE_SIZE); } }; @@ -205,7 +257,7 @@ function TransactionGroupListExpanded({ const content = ( - {isLargeScreenWidth && !(isEmpty && shouldDisplayLoadingIndicator) && ( + {isLargeScreenWidth && !(isEmpty && shouldDisplayLoadingIndicator) && !shouldHideTableHeader && ( <> ({ )} {visibleTransactions.map((transaction, index) => { const shouldShowBottomBorder = !isLastTransaction(index); - const exportedReportActions = Object.values(transactionsSnapshot?.data?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transaction?.reportID}`] ?? {}); + const exportedReportActions = Object.values(resolvedSnapshot?.data?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transaction?.reportID}`] ?? {}); const transactionRow = ( ({ userBillingGracePeriodEnds, ownerBillingGracePeriodEnd, onUndelete, + policyForMovingExpenses, + isExpanded: isExpandedProp, + onToggleExpansion, }: TransactionGroupListItemProps) { const groupItem = item as unknown as TransactionGroupListItemType; @@ -116,7 +119,8 @@ function TransactionGroupListItem({ const isExpenseReportType = searchType === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT; const [transactionsVisibleLimit, setTransactionsVisibleLimit] = useState(CONST.TRANSACTION.RESULTS_PAGE_SIZE as number); - const [isExpanded, setIsExpanded] = useState(false); + const [isExpandedLocal, setIsExpandedLocal] = useState(false); + const isExpanded = isExpandedProp ?? isExpandedLocal; const isActionLoadingSet = useActionLoadingReportIDs(); const [allReportMetadata] = useOnyx(ONYXKEYS.COLLECTION.REPORT_METADATA); const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); @@ -260,17 +264,24 @@ function TransactionGroupListItem({ }, [isScreenFocused, isExpanded, isExpenseReportType, groupItem.transactionsQueryJSON, isOffline, transactionsSnapshot?.search?.isLoading]); const handleToggle = () => { - setIsExpanded((prev) => { - const newExpandedState = !prev; - - if (newExpandedState) { + if (onToggleExpansion) { + onToggleExpansion(); + if (!isExpanded) { refreshTransactions(); } else { setTransactionsVisibleLimit(CONST.TRANSACTION.RESULTS_PAGE_SIZE); } - - return newExpandedState; - }); + } else { + setIsExpandedLocal((prev) => { + const newExpandedState = !prev; + if (newExpandedState) { + refreshTransactions(); + } else { + setTransactionsVisibleLimit(CONST.TRANSACTION.RESULTS_PAGE_SIZE); + } + return newExpandedState; + }); + } }; const onPress = () => { @@ -514,6 +525,8 @@ function TransactionGroupListItem({ } } + const isControlledExpansion = !!onToggleExpansion; + return ( ({ > {({hovered}) => ( - - - + {isControlledExpansion ? ( + getHeader(hovered) + ) : ( + + + + )} )} diff --git a/src/components/Search/SearchList/ListItem/types.ts b/src/components/Search/SearchList/ListItem/types.ts index a87b1e3c0ba9..0f2817a8c199 100644 --- a/src/components/Search/SearchList/ListItem/types.ts +++ b/src/components/Search/SearchList/ListItem/types.ts @@ -348,8 +348,28 @@ type ReportActionListItemType = ListItem & reportName: string; }; +type ExpandedGroupTableHeaderItem = { + itemType: 'expandedGroupTableHeader'; + keyForList: string; + parentGroupKeyForList: string; + columns: SearchColumnType[]; + groupBy?: SearchGroupBy; + canSelectMultiple: boolean; + isExpenseReportType: boolean; + transactionsQueryHash?: number; +}; + +type ExpandedGroupContentItem = { + itemType: 'expandedGroupContent'; + keyForList: string; + parentGroupKeyForList: string; + groupItem: TransactionGroupListItemType; +}; + type SearchListItem = TransactionListItemType | TransactionGroupListItemType | ReportActionListItemType | TaskListItemType | ExpenseReportListItemType; +type SearchFlashListItem = SearchListItem | ExpandedGroupTableHeaderItem | ExpandedGroupContentItem; + type TransactionCardGroupListItemType = TransactionGroupListItemType & {groupedBy: typeof CONST.SEARCH.GROUP_BY.CARD} & PersonalDetails & SearchCardGroup & { /** Final and formatted "cardName" value used for displaying and sorting */ @@ -444,6 +464,10 @@ type TransactionGroupListItemProps = ListItemProps void; + /** Whether this group is expanded (controlled from SearchList) */ + isExpanded?: boolean; + /** Callback to toggle expansion from SearchList */ + onToggleExpansion?: () => void; }; type TransactionGroupListExpandedProps = Pick< @@ -472,6 +496,8 @@ type TransactionGroupListExpandedProps = Pick< isInSingleTransactionReport: boolean; searchTransactions: (pageSize?: number) => void; onLongPress: (transaction: TransactionListItemType) => void; + /** Whether to hide the table header (when it's rendered as a separate sticky FlashList item) */ + shouldHideTableHeader?: boolean; }; type UnreportedExpenseListItemType = Transaction & { @@ -506,4 +532,7 @@ export type { TransactionListItemProps, ReportActionListItemType, UnreportedExpenseListItemType, + ExpandedGroupTableHeaderItem, + ExpandedGroupContentItem, + SearchFlashListItem, }; diff --git a/src/components/Search/SearchList/index.tsx b/src/components/Search/SearchList/index.tsx index fba50df6c661..c95752a1e472 100644 --- a/src/components/Search/SearchList/index.tsx +++ b/src/components/Search/SearchList/index.tsx @@ -13,6 +13,7 @@ import {usePersonalDetails} from '@components/OnyxListItemProvider'; import {PressableWithFeedback} from '@components/Pressable'; import {ScrollOffsetContext} from '@components/ScrollOffsetContextProvider'; import ScrollView from '@components/ScrollView'; +import SearchTableHeaderComponent from '@components/Search/SearchTableHeader'; import type {SearchColumnType, SearchGroupBy, SearchQueryJSON, SelectedTransactions} from '@components/Search/types'; import type {ExtendedTargetedEvent} from '@components/SelectionList/ListItem/types'; import Text from '@components/Text'; @@ -24,26 +25,33 @@ import useOnyx from '@hooks/useOnyx'; import usePrevious from '@hooks/usePrevious'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSafeAreaPaddings from '@hooks/useSafeAreaPaddings'; +import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useUndeleteTransactions from '@hooks/useUndeleteTransactions'; import useWindowDimensions from '@hooks/useWindowDimensions'; import {turnOnMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; import DateUtils from '@libs/DateUtils'; import navigationRef from '@libs/Navigation/navigationRef'; -import {applySelectionToItem, getTableMinWidth} from '@libs/SearchUIUtils'; +import {applySelectionToItem, getColumnsToShow, getTableMinWidth} from '@libs/SearchUIUtils'; import variables from '@styles/variables'; import type {TransactionPreviewData} from '@userActions/Search'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import {columnsSelector} from '@src/selectors/AdvancedSearchFiltersForm'; import type {CardList, Policy, Transaction, TransactionViolations} from '@src/types/onyx'; +import type SearchResults from '@src/types/onyx/SearchResults'; import BaseSearchList from './BaseSearchList'; import type ChatListItem from './ListItem/ChatListItem'; import type ExpenseReportListItem from './ListItem/ExpenseReportListItem'; import type TaskListItem from './ListItem/TaskListItem'; +import TransactionGroupListExpanded from './ListItem/TransactionGroupListExpanded'; import type TransactionGroupListItem from './ListItem/TransactionGroupListItem'; import type TransactionListItem from './ListItem/TransactionListItem'; import type { + ExpandedGroupContentItem, + ExpandedGroupTableHeaderItem, ReportActionListItemType, + SearchFlashListItem, TaskListItemType, TransactionCardGroupListItemType, TransactionCategoryGroupListItemType, @@ -107,7 +115,7 @@ type SearchListProps = Pick, 'onScroll' | 'conten columns: SearchColumnType[]; /** Called when the viewability of rows changes, as defined by the viewabilityConfig prop. */ - onViewableItemsChanged?: (info: {changed: Array>; viewableItems: Array>}) => void; + onViewableItemsChanged?: (info: {changed: Array>; viewableItems: Array>}) => void; /** Invoked on mount and layout changes */ onLayout?: () => void; @@ -141,7 +149,7 @@ type SearchListProps = Pick, 'onScroll' | 'conten ref?: ForwardedRef; }; -const keyExtractor = (item: SearchListItem, index: number) => item.keyForList ?? `${index}`; +const keyExtractor = (item: SearchFlashListItem, index: number) => item.keyForList ?? `${index}`; function isTransactionGroupListItemArray(data: SearchListItem[]): data is TransactionGroupListItemType[] { if (data.length <= 0) { @@ -194,6 +202,50 @@ function isTransactionMatchWithGroupItem(transaction: Transaction, groupItem: Se return false; } +function ExpandedGroupTableHeader({item}: {item: ExpandedGroupTableHeaderItem}) { + const styles = useThemeStyles(); + const theme = useTheme(); + const [visibleColumns] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM, {selector: columnsSelector}); + const [fetchedSnapshot] = useOnyx(`${ONYXKEYS.COLLECTION.SNAPSHOT}${item.transactionsQueryHash ?? ''}`); + const personalDetails = usePersonalDetails(); + const accountID = personalDetails?.[0]?.accountID; + + let resolvedColumns = item.columns; + if (!item.isExpenseReportType && item.transactionsQueryHash) { + const snapshot = fetchedSnapshot as SearchResults | undefined; + if (snapshot?.data) { + resolvedColumns = getColumnsToShow({currentAccountID: accountID, data: snapshot.data, visibleColumns, type: snapshot.search.type}); + } + } + + return ( + + + {}} + sortOrder={undefined} + sortBy={undefined} + shouldShowYear={false} + isAmountColumnWide={false} + isTaxAmountColumnWide={false} + shouldShowSorting={false} + columns={resolvedColumns} + groupBy={item.groupBy} + isExpenseReportView + isActionColumnWide={false} + /> + + + + ); +} + +function isExpandedGroupItem(item: SearchFlashListItem): item is ExpandedGroupTableHeaderItem | ExpandedGroupContentItem { + return typeof item === 'object' && 'itemType' in item && typeof item.itemType === 'string'; +} + function SearchList({ data, ListItem, @@ -229,6 +281,21 @@ function SearchList({ const expensifyIcons = useMemoizedLazyExpensifyIcons(['CheckSquare']); const {hash, groupBy, type} = queryJSON; + + const [expandedGroupKeys, setExpandedGroupKeys] = useState>(new Set()); + + const toggleGroupExpansion = useCallback((keyForList: string) => { + setExpandedGroupKeys((prev) => { + const next = new Set(prev); + if (next.has(keyForList)) { + next.delete(keyForList); + } else { + next.add(keyForList); + } + return next; + }); + }, []); + const flattenedItems = useMemo(() => { if (groupBy || type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT) { if (!isTransactionGroupListItemArray(data)) { @@ -245,6 +312,51 @@ function SearchList({ return []; }, [data, type]); + const {expandedData, stickyHeaderIndices: computedStickyHeaderIndices} = useMemo(() => { + if (expandedGroupKeys.size === 0 || !isTransactionGroupListItemArray(data)) { + return {expandedData: data as SearchFlashListItem[], stickyHeaderIndices: undefined}; + } + + const isExpenseReportType = type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT; + const result: SearchFlashListItem[] = []; + const headerIndices: number[] = []; + + for (const item of data) { + result.push(item); + const key = item.keyForList ?? ''; + + if (!expandedGroupKeys.has(key)) { + continue; + } + + const tableHeaderItem: ExpandedGroupTableHeaderItem = { + itemType: 'expandedGroupTableHeader', + keyForList: `${key}_tableHeader`, + parentGroupKeyForList: key, + columns: columns ?? [], + groupBy, + canSelectMultiple, + isExpenseReportType, + transactionsQueryHash: item.transactionsQueryJSON?.hash, + }; + headerIndices.push(result.length); + result.push(tableHeaderItem); + + const contentItem: ExpandedGroupContentItem = { + itemType: 'expandedGroupContent', + keyForList: `${key}_content`, + parentGroupKeyForList: key, + groupItem: item, + }; + result.push(contentItem); + } + + return { + expandedData: result, + stickyHeaderIndices: headerIndices.length > 0 ? headerIndices : undefined, + }; + }, [data, expandedGroupKeys, type, columns, groupBy, canSelectMultiple]); + const selectedItemsLength = useMemo(() => { const selectedTransactionsCount = flattenedItems.reduce((acc, item) => { const isTransactionSelected = !!(item?.keyForList && selectedTransactions[item.keyForList]?.isSelected); @@ -286,7 +398,7 @@ function SearchList({ const {translate} = useLocalize(); const {isOffline} = useNetwork(); - const listRef = useRef>(null); + const listRef = useRef>(null); const {isKeyboardShown} = useKeyboardState(); const {safeAreaPaddingBottomStyle} = useSafeAreaPaddings(); const prevDataLength = usePrevious(data.length); @@ -417,19 +529,84 @@ function SearchList({ useImperativeHandle(ref, () => ({scrollToIndex}), [scrollToIndex]); - const isItemVisible = useCallback((item: SearchListItem) => item.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || isOffline, [isOffline]); - const firstVisibleIndex = useMemo(() => data.findIndex(isItemVisible), [data, isItemVisible]); - const lastVisibleIndex = useMemo(() => data.findLastIndex(isItemVisible), [data, isItemVisible]); + const isItemVisible = useCallback( + (item: SearchFlashListItem) => { + if (isExpandedGroupItem(item)) { + return true; + } + return item.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || isOffline; + }, + [isOffline], + ); + const firstVisibleIndex = useMemo(() => expandedData.findIndex(isItemVisible), [expandedData, isItemVisible]); + const lastVisibleIndex = useMemo(() => expandedData.findLastIndex(isItemVisible), [expandedData, isItemVisible]); + + const getItemType = useCallback((item: SearchFlashListItem): string | undefined => { + if (!item) { + return undefined; + } + if (isExpandedGroupItem(item)) { + return item.itemType; + } + if ('transactions' in item) { + return 'transactionGroup'; + } + if ('transactionID' in item) { + return 'transaction'; + } + return 'other'; + }, []); const renderItem = useCallback( - (item: SearchListItem, index: number, isItemFocused: boolean, onFocus?: (event: NativeSyntheticEvent) => void) => { + (item: SearchFlashListItem, index: number, isItemFocused: boolean, onFocus?: (event: NativeSyntheticEvent) => void) => { + if (isExpandedGroupItem(item)) { + if (item.itemType === 'expandedGroupTableHeader') { + return ; + } + if (item.itemType === 'expandedGroupContent') { + return ( + + {}} + isEmpty={item.groupItem.transactions.length === 0} + shouldDisplayEmptyView={false} + isExpenseReportType={type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT} + transactionsQueryJSON={item.groupItem.transactionsQueryJSON} + searchTransactions={() => {}} + isInSingleTransactionReport={item.groupItem.transactions.length === 1} + onLongPress={() => {}} + nonPersonalAndWorkspaceCards={nonPersonalAndWorkspaceCards} + onUndelete={handleUndelete} + policyForMovingExpenses={policyForMovingExpenses} + shouldHideTableHeader + /> + + ); + } + return null; + } + const isDisabled = item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; - const shouldApplyAnimation = shouldAnimate && index < data.length - 1; + const shouldApplyAnimation = shouldAnimate && index < expandedData.length - 1; const newTransactionID = item.keyForList ? newTransactionIDByItemKey.get(item.keyForList) : undefined; - // Apply selection lazily per row so we don't rebuild a list-wide wrapper structure on every render. const {itemWithSelection} = applySelectionToItem(item, canSelectMultiple, selectedTransactions); + const isGroupItem = 'transactions' in item; + const groupKey = item.keyForList ?? ''; + const isGroupExpanded = isGroupItem && expandedGroupKeys.has(groupKey); + return ( toggleGroupExpansion(groupKey) : undefined} /> ); @@ -477,7 +656,7 @@ function SearchList({ groupBy, newTransactionIDByItemKey, shouldAnimate, - data.length, + expandedData.length, styles.overflowHidden, hasItemsBeingRemoved, ListItem, @@ -504,6 +683,9 @@ function SearchList({ handleUndelete, firstVisibleIndex, lastVisibleIndex, + expandedGroupKeys, + toggleGroupExpansion, + styles.mh5, ], ); @@ -552,16 +734,16 @@ function SearchList({ )} void} keyExtractor={keyExtractor} onScroll={onScroll} showsVerticalScrollIndicator={false} ref={listRef} columns={columns} scrollToIndex={scrollToIndex} - flattenedItemsLength={data.length} + flattenedItemsLength={expandedData.length} onEndReached={onEndReached} onEndReachedThreshold={onEndReachedThreshold} ListFooterComponent={ListFooterComponent} @@ -572,6 +754,8 @@ function SearchList({ selectedTransactions={selectedTransactions} policyForMovingExpenses={policyForMovingExpenses} nonPersonalAndWorkspaceCards={nonPersonalAndWorkspaceCards} + stickyHeaderIndices={computedStickyHeaderIndices} + getItemType={getItemType} /> Date: Mon, 4 May 2026 19:10:39 +0530 Subject: [PATCH 2/3] refactor: scope SearchFlashListItem to SearchList only Signed-off-by: krishna2323 --- .../SearchList/BaseSearchList/index.native.tsx | 6 +++--- .../Search/SearchList/BaseSearchList/index.tsx | 8 ++++---- .../Search/SearchList/BaseSearchList/types.ts | 12 ++++++------ src/components/Search/SearchList/index.tsx | 17 +++++++++-------- 4 files changed, 22 insertions(+), 21 deletions(-) diff --git a/src/components/Search/SearchList/BaseSearchList/index.native.tsx b/src/components/Search/SearchList/BaseSearchList/index.native.tsx index 0d6dd3f033f0..a6955e67be8b 100644 --- a/src/components/Search/SearchList/BaseSearchList/index.native.tsx +++ b/src/components/Search/SearchList/BaseSearchList/index.native.tsx @@ -1,10 +1,10 @@ import {FlashList} from '@shopify/flash-list'; import React, {useCallback} from 'react'; import Animated from 'react-native-reanimated'; -import type {SearchFlashListItem} from '@components/Search/SearchList/ListItem/types'; +import type {SearchListItem} from '@components/Search/SearchList/ListItem/types'; import type BaseSearchListProps from './types'; -const AnimatedFlashListComponent = Animated.createAnimatedComponent(FlashList); +const AnimatedFlashListComponent = Animated.createAnimatedComponent(FlashList); function BaseSearchList({ data, @@ -22,7 +22,7 @@ function BaseSearchList({ getItemType, }: BaseSearchListProps) { const renderItemWithoutKeyboardFocus = useCallback( - ({item, index}: {item: SearchFlashListItem; index: number}) => { + ({item, index}: {item: SearchListItem; index: number}) => { return renderItem(item, index, false, undefined); }, [renderItem], diff --git a/src/components/Search/SearchList/BaseSearchList/index.tsx b/src/components/Search/SearchList/BaseSearchList/index.tsx index 4b7a89d2bfe5..82933a41ca0d 100644 --- a/src/components/Search/SearchList/BaseSearchList/index.tsx +++ b/src/components/Search/SearchList/BaseSearchList/index.tsx @@ -3,7 +3,7 @@ import {FlashList} from '@shopify/flash-list'; import React, {useCallback, useEffect, useMemo, useRef} from 'react'; import type {NativeSyntheticEvent} from 'react-native'; import Animated from 'react-native-reanimated'; -import type {SearchFlashListItem} from '@components/Search/SearchList/ListItem/types'; +import type {SearchListItem} from '@components/Search/SearchList/ListItem/types'; import type {ExtendedTargetedEvent} from '@components/SelectionList/ListItem/types'; import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager'; import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; @@ -12,7 +12,7 @@ import {addKeyDownPressListener, removeKeyDownPressListener} from '@libs/Keyboar import CONST from '@src/CONST'; import type BaseSearchListProps from './types'; -const AnimatedFlashListComponent = Animated.createAnimatedComponent(FlashList); +const AnimatedFlashListComponent = Animated.createAnimatedComponent(FlashList); function BaseSearchList({ data, @@ -62,7 +62,7 @@ function BaseSearchList({ }); const renderItemWithKeyboardFocus = useCallback( - ({item, index}: {item: SearchFlashListItem; index: number}) => { + ({item, index}: {item: SearchListItem; index: number}) => { const isItemFocused = focusedIndex === index; const onFocus = (event: NativeSyntheticEvent) => { @@ -87,7 +87,7 @@ function BaseSearchList({ const selectFocusedOption = useCallback(() => { const focusedItem = data.at(focusedIndex); - if (!focusedItem || ('itemType' in focusedItem && typeof focusedItem.itemType === 'string')) { + if (!focusedItem) { return; } diff --git a/src/components/Search/SearchList/BaseSearchList/types.ts b/src/components/Search/SearchList/BaseSearchList/types.ts index 081f1306eae0..2afbc6c1e3c0 100644 --- a/src/components/Search/SearchList/BaseSearchList/types.ts +++ b/src/components/Search/SearchList/BaseSearchList/types.ts @@ -1,13 +1,13 @@ import type {FlashListProps, FlashListRef} from '@shopify/flash-list'; import type {ForwardedRef} from 'react'; import type {NativeSyntheticEvent} from 'react-native'; -import type {SearchFlashListItem} from '@components/Search/SearchList/ListItem/types'; +import type {SearchListItem} from '@components/Search/SearchList/ListItem/types'; import type {SearchColumnType, SelectedTransactions} from '@components/Search/types'; import type {ExtendedTargetedEvent} from '@components/SelectionList/ListItem/types'; import type {CardList, Policy, Transaction} from '@src/types/onyx'; type BaseSearchListProps = Pick< - FlashListProps, + FlashListProps, | 'onScroll' | 'contentContainerStyle' | 'onEndReached' @@ -21,10 +21,10 @@ type BaseSearchListProps = Pick< | 'getItemType' > & { /** The data to display in the list */ - data: SearchFlashListItem[]; + data: SearchListItem[]; /** The function to render each item in the list */ - renderItem: (item: SearchFlashListItem, index: number, isItemFocused: boolean, onFocus?: (event: NativeSyntheticEvent) => void) => React.JSX.Element | null; + renderItem: (item: SearchListItem, index: number, isItemFocused: boolean, onFocus?: (event: NativeSyntheticEvent) => void) => React.JSX.Element | null; /** The columns that might change to trigger re-render via extraData */ columns: SearchColumnType[]; @@ -36,10 +36,10 @@ type BaseSearchListProps = Pick< flattenedItemsLength: number; /** The callback, which is run when a row is pressed */ - onSelectRow: (item: SearchFlashListItem) => void; + onSelectRow: (item: SearchListItem) => void; /** The ref to the list */ - ref: ForwardedRef>; + ref: ForwardedRef>; /** The function to scroll to an index */ scrollToIndex?: (index: number, animated?: boolean) => void; diff --git a/src/components/Search/SearchList/index.tsx b/src/components/Search/SearchList/index.tsx index c95752a1e472..400ac4722715 100644 --- a/src/components/Search/SearchList/index.tsx +++ b/src/components/Search/SearchList/index.tsx @@ -41,6 +41,7 @@ import {columnsSelector} from '@src/selectors/AdvancedSearchFiltersForm'; import type {CardList, Policy, Transaction, TransactionViolations} from '@src/types/onyx'; import type SearchResults from '@src/types/onyx/SearchResults'; import BaseSearchList from './BaseSearchList'; +import type BaseSearchListProps from './BaseSearchList/types'; import type ChatListItem from './ListItem/ChatListItem'; import type ExpenseReportListItem from './ListItem/ExpenseReportListItem'; import type TaskListItem from './ListItem/TaskListItem'; @@ -115,7 +116,7 @@ type SearchListProps = Pick, 'onScroll' | 'conten columns: SearchColumnType[]; /** Called when the viewability of rows changes, as defined by the viewabilityConfig prop. */ - onViewableItemsChanged?: (info: {changed: Array>; viewableItems: Array>}) => void; + onViewableItemsChanged?: (info: {changed: Array>; viewableItems: Array>}) => void; /** Invoked on mount and layout changes */ onLayout?: () => void; @@ -398,7 +399,7 @@ function SearchList({ const {translate} = useLocalize(); const {isOffline} = useNetwork(); - const listRef = useRef>(null); + const listRef = useRef>(null); const {isKeyboardShown} = useKeyboardState(); const {safeAreaPaddingBottomStyle} = useSafeAreaPaddings(); const prevDataLength = usePrevious(data.length); @@ -734,10 +735,10 @@ function SearchList({ )} void} - keyExtractor={keyExtractor} + data={expandedData as SearchListItem[]} + renderItem={renderItem as BaseSearchListProps['renderItem']} + onSelectRow={onSelectRow} + keyExtractor={keyExtractor as BaseSearchListProps['keyExtractor']} onScroll={onScroll} showsVerticalScrollIndicator={false} ref={listRef} @@ -747,7 +748,7 @@ function SearchList({ onEndReached={onEndReached} onEndReachedThreshold={onEndReachedThreshold} ListFooterComponent={ListFooterComponent} - onViewableItemsChanged={onViewableItemsChanged} + onViewableItemsChanged={onViewableItemsChanged as BaseSearchListProps['onViewableItemsChanged']} onLayout={onLayout} contentContainerStyle={contentContainerStyle} newTransactions={newTransactions} @@ -755,7 +756,7 @@ function SearchList({ policyForMovingExpenses={policyForMovingExpenses} nonPersonalAndWorkspaceCards={nonPersonalAndWorkspaceCards} stickyHeaderIndices={computedStickyHeaderIndices} - getItemType={getItemType} + getItemType={getItemType as BaseSearchListProps['getItemType']} /> Date: Mon, 4 May 2026 20:05:08 +0530 Subject: [PATCH 3/3] feat: make both group header and table header sticky in expanded groups Signed-off-by: krishna2323 --- .../ListItem/TransactionGroupListExpanded.tsx | 85 +++--------- .../ListItem/TransactionGroupListItem.tsx | 109 +++++++--------- .../Search/SearchList/ListItem/types.ts | 20 +-- src/components/Search/SearchList/index.tsx | 122 ++++++++++-------- 4 files changed, 144 insertions(+), 192 deletions(-) diff --git a/src/components/Search/SearchList/ListItem/TransactionGroupListExpanded.tsx b/src/components/Search/SearchList/ListItem/TransactionGroupListExpanded.tsx index 8eff435eb0fa..644ce0309b15 100644 --- a/src/components/Search/SearchList/ListItem/TransactionGroupListExpanded.tsx +++ b/src/components/Search/SearchList/ListItem/TransactionGroupListExpanded.tsx @@ -1,4 +1,4 @@ -import React, {useCallback} from 'react'; +import React from 'react'; import {View} from 'react-native'; import ActivityIndicator from '@components/ActivityIndicator'; import Button from '@components/Button'; @@ -10,7 +10,6 @@ import type {ListItem} from '@components/SelectionList/types'; import Text from '@components/Text'; import TransactionItemRow from '@components/TransactionItemRow'; import {useWideRHPActions} from '@components/WideRHPContextProvider'; -import useActionLoadingReportIDs from '@hooks/useActionLoadingReportIDs'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; @@ -18,13 +17,12 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; -import {search} from '@libs/actions/Search'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {getReportIDForTransaction} from '@libs/MoneyRequestReportUtils'; import Navigation from '@libs/Navigation/Navigation'; import {getReportAction} from '@libs/ReportActionsUtils'; import {getReportOrDraftReport} from '@libs/ReportUtils'; -import {createAndOpenSearchTransactionThread, getColumnsToShow, getSections, getTableMinWidth} from '@libs/SearchUIUtils'; +import {createAndOpenSearchTransactionThread, getColumnsToShow, getTableMinWidth} from '@libs/SearchUIUtils'; import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; import {getTransactionViolations, isDeletedTransaction} from '@libs/TransactionUtils'; import type {TransactionPreviewData} from '@userActions/Search'; @@ -33,7 +31,6 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import {columnsSelector} from '@src/selectors/AdvancedSearchFiltersForm'; -import type SearchResults from '@src/types/onyx/SearchResults'; import type {TransactionGroupListExpandedProps, TransactionListItemType} from './types'; function TransactionGroupListExpanded({ @@ -66,65 +63,16 @@ function TransactionGroupListExpanded({ const styles = useThemeStyles(); const {windowWidth} = useWindowDimensions(); const currentUserDetails = useCurrentUserPersonalDetails(); - const {translate, formatPhoneNumber} = useLocalize(); + const {translate} = useLocalize(); const [isMobileSelectionModeEnabled] = useOnyx(ONYXKEYS.RAM_ONLY_MOBILE_SELECTION_MODE); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [betas] = useOnyx(ONYXKEYS.BETAS); const [visibleColumns] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM, {selector: columnsSelector}); const [allTransactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION); - const isSelfFetching = !transactionsSnapshot; - const [fetchedSnapshot] = useOnyx(`${ONYXKEYS.COLLECTION.SNAPSHOT}${transactionsQueryJSON?.hash ?? ''}`); - const resolvedSnapshot = transactionsSnapshot ?? (isSelfFetching ? (fetchedSnapshot as SearchResults | undefined) : undefined); - const isActionLoadingSet = useActionLoadingReportIDs(); - const [allReportMetadata] = useOnyx(ONYXKEYS.COLLECTION.REPORT_METADATA); - const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); - const [cardFeeds] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER); - const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); + const transactionsSnapshotMetadata = transactionsSnapshot?.search; - let resolvedTransactions: TransactionListItemType[]; - if (!isSelfFetching || isExpenseReportType) { - resolvedTransactions = transactions; - } else if (!resolvedSnapshot?.data) { - resolvedTransactions = []; - } else { - const [sectionData] = getSections({ - type: CONST.SEARCH.DATA_TYPES.EXPENSE, - data: resolvedSnapshot.data, - currentAccountID: currentUserDetails.accountID, - currentUserEmail: currentUserDetails.email ?? '', - translate, - formatPhoneNumber, - bankAccountList, - isActionLoadingSet, - allReportMetadata, - cardFeeds, - conciergeReportID, - }) as [TransactionListItemType[], number, boolean]; - resolvedTransactions = sectionData; - } - - const resolvedSearchTransactions = useCallback( - (pageSize = 0) => { - if (!isSelfFetching || !transactionsQueryJSON) { - searchTransactions(pageSize); - return; - } - search({ - queryJSON: transactionsQueryJSON, - searchKey: undefined, - offset: (resolvedSnapshot?.search?.offset ?? 0) + pageSize, - shouldCalculateTotals: false, - isLoading: !!resolvedSnapshot?.search?.isLoading, - isOffline, - }); - }, - [isSelfFetching, transactionsQueryJSON, searchTransactions, resolvedSnapshot?.search?.offset, resolvedSnapshot?.search?.isLoading, isOffline], - ); - - const transactionsSnapshotMetadata = resolvedSnapshot?.search; - - const visibleTransactions = isExpenseReportType ? resolvedTransactions.slice(0, transactionsVisibleLimit) : resolvedTransactions; + const visibleTransactions = isExpenseReportType ? transactions.slice(0, transactionsVisibleLimit) : transactions; const isLastTransaction = (index: number) => { return index === visibleTransactions.length - 1; @@ -132,27 +80,28 @@ function TransactionGroupListExpanded({ let currentColumns = columns ?? []; if (!isExpenseReportType) { - if (!resolvedSnapshot?.data) { + if (!transactionsSnapshot?.data) { currentColumns = []; } else { - currentColumns = getColumnsToShow({currentAccountID: accountID, data: resolvedSnapshot?.data, visibleColumns, type: resolvedSnapshot?.search.type}); + currentColumns = getColumnsToShow({currentAccountID: accountID, data: transactionsSnapshot?.data, visibleColumns, type: transactionsSnapshot?.search.type}); } } - const shouldDisplayShowMoreButton = isExpenseReportType ? resolvedTransactions.length > transactionsVisibleLimit : !!transactionsSnapshotMetadata?.hasMoreResults && !isOffline; + // Currently only the transaction report groups have transactions where the empty view makes sense + const shouldDisplayShowMoreButton = isExpenseReportType ? transactions.length > transactionsVisibleLimit : !!transactionsSnapshotMetadata?.hasMoreResults && !isOffline; const currentOffset = transactionsSnapshotMetadata?.offset ?? 0; - const shouldShowLoadingOnSearch = !!(!resolvedTransactions?.length && transactionsSnapshotMetadata?.isLoading) || currentOffset > 0; + const shouldShowLoadingOnSearch = !!(!transactions?.length && transactionsSnapshotMetadata?.isLoading) || currentOffset > 0; const shouldDisplayLoadingIndicator = !isExpenseReportType && !!transactionsSnapshotMetadata?.isLoading && shouldShowLoadingOnSearch; const {isLargeScreenWidth} = useResponsiveLayout(); - const isAmountColumnWide = resolvedTransactions.some((transaction) => transaction.isAmountColumnWide); - const isTaxAmountColumnWide = resolvedTransactions.some((transaction) => transaction.isTaxAmountColumnWide); - const shouldShowYearForSomeTransaction = resolvedTransactions.some((transaction) => transaction.shouldShowYear); + const isAmountColumnWide = transactions.some((transaction) => transaction.isAmountColumnWide); + const isTaxAmountColumnWide = transactions.some((transaction) => transaction.isTaxAmountColumnWide); + const shouldShowYearForSomeTransaction = transactions.some((transaction) => transaction.shouldShowYear); const amountColumnSize = isAmountColumnWide ? CONST.SEARCH.TABLE_COLUMN_SIZES.WIDE : CONST.SEARCH.TABLE_COLUMN_SIZES.NORMAL; const taxAmountColumnSize = isTaxAmountColumnWide ? CONST.SEARCH.TABLE_COLUMN_SIZES.WIDE : CONST.SEARCH.TABLE_COLUMN_SIZES.NORMAL; const dateColumnSize = shouldShowYearForSomeTransaction ? CONST.SEARCH.TABLE_COLUMN_SIZES.WIDE : CONST.SEARCH.TABLE_COLUMN_SIZES.NORMAL; - const isActionColumnWide = resolvedTransactions.some((transaction) => !!transaction.isActionColumnWide || isDeletedTransaction(transaction)); + const isActionColumnWide = transactions.some((transaction) => !!transaction.isActionColumnWide || isDeletedTransaction(transaction)); const {markReportIDAsExpense} = useWideRHPActions(); const selectRow = onSelectRow as (item: TItem, transactionPreviewData?: TransactionPreviewData) => void; @@ -197,7 +146,7 @@ function TransactionGroupListExpanded({ return; } - const siblingTransactionIDs = resolvedTransactions + const siblingTransactionIDs = transactions .filter((transaction) => transaction.reportAction?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) .map((transaction) => transaction.transactionID); @@ -213,7 +162,7 @@ function TransactionGroupListExpanded({ if (isExpenseReportType) { setTransactionsVisibleLimit((currentPageSize) => currentPageSize + CONST.TRANSACTION.RESULTS_PAGE_SIZE); } else if (!isOffline && transactionsQueryJSON) { - resolvedSearchTransactions(CONST.SEARCH.RESULTS_PAGE_SIZE); + searchTransactions(CONST.SEARCH.RESULTS_PAGE_SIZE); } }; @@ -281,7 +230,7 @@ function TransactionGroupListExpanded({ )} {visibleTransactions.map((transaction, index) => { const shouldShowBottomBorder = !isLastTransaction(index); - const exportedReportActions = Object.values(resolvedSnapshot?.data?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transaction?.reportID}`] ?? {}); + const exportedReportActions = Object.values(transactionsSnapshot?.data?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transaction?.reportID}`] ?? {}); const transactionRow = ( ({ ownerBillingGracePeriodEnd, onUndelete, policyForMovingExpenses, - isExpanded: isExpandedProp, - onToggleExpansion, + onExpandChange, + shouldHideTableHeader, + shouldHideHeader, + shouldHideContent, }: TransactionGroupListItemProps) { const groupItem = item as unknown as TransactionGroupListItemType; @@ -119,8 +121,7 @@ function TransactionGroupListItem({ const isExpenseReportType = searchType === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT; const [transactionsVisibleLimit, setTransactionsVisibleLimit] = useState(CONST.TRANSACTION.RESULTS_PAGE_SIZE as number); - const [isExpandedLocal, setIsExpandedLocal] = useState(false); - const isExpanded = isExpandedProp ?? isExpandedLocal; + const [isExpanded, setIsExpanded] = useState(false); const isActionLoadingSet = useActionLoadingReportIDs(); const [allReportMetadata] = useOnyx(ONYXKEYS.COLLECTION.REPORT_METADATA); const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); @@ -264,24 +265,18 @@ function TransactionGroupListItem({ }, [isScreenFocused, isExpanded, isExpenseReportType, groupItem.transactionsQueryJSON, isOffline, transactionsSnapshot?.search?.isLoading]); const handleToggle = () => { - if (onToggleExpansion) { - onToggleExpansion(); - if (!isExpanded) { + setIsExpanded((prev) => { + const newExpandedState = !prev; + + if (newExpandedState) { refreshTransactions(); } else { setTransactionsVisibleLimit(CONST.TRANSACTION.RESULTS_PAGE_SIZE); } - } else { - setIsExpandedLocal((prev) => { - const newExpandedState = !prev; - if (newExpandedState) { - refreshTransactions(); - } else { - setTransactionsVisibleLimit(CONST.TRANSACTION.RESULTS_PAGE_SIZE); - } - return newExpandedState; - }); - } + + onExpandChange?.(item.keyForList ?? '', newExpandedState); + return newExpandedState; + }); }; const onPress = () => { @@ -525,8 +520,6 @@ function TransactionGroupListItem({ } } - const isControlledExpansion = !!onToggleExpansion; - return ( ({ > {({hovered}) => ( - {isControlledExpansion ? ( - getHeader(hovered) - ) : ( - - - - )} + + + )} diff --git a/src/components/Search/SearchList/ListItem/types.ts b/src/components/Search/SearchList/ListItem/types.ts index 0f2817a8c199..c25624811357 100644 --- a/src/components/Search/SearchList/ListItem/types.ts +++ b/src/components/Search/SearchList/ListItem/types.ts @@ -359,8 +359,8 @@ type ExpandedGroupTableHeaderItem = { transactionsQueryHash?: number; }; -type ExpandedGroupContentItem = { - itemType: 'expandedGroupContent'; +type ExpandedGroupSummaryHeaderItem = { + itemType: 'expandedGroupSummaryHeader'; keyForList: string; parentGroupKeyForList: string; groupItem: TransactionGroupListItemType; @@ -368,7 +368,7 @@ type ExpandedGroupContentItem = { type SearchListItem = TransactionListItemType | TransactionGroupListItemType | ReportActionListItemType | TaskListItemType | ExpenseReportListItemType; -type SearchFlashListItem = SearchListItem | ExpandedGroupTableHeaderItem | ExpandedGroupContentItem; +type SearchFlashListItem = SearchListItem | ExpandedGroupTableHeaderItem | ExpandedGroupSummaryHeaderItem; type TransactionCardGroupListItemType = TransactionGroupListItemType & {groupedBy: typeof CONST.SEARCH.GROUP_BY.CARD} & PersonalDetails & SearchCardGroup & { @@ -464,10 +464,14 @@ type TransactionGroupListItemProps = ListItemProps void; - /** Whether this group is expanded (controlled from SearchList) */ - isExpanded?: boolean; - /** Callback to toggle expansion from SearchList */ - onToggleExpansion?: () => void; + /** Callback when expansion state changes */ + onExpandChange?: (keyForList: string, isExpanded: boolean) => void; + /** Whether to hide the table header inside the expanded content */ + shouldHideTableHeader?: boolean; + /** Whether to hide the group summary header (content-only mode for expanded groups) */ + shouldHideHeader?: boolean; + /** Whether to hide the expanded content (header-only mode for sticky group header) */ + shouldHideContent?: boolean; }; type TransactionGroupListExpandedProps = Pick< @@ -533,6 +537,6 @@ export type { ReportActionListItemType, UnreportedExpenseListItemType, ExpandedGroupTableHeaderItem, - ExpandedGroupContentItem, + ExpandedGroupSummaryHeaderItem, SearchFlashListItem, }; diff --git a/src/components/Search/SearchList/index.tsx b/src/components/Search/SearchList/index.tsx index 400ac4722715..017ea499f7e6 100644 --- a/src/components/Search/SearchList/index.tsx +++ b/src/components/Search/SearchList/index.tsx @@ -45,11 +45,10 @@ import type BaseSearchListProps from './BaseSearchList/types'; import type ChatListItem from './ListItem/ChatListItem'; import type ExpenseReportListItem from './ListItem/ExpenseReportListItem'; import type TaskListItem from './ListItem/TaskListItem'; -import TransactionGroupListExpanded from './ListItem/TransactionGroupListExpanded'; import type TransactionGroupListItem from './ListItem/TransactionGroupListItem'; import type TransactionListItem from './ListItem/TransactionListItem'; import type { - ExpandedGroupContentItem, + ExpandedGroupSummaryHeaderItem, ExpandedGroupTableHeaderItem, ReportActionListItemType, SearchFlashListItem, @@ -243,8 +242,8 @@ function ExpandedGroupTableHeader({item}: {item: ExpandedGroupTableHeaderItem}) ); } -function isExpandedGroupItem(item: SearchFlashListItem): item is ExpandedGroupTableHeaderItem | ExpandedGroupContentItem { - return typeof item === 'object' && 'itemType' in item && typeof item.itemType === 'string'; +function isExpandedGroupItem(item: SearchFlashListItem): item is ExpandedGroupTableHeaderItem | ExpandedGroupSummaryHeaderItem { + return typeof item === 'object' && 'itemType' in item && (item.itemType === 'expandedGroupTableHeader' || item.itemType === 'expandedGroupSummaryHeader'); } function SearchList({ @@ -279,19 +278,20 @@ function SearchList({ ref, }: SearchListProps) { const styles = useThemeStyles(); + const theme = useTheme(); const expensifyIcons = useMemoizedLazyExpensifyIcons(['CheckSquare']); const {hash, groupBy, type} = queryJSON; const [expandedGroupKeys, setExpandedGroupKeys] = useState>(new Set()); - const toggleGroupExpansion = useCallback((keyForList: string) => { + const handleExpandChange = useCallback((keyForList: string, isExpanded: boolean) => { setExpandedGroupKeys((prev) => { const next = new Set(prev); - if (next.has(keyForList)) { - next.delete(keyForList); - } else { + if (isExpanded) { next.add(keyForList); + } else { + next.delete(keyForList); } return next; }); @@ -323,33 +323,33 @@ function SearchList({ const headerIndices: number[] = []; for (const item of data) { - result.push(item); const key = item.keyForList ?? ''; - if (!expandedGroupKeys.has(key)) { - continue; + if (expandedGroupKeys.has(key)) { + const summaryHeaderItem: ExpandedGroupSummaryHeaderItem = { + itemType: 'expandedGroupSummaryHeader', + keyForList: `${key}_summaryHeader`, + parentGroupKeyForList: key, + groupItem: item, + }; + headerIndices.push(result.length); + result.push(summaryHeaderItem); + + const tableHeaderItem: ExpandedGroupTableHeaderItem = { + itemType: 'expandedGroupTableHeader', + keyForList: `${key}_tableHeader`, + parentGroupKeyForList: key, + columns: columns ?? [], + groupBy, + canSelectMultiple, + isExpenseReportType, + transactionsQueryHash: item.transactionsQueryJSON?.hash, + }; + headerIndices.push(result.length); + result.push(tableHeaderItem); } - const tableHeaderItem: ExpandedGroupTableHeaderItem = { - itemType: 'expandedGroupTableHeader', - keyForList: `${key}_tableHeader`, - parentGroupKeyForList: key, - columns: columns ?? [], - groupBy, - canSelectMultiple, - isExpenseReportType, - transactionsQueryHash: item.transactionsQueryJSON?.hash, - }; - headerIndices.push(result.length); - result.push(tableHeaderItem); - - const contentItem: ExpandedGroupContentItem = { - itemType: 'expandedGroupContent', - keyForList: `${key}_content`, - parentGroupKeyForList: key, - groupItem: item, - }; - result.push(contentItem); + result.push(item); } return { @@ -564,33 +564,42 @@ function SearchList({ if (item.itemType === 'expandedGroupTableHeader') { return ; } - if (item.itemType === 'expandedGroupContent') { + if (item.itemType === 'expandedGroupSummaryHeader') { + const {itemWithSelection} = applySelectionToItem(item.groupItem, canSelectMultiple, selectedTransactions); return ( - - + {}} - isEmpty={item.groupItem.transactions.length === 0} - shouldDisplayEmptyView={false} - isExpenseReportType={type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT} - transactionsQueryJSON={item.groupItem.transactionsQueryJSON} - searchTransactions={() => {}} - isInSingleTransactionReport={item.groupItem.transactions.length === 1} - onLongPress={() => {}} nonPersonalAndWorkspaceCards={nonPersonalAndWorkspaceCards} + onFocus={onFocus} onUndelete={handleUndelete} - policyForMovingExpenses={policyForMovingExpenses} - shouldHideTableHeader + keyForList={item.groupItem.keyForList} + isFirstItem={index === firstVisibleIndex} + isLastItem={false} + onExpandChange={handleExpandChange} + shouldHideContent /> ); @@ -646,16 +655,14 @@ function SearchList({ keyForList={item.keyForList} isFirstItem={index === firstVisibleIndex} isLastItem={index === lastVisibleIndex && !ListFooterComponent} - isExpanded={isGroupExpanded} - onToggleExpansion={isGroupItem ? () => toggleGroupExpansion(groupKey) : undefined} + onExpandChange={handleExpandChange} + shouldHideTableHeader={isGroupExpanded} + shouldHideHeader={isGroupExpanded} /> ); }, [ - type, - groupBy, - newTransactionIDByItemKey, shouldAnimate, expandedData.length, styles.overflowHidden, @@ -685,8 +692,11 @@ function SearchList({ firstVisibleIndex, lastVisibleIndex, expandedGroupKeys, - toggleGroupExpansion, - styles.mh5, + handleExpandChange, + newTransactionIDByItemKey, + type, + groupBy, + theme.appBG, ], );