diff --git a/src/components/Search/SearchList/BaseSearchList/index.native.tsx b/src/components/Search/SearchList/BaseSearchList/index.native.tsx index ad5d017c708f..a6955e67be8b 100644 --- a/src/components/Search/SearchList/BaseSearchList/index.native.tsx +++ b/src/components/Search/SearchList/BaseSearchList/index.native.tsx @@ -18,6 +18,8 @@ function BaseSearchList({ onViewableItemsChanged, onLayout, contentContainerStyle, + stickyHeaderIndices, + getItemType, }: BaseSearchListProps) { const renderItemWithoutKeyboardFocus = useCallback( ({item, index}: {item: SearchListItem; index: number}) => { @@ -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..82933a41ca0d 100644 --- a/src/components/Search/SearchList/BaseSearchList/index.tsx +++ b/src/components/Search/SearchList/BaseSearchList/index.tsx @@ -34,6 +34,8 @@ function BaseSearchList({ selectedTransactions, policyForMovingExpenses, nonPersonalAndWorkspaceCards, + stickyHeaderIndices, + getItemType, }: BaseSearchListProps) { const hasKeyBeenPressed = useRef(false); const isFocused = useIsFocused(); @@ -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..2afbc6c1e3c0 100644 --- a/src/components/Search/SearchList/BaseSearchList/types.ts +++ b/src/components/Search/SearchList/BaseSearchList/types.ts @@ -17,12 +17,14 @@ type BaseSearchListProps = Pick< | 'keyExtractor' | 'showsVerticalScrollIndicator' | 'onLayout' + | 'stickyHeaderIndices' + | 'getItemType' > & { /** The data to display in the list */ data: SearchListItem[]; /** 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: 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[]; diff --git a/src/components/Search/SearchList/ListItem/TransactionGroupListExpanded.tsx b/src/components/Search/SearchList/ListItem/TransactionGroupListExpanded.tsx index 087288f8149b..644ce0309b15 100644 --- a/src/components/Search/SearchList/ListItem/TransactionGroupListExpanded.tsx +++ b/src/components/Search/SearchList/ListItem/TransactionGroupListExpanded.tsx @@ -57,6 +57,7 @@ function TransactionGroupListExpanded({ onLongPress, nonPersonalAndWorkspaceCards, onUndelete, + shouldHideTableHeader, }: TransactionGroupListExpandedProps) { const theme = useTheme(); const styles = useThemeStyles(); @@ -205,7 +206,7 @@ function TransactionGroupListExpanded({ const content = ( - {isLargeScreenWidth && !(isEmpty && shouldDisplayLoadingIndicator) && ( + {isLargeScreenWidth && !(isEmpty && shouldDisplayLoadingIndicator) && !shouldHideTableHeader && ( <> ({ userBillingGracePeriodEnds, ownerBillingGracePeriodEnd, onUndelete, + policyForMovingExpenses, + onExpandChange, + shouldHideTableHeader, + shouldHideHeader, + shouldHideContent, }: TransactionGroupListItemProps) { const groupItem = item as unknown as TransactionGroupListItemType; @@ -269,6 +274,7 @@ function TransactionGroupListItem({ setTransactionsVisibleLimit(CONST.TRANSACTION.RESULTS_PAGE_SIZE); } + onExpandChange?.(item.keyForList ?? '', newExpandedState); return newExpandedState; }); }; @@ -550,11 +556,11 @@ function TransactionGroupListItem({ {({hovered}) => ( @@ -581,6 +587,8 @@ function TransactionGroupListItem({ onLongPress={onExpandedRowLongPress} nonPersonalAndWorkspaceCards={nonPersonalAndWorkspaceCards} onUndelete={onUndelete} + policyForMovingExpenses={policyForMovingExpenses} + shouldHideTableHeader={shouldHideTableHeader} /> diff --git a/src/components/Search/SearchList/ListItem/types.ts b/src/components/Search/SearchList/ListItem/types.ts index a87b1e3c0ba9..c25624811357 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 ExpandedGroupSummaryHeaderItem = { + itemType: 'expandedGroupSummaryHeader'; + keyForList: string; + parentGroupKeyForList: string; + groupItem: TransactionGroupListItemType; +}; + type SearchListItem = TransactionListItemType | TransactionGroupListItemType | ReportActionListItemType | TaskListItemType | ExpenseReportListItemType; +type SearchFlashListItem = SearchListItem | ExpandedGroupTableHeaderItem | ExpandedGroupSummaryHeaderItem; + 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,14 @@ type TransactionGroupListItemProps = ListItemProps 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< @@ -472,6 +500,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 +536,7 @@ export type { TransactionListItemProps, ReportActionListItemType, UnreportedExpenseListItemType, + ExpandedGroupTableHeaderItem, + ExpandedGroupSummaryHeaderItem, + SearchFlashListItem, }; diff --git a/src/components/Search/SearchList/index.tsx b/src/components/Search/SearchList/index.tsx index fba50df6c661..017ea499f7e6 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 BaseSearchListProps from './BaseSearchList/types'; import type ChatListItem from './ListItem/ChatListItem'; import type ExpenseReportListItem from './ListItem/ExpenseReportListItem'; import type TaskListItem from './ListItem/TaskListItem'; import type TransactionGroupListItem from './ListItem/TransactionGroupListItem'; import type TransactionListItem from './ListItem/TransactionListItem'; import type { + ExpandedGroupSummaryHeaderItem, + ExpandedGroupTableHeaderItem, ReportActionListItemType, + SearchFlashListItem, TaskListItemType, TransactionCardGroupListItemType, TransactionCategoryGroupListItemType, @@ -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 | ExpandedGroupSummaryHeaderItem { + return typeof item === 'object' && 'itemType' in item && (item.itemType === 'expandedGroupTableHeader' || item.itemType === 'expandedGroupSummaryHeader'); +} + function SearchList({ data, ListItem, @@ -226,9 +278,25 @@ 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 handleExpandChange = useCallback((keyForList: string, isExpanded: boolean) => { + setExpandedGroupKeys((prev) => { + const next = new Set(prev); + if (isExpanded) { + next.add(keyForList); + } else { + next.delete(keyForList); + } + return next; + }); + }, []); + const flattenedItems = useMemo(() => { if (groupBy || type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT) { if (!isTransactionGroupListItemArray(data)) { @@ -245,6 +313,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) { + const key = item.keyForList ?? ''; + + 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); + } + + result.push(item); + } + + 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); @@ -417,19 +530,93 @@ 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 === 'expandedGroupSummaryHeader') { + const {itemWithSelection} = applySelectionToItem(item.groupItem, canSelectMultiple, selectedTransactions); + return ( + + + + ); + } + 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 ( ); }, [ - type, - groupBy, - newTransactionIDByItemKey, shouldAnimate, - data.length, + expandedData.length, styles.overflowHidden, hasItemsBeingRemoved, ListItem, @@ -504,6 +691,12 @@ function SearchList({ handleUndelete, firstVisibleIndex, lastVisibleIndex, + expandedGroupKeys, + handleExpandChange, + newTransactionIDByItemKey, + type, + groupBy, + theme.appBG, ], ); @@ -552,26 +745,28 @@ function SearchList({ )}