From 21baec9a420f05b8b3688b43972c65ee72a4d224 Mon Sep 17 00:00:00 2001 From: bartlomiej obudzinski Date: Mon, 6 Jul 2026 10:46:53 +0200 Subject: [PATCH 1/4] refactor: finalize Search router and delete legacy SearchList shell --- config/eslint/eslint.seatbelt.tsv | 3 +- .../Search/ExpenseFlatSearchView.tsx | 4 +- .../Search/ExpenseGroupedSearchView.tsx | 10 +- src/components/Search/SearchList/index.tsx | 667 ------------------ .../Search/hooks/useSearchListViewState.ts | 2 +- src/components/Search/index.tsx | 177 +---- src/libs/SearchUIUtils.ts | 73 +- .../Search/ExpenseGroupedSearchViewTest.tsx | 7 - .../unit/Search/SearchListRenderCountTest.tsx | 248 ------- tests/unit/Search/SearchUIUtilsTest.ts | 46 -- 10 files changed, 91 insertions(+), 1146 deletions(-) delete mode 100644 src/components/Search/SearchList/index.tsx delete mode 100644 tests/unit/Search/SearchListRenderCountTest.tsx diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv index 9ccf499f832f..ec76052fb3cd 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -364,7 +364,6 @@ "../../src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemWide.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/Search/SearchList/ListItem/TransactionListItem/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 4 "../../src/components/Search/SearchList/ListItem/WithdrawalIDListItemHeader.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/components/Search/SearchList/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 7 "../../src/components/Search/SearchMultipleSelectionPicker.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/Search/SearchPageHeader/SearchActionsBarCreateButton.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/Search/SearchPageHeader/SearchFilterBar.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 @@ -727,7 +726,7 @@ "../../src/libs/ReportUtils.ts" "rulesdir/no-onyx-connect" 16 "../../src/libs/SearchAutocompleteUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 "../../src/libs/SearchQueryUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 72 -"../../src/libs/SearchUIUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 69 +"../../src/libs/SearchUIUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 76 "../../src/libs/SearchUIUtils.ts" "no-restricted-imports" 1 "../../src/libs/SelectionScraper/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 "../../src/libs/SidebarUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 diff --git a/src/components/Search/ExpenseFlatSearchView.tsx b/src/components/Search/ExpenseFlatSearchView.tsx index 90a88fbd4e20..8a0485f7cba0 100644 --- a/src/components/Search/ExpenseFlatSearchView.tsx +++ b/src/components/Search/ExpenseFlatSearchView.tsx @@ -164,8 +164,8 @@ function ExpenseFlatSearchView({ const renderItem = (item: SearchListItem, index: number, isItemFocused: boolean, onFocus?: (event: NativeSyntheticEvent) => void) => { const isDisabled = isRowDeleted(item); - // Flat view always animates row exits, so the only gate is excluding the last row. - const shouldApplyAnimation = index < data.length - 1; + // Only expense row exits animate; invoice and trip transaction lists do not (matches the legacy per-type gate). + const shouldApplyAnimation = type === CONST.SEARCH.DATA_TYPES.EXPENSE && index < data.length - 1; return ( listItem.keyForList === `header_${item.keyForList}`) : -1; + if (!item) { + return; + } + const splitIndex = item.keyForList ? listData.findIndex((listItem) => listItem.keyForList === `header_${item.keyForList}`) : -1; scrollToListIndex(splitIndex !== -1 ? splitIndex : index, animated); }, }), @@ -337,7 +339,7 @@ function ExpenseGroupedSearchView({ const newTransactionID = item.keyForList ? newTransactionIDByItemKey.get(item.keyForList) : undefined; return ( void; -}; - -type SearchListProps = Pick, '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, transactionPreviewData?: TransactionPreviewData, event?: ModifiedMouseEvent) => void; - - /** Whether this is a multi-select list */ - canSelectMultiple: boolean; - - /** Styles to apply to SelectionList container */ - containerStyle?: StyleProp; - - /** Whether to prevent long press of options */ - shouldPreventLongPressRow?: boolean; - - /** Whether to animate the items in the list */ - shouldAnimate?: boolean; - - /** The search query */ - queryJSON: SearchQueryJSON; - - /** Columns to show */ - columns: SearchColumnType[]; - - /** Called when the viewability of rows changes, as defined by the viewabilityConfig prop. */ - onViewableItemsChanged?: (info: {changed: Array>; viewableItems: Array>}) => void; - - /** Invoked on mount and layout changes */ - onLayout?: () => void; - - /** Styles to apply to the content container */ - contentContainerStyle?: StyleProp; - - /** Whether mobile selection mode is enabled */ - isMobileSelectionModeEnabled: boolean; - - newTransactions?: Transaction[]; - - /** Non-personal and workspace cards (same drill path as former custom card names for rows) */ - nonPersonalAndWorkspaceCards?: CardList; - - /** All policies' tag lists, drilled from the list level so each row can resolve its policy's tags without an Onyx subscription per row */ - policyTags?: OnyxCollection; - - /** Whether all transactions have been loaded from snapshots in group-by views */ - hasLoadedAllTransactions?: boolean; - - /** Whether the action column should use its wider variant (e.g. when there is at least one deleted transaction) */ - isActionColumnWide?: boolean; - - /** Precomputed attendee-tracking boolean (derived from policy-for-moving-expenses) */ - isAttendeesEnabledForMovingPolicy?: boolean; - - /** Reference to the outer element */ - ref?: ForwardedRef; -}; - -const keyExtractor = (item: SearchListItem, index: number) => item.keyForList ?? `${index}`; - -function isTransactionGroupListItemArray(data: SearchListItem[]): data is TransactionGroupListItemType[] { - if (data.length <= 0) { - return false; - } - const firstElement = data.at(0); - return typeof firstElement === 'object' && 'transactions' in firstElement; -} - -function isTransactionMatchWithGroupItem(transaction: Transaction, groupItem: SearchListItem, groupBy: SearchGroupBy | undefined) { - if (groupBy === CONST.SEARCH.GROUP_BY.CARD) { - return transaction.cardID === (groupItem as TransactionCardGroupListItemType).cardID; - } - if (groupBy === CONST.SEARCH.GROUP_BY.FROM) { - return !!transaction.transactionID; - } - if (groupBy === CONST.SEARCH.GROUP_BY.CATEGORY) { - return (transaction.category ?? '') === ((groupItem as TransactionCategoryGroupListItemType).category ?? ''); - } - if (groupBy === CONST.SEARCH.GROUP_BY.MERCHANT) { - return (transaction.merchant ?? '') === ((groupItem as TransactionMerchantGroupListItemType).merchant ?? ''); - } - if (groupBy === CONST.SEARCH.GROUP_BY.MONTH) { - const monthGroup = groupItem as TransactionMonthGroupListItemType; - const transactionDateString = transaction.modifiedCreated ?? transaction.created ?? ''; - return DateUtils.isDateStringInMonth(transactionDateString, monthGroup.year, monthGroup.month); - } - if (groupBy === CONST.SEARCH.GROUP_BY.WEEK) { - const weekGroup = groupItem as TransactionWeekGroupListItemType; - const transactionDateString = transaction.modifiedCreated ?? transaction.created ?? ''; - const datePart = transactionDateString.substring(0, 10); - const {start: weekStart, end: weekEnd} = DateUtils.getWeekDateRange(weekGroup.week); - return datePart >= weekStart && datePart <= weekEnd; - } - if (groupBy === CONST.SEARCH.GROUP_BY.YEAR) { - const yearGroup = groupItem as TransactionYearGroupListItemType; - const transactionDateString = transaction.modifiedCreated ?? transaction.created ?? ''; - const transactionYear = parseInt(transactionDateString.substring(0, 4), 10); - return transactionYear === yearGroup.year; - } - if (groupBy === CONST.SEARCH.GROUP_BY.QUARTER) { - const quarterGroup = groupItem as TransactionQuarterGroupListItemType; - const transactionDateString = transaction.modifiedCreated ?? transaction.created ?? ''; - const transactionYear = parseInt(transactionDateString.substring(0, 4), 10); - const transactionMonth = parseInt(transactionDateString.substring(5, 7), 10); - // Calculate which quarter the transaction belongs to (1-4) - const transactionQuarter = Math.floor((transactionMonth - 1) / 3) + 1; - return transactionYear === quarterGroup.year && transactionQuarter === quarterGroup.quarter; - } - return false; -} - -function SearchList({ - data, - ListItem, - SearchTableHeader, - onSelectRow, - canSelectMultiple, - onScroll = () => {}, - contentContainerStyle, - onEndReachedThreshold, - onEndReached, - containerStyle, - ListFooterComponent, - shouldPreventLongPressRow, - queryJSON, - columns, - onViewableItemsChanged, - onLayout, - shouldAnimate, - isMobileSelectionModeEnabled, - newTransactions = [], - nonPersonalAndWorkspaceCards, - hasLoadedAllTransactions, - policyTags, - isActionColumnWide, - isAttendeesEnabledForMovingPolicy, - ref, -}: SearchListProps) { - const styles = useThemeStyles(); - const {toggle, toggleAll} = useSearchRowSelectionActions(); - const {selectedTransactions} = useSearchSelectionContext(); - - const {groupBy, type} = queryJSON; - const flattenedItems = useMemo(() => { - if (groupBy || type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT) { - if (!isTransactionGroupListItemArray(data)) { - return data; - } - return data.flatMap((item) => item.transactions); - } - return data; - }, [data, groupBy, type]); - const emptyReports = useMemo(() => { - if (isTransactionGroupListItemArray(data)) { - return data.filter((item) => item.transactions.length === 0 && item.keyForList); - } - return []; - }, [data]); - - const selectedItemsLength = useMemo(() => { - const selectedTransactionsCount = flattenedItems.reduce((acc, item) => { - const isTransactionSelected = !!(item?.keyForList && selectedTransactions[item.keyForList]?.isSelected); - return acc + (isTransactionSelected ? 1 : 0); - }, 0); - - if (isTransactionGroupListItemArray(data)) { - const selectedEmptyReports = emptyReports.reduce((acc, item) => { - const isEmptyReportSelected = !!(item.keyForList && selectedTransactions[item.keyForList]?.isSelected); - return acc + (isEmptyReportSelected ? 1 : 0); - }, 0); - - return selectedEmptyReports + selectedTransactionsCount; - } - - return selectedTransactionsCount; - }, [flattenedItems, data, emptyReports, selectedTransactions]); - - const totalItems = useMemo(() => { - if (isTransactionGroupListItemArray(data)) { - const selectableEmptyReports = emptyReports.filter((item) => item.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); - const selectableTransactions = flattenedItems.filter((item) => { - if ('pendingAction' in item) { - return item.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; - } - return true; - }); - return selectableEmptyReports.length + selectableTransactions.length; - } - - const selectableTransactions = flattenedItems.filter((item) => { - if ('pendingAction' in item) { - return item.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; - } - return true; - }); - return selectableTransactions.length; - }, [data, flattenedItems, emptyReports]); - - const {isOffline} = useNetwork(); - const listRef = useRef>(null); - const {isKeyboardShown} = useKeyboardState(); - const {safeAreaPaddingBottomStyle} = useSafeAreaPaddings(); - const prevDataLength = usePrevious(data.length); - // 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, isLargeScreenWidth} = useResponsiveLayout(); - - const hasItemsBeingRemoved = prevDataLength && prevDataLength > data.length; - const {isEditingCell, wasRecentlyEditingCell} = useEditingCellState(); - - const [expandedGroups, setExpandedGroups] = useState>(() => new Set()); - - const onToggleGroup = useCallback((key: string) => { - setExpandedGroups((prev) => { - const next = new Set(prev); - if (next.has(key)) { - next.delete(key); - } else { - next.add(key); - } - return next; - }); - }, []); - - const isWeb = getPlatform() === CONST.PLATFORM.WEB; - const shouldSplitGroups = !!groupBy && isLargeScreenWidth && isWeb; - - const { - splitData: listData, - stickyHeaderIndices, - childrenContainerIndices, - } = useMemo(() => { - if (!shouldSplitGroups) { - return {splitData: data, stickyHeaderIndices: undefined, childrenContainerIndices: CONST.EMPTY_ARRAY as readonly number[]}; - } - - const {splitData, stickyHeaderIndices: splitStickyIndices} = splitGroupsIntoPairs(data); - const childrenIndices: number[] = []; - - for (let i = 0; i < splitData.length; i++) { - const item = splitData.at(i); - if (item && isGroupChildrenContainerItem(item)) { - childrenIndices.push(i); - } - } - - return { - splitData, - stickyHeaderIndices: splitStickyIndices.length > 0 ? splitStickyIndices : undefined, - childrenContainerIndices: childrenIndices, - }; - }, [data, shouldSplitGroups]); - - const getItemType = useCallback( - (item: SearchListItem) => { - if (!shouldSplitGroups) { - return undefined; - } - if (isGroupHeaderItem(item)) { - return item.listItemType; - } - if (isGroupChildrenContainerItem(item)) { - return item.listItemType; - } - return 'default'; - }, - [shouldSplitGroups], - ); - - const overrideItemLayout = useCallback((layout: {size?: number; span?: number}, item: SearchListItem) => { - if (!isGroupHeaderItem(item)) { - return; - } - // FlashList requires mutating the layout object passed to overrideItemLayout - // eslint-disable-next-line no-param-reassign -- FlashList overrideItemLayout API - layout.size = variables.tableRowHeight; - }, []); - - const stickyHeaderConfig = useMemo( - () => - shouldSplitGroups - ? { - hideRelatedCell: true, - useNativeDriver: true, - zIndex: 2, - } - : undefined, - [shouldSplitGroups], - ); - - const [lastPaymentMethod] = useOnyx(ONYXKEYS.NVP_LAST_PAYMENT_METHOD); - const [personalPolicyID] = useOnyx(ONYXKEYS.PERSONAL_POLICY_ID); - const [userBillingGracePeriodEnds] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END); - const [ownerBillingGracePeriodEnd] = useOnyx(ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END); - const [visibleColumns] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM, {selector: columnsSelector}); - 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 undeleteTransactions = useUndeleteTransactions(); - - const handleUndelete = (transaction: Transaction) => undeleteTransactions([transaction]); - - const newTransactionIDByItemKey = (() => { - if (newTransactions.length === 0) { - return CONST.EMPTY_MAP; - } - - const mappedTransactionIDs = new Map(); - for (const item of data) { - const matchedTransactionID = newTransactions.find((transaction) => isTransactionMatchWithGroupItem(transaction, item, groupBy))?.transactionID; - if (matchedTransactionID && item.keyForList) { - mappedTransactionIDs.set(item.keyForList, matchedTransactionID); - } - } - - return mappedTransactionIDs; - })(); - - const {onLongPressRow, modal} = useRowLongPressMenu({shouldPreventLongPressRow, isSmallScreenWidth, isMobileSelectionModeEnabled}); - - // In mobile selection mode a row tap toggles selection. This must live here (not in ) because - // renders SearchWriteActionsProvider as its child, so the `toggle` it reads is the default no-op; - // SearchList sits inside the provider and gets the real one. - const handleSelectRow = useCallback( - (item: SearchListItem, transactionPreviewData?: TransactionPreviewData, event?: ModifiedMouseEvent) => { - if (isMobileSelectionModeEnabled) { - if (item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { - return; - } - toggle(item); - return; - } - onSelectRow(item, transactionPreviewData, event); - }, - [isMobileSelectionModeEnabled, toggle, onSelectRow], - ); - - /** - * Scrolls to the desired item index in the FlashList (listData coordinates). - * Used by keyboard navigation where focused indices map directly to listData rows. - */ - const scrollToListIndex = useCallback( - (index: number, animated = true) => { - const item = listData.at(index); - - if (!listRef.current || !item || index === -1) { - return; - } - - if (isEditingCell || wasRecentlyEditingCell) { - return; - } - - listRef.current.scrollToIndex({index, animated, viewOffset: -variables.contentHeaderHeight}); - }, - [listData, isEditingCell, wasRecentlyEditingCell], - ); - - /** - * Scrolls to the desired item index in the source data array. - * Remaps to split listData indices when sticky group headers are active. - */ - const scrollToDataIndex = useCallback( - (index: number, animated = true) => { - const item = data.at(index); - - if (!listRef.current || !item || index === -1) { - return; - } - - if (isEditingCell || wasRecentlyEditingCell) { - return; - } - - let targetIndex = index; - if (shouldSplitGroups && item.keyForList) { - const splitIndex = listData.findIndex((listItem) => listItem.keyForList === `header_${item.keyForList}`); - if (splitIndex !== -1) { - targetIndex = splitIndex; - } - } - - listRef.current.scrollToIndex({index: targetIndex, animated, viewOffset: -variables.contentHeaderHeight}); - }, - [data, listData, shouldSplitGroups, isEditingCell, wasRecentlyEditingCell], - ); - - useScrollRestoration(listRef); - - useImperativeHandle(ref, () => ({scrollToIndex: scrollToDataIndex}), [scrollToDataIndex]); - - const isItemVisible = useCallback((item: SearchListItem) => item.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || isOffline, [isOffline]); - const firstVisibleIndex = useMemo(() => listData.findIndex(isItemVisible), [listData, isItemVisible]); - const lastVisibleIndex = useMemo(() => listData.findLastIndex(isItemVisible), [listData, isItemVisible]); - - const renderItem = useCallback( - (item: SearchListItem, index: number, isItemFocused: boolean, onFocus?: (event: NativeSyntheticEvent) => void) => { - // Handle group header items (sticky) - if (isGroupHeaderItem(item)) { - const headerItem = item; - const originalKey = (item.keyForList ?? '').replace('header_', ''); - return ( - onToggleGroup(originalKey)} - onSelectRow={handleSelectRow} - onCheckboxPress={toggle} - onLongPressRow={onLongPressRow} - onFocus={onFocus} - isFocused={isItemFocused} - isFirstItem={index === firstVisibleIndex} - isLastItem={false} - originalKey={originalKey} - lastPaymentMethod={lastPaymentMethod} - personalPolicyID={personalPolicyID} - userBillingGracePeriodEnds={userBillingGracePeriodEnds} - ownerBillingGracePeriodEnd={ownerBillingGracePeriodEnd} - visibleColumns={visibleColumns} - /> - ); - } - - // Handle children container items (animated expand/collapse) - if (isGroupChildrenContainerItem(item)) { - const containerItem = item; - const originalKey = (item.keyForList ?? '').replace('children_', ''); - const containerNewTransactionID = item.keyForList ? newTransactionIDByItemKey.get(originalKey) : undefined; - return ( - - ); - } - - // Default rendering for non-group items - const isDisabled = item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; - const shouldApplyAnimation = shouldAnimate && index < listData.length - 1; - - const newTransactionID = item.keyForList ? newTransactionIDByItemKey.get(item.keyForList) : undefined; - - return ( - - - - ); - }, - [ - type, - groupBy, - newTransactionIDByItemKey, - shouldAnimate, - listData.length, - hasItemsBeingRemoved, - ListItem, - handleSelectRow, - onLongPressRow, - toggle, - canSelectMultiple, - columns, - lastPaymentMethod, - personalPolicyID, - userBillingGracePeriodEnds, - ownerBillingGracePeriodEnd, - nonPersonalAndWorkspaceCards, - policyTags, - ListFooterComponent, - handleUndelete, - firstVisibleIndex, - lastVisibleIndex, - expandedGroups, - onToggleGroup, - bankAccountList, - cardFeeds, - conciergeReportID, - visibleColumns, - ], - ); - - const tableHeaderVisible = canSelectMultiple || !!SearchTableHeader; - const selectAllButtonVisible = canSelectMultiple && !SearchTableHeader; - const isSelectAllChecked = selectedItemsLength > 0 && selectedItemsLength === totalItems && hasLoadedAllTransactions; - - const content = ( - - {tableHeaderVisible && ( - - )} - - {modal} - - ); - - return ( - - {content} - - ); -} - -export default SearchList; -export {isTransactionMatchWithGroupItem}; diff --git a/src/components/Search/hooks/useSearchListViewState.ts b/src/components/Search/hooks/useSearchListViewState.ts index cf59d112d80b..c7f94336779b 100644 --- a/src/components/Search/hooks/useSearchListViewState.ts +++ b/src/components/Search/hooks/useSearchListViewState.ts @@ -95,7 +95,7 @@ function useSearchListViewState({data, listData = data, isMobileSelectionModeEna const scrollToListIndex = (index: number, animated = true) => { const item = listData.at(index); - if (!listRef.current || !item || index === -1) { + if (!listRef.current || !item || index < 0) { return; } // Mirror SearchList: don't scroll while a row's cell is being inline-edited, which would blur/move it mid-edit. diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index d6fe878315ec..a8b62a118521 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -36,7 +36,6 @@ import {buildCannedSearchQuery, buildSearchQueryString} from '@libs/SearchQueryU import { createAndOpenSearchTransactionThread, doesSearchItemMatchSort, - getListItem, getValidGroupBy, getWideAmountIndicators, isGroupedItemArray, @@ -100,7 +99,6 @@ import useSearchSnapshot from './hooks/useSearchSnapshot'; import SearchChartView from './SearchChartView'; import SearchChartWrapper from './SearchChartWrapper'; import {useSearchQueryActions, useSearchQueryContext, useSearchResultsActions, useSearchResultsContext, useSearchSelectionActions} from './SearchContext'; -import SearchList from './SearchList'; import {SearchScopeProvider} from './SearchScopeProvider'; import SearchTableHeader from './SearchTableHeader'; import SearchWriteActionsProvider from './SearchWriteActionsProvider'; @@ -138,7 +136,7 @@ function Search({ onContentReady, onDestinationVisible, }: SearchProps) { - const {type, status, sortBy, sortOrder, hash, similarSearchHash, groupBy, view} = queryJSON; + const {type, sortBy, sortOrder, hash, similarSearchHash, groupBy, view} = queryJSON; const {isOffline} = useNetwork(); const prevIsOffline = usePrevious(isOffline); @@ -178,7 +176,6 @@ function Search({ const isAttendeesEnabledForMovingPolicy = shouldShowAttendees(CONST.IOU.TYPE.SUBMIT, policyForMovingExpenses); const [, cardFeedsResult] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER); - const [policyTags] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS); const searchDataType = useMemo(() => (shouldUseLiveData ? CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT : searchResults?.search?.type), [shouldUseLiveData, searchResults?.search?.type]); const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, hash, offset === 0); @@ -703,7 +700,6 @@ function Search({ const isChat = type === CONST.SEARCH.DATA_TYPES.CHAT; const isTask = type === CONST.SEARCH.DATA_TYPES.TASK; const canSelectMultiple = !isChat && !isTask && (!isSmallScreenWidth || isMobileSelectionModeEnabled); - const ListItem = getListItem(type, status, validGroupBy); useSaveSortedReportIDs(type, sortedData); @@ -1053,161 +1049,56 @@ function Search({ /> ) : undefined; - const isFlatExpenseView = type === CONST.SEARCH.DATA_TYPES.EXPENSE && !validGroupBy; - const isExpenseGroupedView = type === CONST.SEARCH.DATA_TYPES.EXPENSE && !!validGroupBy; + // Transaction lists (expense, invoice, trip) render through the flat or grouped view depending on groupBy; + // chat, expense-report and task each have their own dedicated view. Every view composes BaseSearchList + // directly, and the snapshot, lifecycle and selection providers stay here so the data layer runs once. + const isTransactionListView = type !== CONST.SEARCH.DATA_TYPES.CHAT && type !== CONST.SEARCH.DATA_TYPES.TASK && type !== CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT; + + const commonViewProps = { + ref: searchListRef, + queryJSON, + data: stableSortedData, + columns: columnsToShow, + onSelectRow, + canSelectMultiple, + SearchTableHeader: searchTableHeader, + tableHeaderVisible, + contentContainerStyle: [styles.pb3, contentContainerStyle], + containerStyle: [styles.pv0], + onScroll: onSearchListScroll, + onEndReached: fetchMoreResults, + ListFooterComponent: listFooterComponent, + onLayout, + isMobileSelectionModeEnabled, + newTransactions, + hasLoadedAllTransactions, + isActionColumnWide: isTask || hasDeletedTransaction, + }; - // Flat-expense, grouped-expense, expense-report, task and chat each render through a dedicated view composed over BaseSearchList; - // the remaining types keep the legacy SearchList shell. The snapshot, lifecycle and selection providers - // stay here so the data layer runs once. let searchListContent: React.JSX.Element; - if (isFlatExpenseView) { + if (isTransactionListView && !validGroupBy) { searchListContent = ( ); - } else if (isExpenseGroupedView) { + } else if (isTransactionListView) { searchListContent = ( ); } else if (isChat) { - searchListContent = ( - - ); + searchListContent = ; } else if (isExpenseReportType) { - searchListContent = ( - - ); - } else if (isTask) { - searchListContent = ( - - ); + searchListContent = ; } else { - searchListContent = ( - - ); + // TASK is the only remaining data type. + searchListContent = ; } return ( diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index e5835acf325b..0419e7ed8a7d 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -4,11 +4,6 @@ import type {LocaleContextProps, LocalizedTranslate} from '@components/LocaleCon import type {MenuItemWithLink} from '@components/MenuItemList'; import type {MultiSelectItem} from '@components/Search/FilterComponents/MultiSelect'; import type {SingleSelectItem} from '@components/Search/FilterComponents/SingleSelect'; -import ChatListItem from '@components/Search/SearchList/ListItem/ChatListItem'; -import ExpenseReportListItem from '@components/Search/SearchList/ListItem/ExpenseReportListItem'; -import TaskListItem from '@components/Search/SearchList/ListItem/TaskListItem'; -import TransactionGroupListItem from '@components/Search/SearchList/ListItem/TransactionGroupListItem'; -import TransactionListItem from '@components/Search/SearchList/ListItem/TransactionListItem'; import type { ExpenseReportListItemType, GroupChildrenContainerItemType, @@ -74,7 +69,6 @@ import type {SaveSearchItem} from '@src/types/onyx/SaveSearch'; import type SearchResults from '@src/types/onyx/SearchResults'; import type { ListItemDataType, - ListItemType, SearchCardGroup, SearchCategoryGroup, SearchDataTypes, @@ -3589,25 +3583,6 @@ function getQuarterSections(data: OnyxTypes.SearchResults['data'], queryJSON: Se return [quarterSectionsValues, quarterSectionsValues.length, hasDeletedTransactionInData(data)]; } -/** - * Returns the appropriate list item component based on the type and status of the search data. - */ -function getListItem(type: SearchDataTypes, status: SearchStatus, groupBy?: SearchGroupBy): ListItemType { - if (type === CONST.SEARCH.DATA_TYPES.CHAT) { - return ChatListItem; - } - if (type === CONST.SEARCH.DATA_TYPES.TASK) { - return TaskListItem; - } - if (type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT) { - return ExpenseReportListItem; - } - if (groupBy) { - return TransactionGroupListItem; - } - return TransactionListItem; -} - /** * Organizes data into appropriate list sections for display based on the type of search results. */ @@ -6361,14 +6336,60 @@ function splitGroupsIntoPairs(data: SearchListItem[]): {splitData: SearchListIte return {splitData, stickyHeaderIndices}; } +/** + * Checks whether a transaction belongs to the given group list item, based on the active groupBy. + */ +function isTransactionMatchWithGroupItem(transaction: OnyxTypes.Transaction, groupItem: SearchListItem, groupBy: SearchGroupBy | undefined) { + if (groupBy === CONST.SEARCH.GROUP_BY.CARD) { + return transaction.cardID === (groupItem as TransactionCardGroupListItemType).cardID; + } + if (groupBy === CONST.SEARCH.GROUP_BY.FROM) { + return !!transaction.transactionID; + } + if (groupBy === CONST.SEARCH.GROUP_BY.CATEGORY) { + return (transaction.category ?? '') === ((groupItem as TransactionCategoryGroupListItemType).category ?? ''); + } + if (groupBy === CONST.SEARCH.GROUP_BY.MERCHANT) { + return (transaction.merchant ?? '') === ((groupItem as TransactionMerchantGroupListItemType).merchant ?? ''); + } + if (groupBy === CONST.SEARCH.GROUP_BY.MONTH) { + const monthGroup = groupItem as TransactionMonthGroupListItemType; + const transactionDateString = transaction.modifiedCreated ?? transaction.created ?? ''; + return DateUtils.isDateStringInMonth(transactionDateString, monthGroup.year, monthGroup.month); + } + if (groupBy === CONST.SEARCH.GROUP_BY.WEEK) { + const weekGroup = groupItem as TransactionWeekGroupListItemType; + const transactionDateString = transaction.modifiedCreated ?? transaction.created ?? ''; + const datePart = transactionDateString.substring(0, 10); + const {start: weekStart, end: weekEnd} = DateUtils.getWeekDateRange(weekGroup.week); + return datePart >= weekStart && datePart <= weekEnd; + } + if (groupBy === CONST.SEARCH.GROUP_BY.YEAR) { + const yearGroup = groupItem as TransactionYearGroupListItemType; + const transactionDateString = transaction.modifiedCreated ?? transaction.created ?? ''; + const transactionYear = parseInt(transactionDateString.substring(0, 4), 10); + return transactionYear === yearGroup.year; + } + if (groupBy === CONST.SEARCH.GROUP_BY.QUARTER) { + const quarterGroup = groupItem as TransactionQuarterGroupListItemType; + const transactionDateString = transaction.modifiedCreated ?? transaction.created ?? ''; + const transactionYear = parseInt(transactionDateString.substring(0, 4), 10); + const transactionMonth = parseInt(transactionDateString.substring(5, 7), 10); + // Calculate which quarter the transaction belongs to (1-4) + const transactionQuarter = Math.floor((transactionMonth - 1) / 3) + 1; + return transactionYear === quarterGroup.year && transactionQuarter === quarterGroup.quarter; + } + return false; +} + export { getSearchBulkEditPolicyID, getSuggestedSearches, - getListItem, getSections, getSuggestedSearchesVisibility, getSortedSections, getViolationsFromSearchData, + isTransactionMatchWithGroupItem, isTransactionGroupListItemType, isTransactionReportGroupListItemType, isTransactionCategoryGroupListItemType, diff --git a/tests/unit/Search/ExpenseGroupedSearchViewTest.tsx b/tests/unit/Search/ExpenseGroupedSearchViewTest.tsx index 11d5c32dc589..23458e635e83 100644 --- a/tests/unit/Search/ExpenseGroupedSearchViewTest.tsx +++ b/tests/unit/Search/ExpenseGroupedSearchViewTest.tsx @@ -61,13 +61,6 @@ jest.mock('@libs/getPlatform', () => ({ default: jest.fn(() => 'ios'), })); -// The grouped view imports isTransactionMatchWithGroupItem from the heavy SearchList module; stub it out. -jest.mock('@components/Search/SearchList', () => ({ - __esModule: true, - default: () => null, - isTransactionMatchWithGroupItem: jest.fn(() => false), -})); - // Group header/children only render on the split path; stub them so importing the view stays lightweight. jest.mock('@components/Search/SearchList/ListItem/GroupHeader', () => ({__esModule: true, default: () => null})); jest.mock('@components/Search/SearchList/ListItem/GroupChildrenContainer', () => ({__esModule: true, default: () => null})); diff --git a/tests/unit/Search/SearchListRenderCountTest.tsx b/tests/unit/Search/SearchListRenderCountTest.tsx deleted file mode 100644 index b878dffdf738..000000000000 --- a/tests/unit/Search/SearchListRenderCountTest.tsx +++ /dev/null @@ -1,248 +0,0 @@ -import {render} from '@testing-library/react-native'; - -import ComposeProviders from '@components/ComposeProviders'; -import {LocaleContextProvider} from '@components/LocaleContextProvider'; -import OnyxListItemProvider from '@components/OnyxListItemProvider'; -import ScrollOffsetContextProvider from '@components/ScrollOffsetContextProvider'; -import SearchList from '@components/Search/SearchList'; -import type {SearchListItem} from '@components/Search/SearchList/ListItem/types'; -import type {SearchColumnType, SearchQueryJSON} from '@components/Search/types'; -import Text from '@components/Text'; -import ThemeProvider from '@components/ThemeProvider'; -import ThemeStylesProvider from '@components/ThemeStylesContextProvider'; - -import {setHasRadio} from '@libs/NetworkState'; - -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; - -import React, {Profiler, useCallback, useEffect, useMemo, useRef} from 'react'; -import {View} from 'react-native'; -import Onyx from 'react-native-onyx'; - -import * as TestHelper from '../../utils/TestHelper'; -import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; -import wrapOnyxWithWaitForBatchedUpdates from '../../utils/wrapOnyxWithWaitForBatchedUpdates'; - -jest.mock('@hooks/useLocalize', () => - jest.fn(() => ({ - translate: jest.fn((key: string) => key), - numberFormat: jest.fn(), - })), -); - -jest.mock('@hooks/useNetwork', () => - jest.fn(() => ({ - isOffline: false, - })), -); - -jest.mock('@hooks/useKeyboardState', () => ({ - __esModule: true, - default: jest.fn(() => ({ - isKeyboardShown: false, - keyboardHeight: 0, - })), -})); - -jest.mock('@hooks/useResponsiveLayout', () => ({ - __esModule: true, - default: jest.fn(() => ({ - isSmallScreenWidth: false, - isLargeScreenWidth: true, - shouldUseNarrowLayout: false, - })), -})); - -jest.mock('@hooks/useSafeAreaPaddings', () => ({ - __esModule: true, - default: jest.fn(() => ({ - safeAreaPaddingBottomStyle: {}, - })), -})); - -jest.mock('@hooks/useWindowDimensions', () => ({ - __esModule: true, - default: jest.fn(() => ({ - windowWidth: 1200, - windowHeight: 800, - })), -})); - -jest.mock('@react-navigation/native', () => ({ - // Defer callback to after commit so focus effect runs at effect time, not during render. - // Running during render would skew render-count/duration and is unrepresentative of production. - useFocusEffect: jest.fn((callback: () => void) => { - queueMicrotask(() => callback()); - return () => {}; - }), - useRoute: jest.fn(() => ({key: 'search-test-route'})), - useIsFocused: () => true, - createNavigationContainerRef: jest.fn(() => ({ - getCurrentRoute: jest.fn(), - addListener: jest.fn(() => jest.fn()), - removeListener: jest.fn(), - isReady: jest.fn(() => true), - getState: jest.fn(), - })), -})); - -jest.mock('@src/components/ConfirmedRoute.tsx'); - -/** Maximum allowed render count for the SearchList subtree on initial mount with stable props (measured via Profiler). Exceeding this suggests a re-render regression. */ -const MAX_INITIAL_RENDER_COUNT = 15; - -function ThemeProviderWithLight({children}: {children: React.ReactNode}) { - return {children}; -} -ThemeProviderWithLight.displayName = 'ThemeProviderWithLight'; - -/** Minimal list item component to avoid heavy TransactionListItem dependencies */ -function MockListItem({item}: {item: SearchListItem}) { - return ( - - {item?.keyForList ?? 'unknown'} - - ); -} -MockListItem.displayName = 'MockListItem'; - -const STABLE_QUERY_JSON: SearchQueryJSON = { - hash: 0, - recentSearchHash: 0, - similarSearchHash: 0, - groupBy: undefined, - type: CONST.SEARCH.DATA_TYPES.EXPENSE, - status: CONST.SEARCH.STATUS.EXPENSE.ALL, - sortBy: CONST.SEARCH.TABLE_COLUMNS.DATE, - sortOrder: 'desc', - view: CONST.SEARCH.VIEW.TABLE, - flatFilters: [], - inputQuery: '', - filters: {operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.STATUS, right: ''}, - policyID: undefined, - columns: undefined, - limit: undefined, - rawFilterList: undefined, -}; - -const STABLE_COLUMNS: SearchColumnType[] = [CONST.SEARCH.TABLE_COLUMNS.DATE, CONST.SEARCH.TABLE_COLUMNS.MERCHANT, CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT, CONST.SEARCH.TABLE_COLUMNS.ACTION]; - -function createMockData(length: number): SearchListItem[] { - return Array.from({length}, (_, i) => ({ - keyForList: `transaction-${i}`, - transactionID: `${i}`, - reportID: '1', - policyID: 'policy-1', - amount: 100, - currency: 'USD', - created: '2025-01-01', - merchant: `Merchant ${i}`, - category: '', - tag: '', - modifiedAmount: 100, - modifiedCreated: '2025-01-01', - modifiedCurrency: 'USD', - modifiedMerchant: `Merchant ${i}`, - action: CONST.SEARCH.ACTION_TYPES.VIEW, - reportAction: {reportActionID: `${i}`, actionName: 'created', created: '2025-01-01'}, - policy: {id: 'policy-1', name: 'Policy', type: 'team', role: 'admin', owner: 'test@test.com', outputCurrency: 'USD', isPolicyExpenseChatEnabled: true}, - formattedMerchant: `Merchant ${i}`, - formattedTotal: 100, - date: '2025-01-01', - shouldShowMerchant: true, - shouldShowYear: false, - shouldShowYearSubmitted: false, - shouldShowYearApproved: false, - shouldShowYearPosted: false, - shouldShowYearExported: false, - })) as unknown as SearchListItem[]; -} - -const MOCK_DATA = createMockData(100); - -beforeAll(() => - Onyx.init({ - keys: ONYXKEYS, - evictableKeys: [ONYXKEYS.COLLECTION.REPORT], - }), -); - -beforeEach(() => { - global.fetch = TestHelper.getGlobalFetchMock(); - wrapOnyxWithWaitForBatchedUpdates(Onyx); - setHasRadio(true); - Onyx.merge(ONYXKEYS.COLLECTION.REPORT, {}); - Onyx.merge(ONYXKEYS.COLLECTION.POLICY, {}); -}); - -afterEach(() => { - Onyx.clear(); -}); - -describe('SearchList render count', () => { - it('should not exceed max render count on initial mount with stable props', async () => { - let countRef: React.RefObject | null = null; - - function SearchListWrapper({onRenderCount}: {onRenderCount: () => void}) { - const onSelectRow = useCallback(() => {}, []); - const onEndReached = useCallback(() => {}, []); - const onLayout = useCallback(() => {}, []); - - const queryJSON = useMemo(() => STABLE_QUERY_JSON, []); - const columns = useMemo(() => STABLE_COLUMNS, []); - const data = useMemo(() => MOCK_DATA, []); - const contentContainerStyle = useMemo(() => ({}), []); - const containerStyle = useMemo(() => ({}), []); - - return ( - - - - ); - } - - function SearchListWrapperWithProviders({onRenderCount}: {onRenderCount: () => void}) { - return ( - - - - ); - } - - function TestRoot() { - const renderCountRef = useRef(0); - useEffect(() => { - countRef = renderCountRef; - }); - return ( - { - renderCountRef.current += 1; - }} - /> - ); - } - - render(); - await waitForBatchedUpdates(); - - const ref = countRef as React.RefObject | null; - expect(ref?.current ?? 0).toBeLessThanOrEqual(MAX_INITIAL_RENDER_COUNT); - }); -}); diff --git a/tests/unit/Search/SearchUIUtilsTest.ts b/tests/unit/Search/SearchUIUtilsTest.ts index ffae3d48ce01..90681a08a54f 100644 --- a/tests/unit/Search/SearchUIUtilsTest.ts +++ b/tests/unit/Search/SearchUIUtilsTest.ts @@ -1,8 +1,4 @@ import * as defaultWorkspaceAvatars from '@components/Icon/WorkspaceDefaultAvatars'; -import ChatListItem from '@components/Search/SearchList/ListItem/ChatListItem'; -import ExpenseReportListItem from '@components/Search/SearchList/ListItem/ExpenseReportListItem'; -import TransactionGroupListItem from '@components/Search/SearchList/ListItem/TransactionGroupListItem'; -import TransactionListItem from '@components/Search/SearchList/ListItem/TransactionListItem'; import type { ReportActionListItemType, TaskListItemType, @@ -2582,48 +2578,6 @@ describe('SearchUIUtils', () => { }); }); - describe('Test getListItem', () => { - it('should return ChatListItem when type is CHAT', () => { - expect(SearchUIUtils.getListItem(CONST.SEARCH.DATA_TYPES.CHAT, CONST.SEARCH.STATUS.EXPENSE.ALL)).toStrictEqual(ChatListItem); - }); - - it('should return TransactionListItem when groupBy is undefined', () => { - expect(SearchUIUtils.getListItem(CONST.SEARCH.DATA_TYPES.EXPENSE, CONST.SEARCH.STATUS.EXPENSE.ALL, undefined)).toStrictEqual(TransactionListItem); - }); - - it('should return ExpenseReportListItem when type is EXPENSE-REPORT', () => { - expect(SearchUIUtils.getListItem(CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT, CONST.SEARCH.STATUS.EXPENSE.ALL)).toStrictEqual(ExpenseReportListItem); - }); - - it('should return TransactionListItem when type is TRIP', () => { - expect(SearchUIUtils.getListItem(CONST.SEARCH.DATA_TYPES.TRIP, CONST.SEARCH.STATUS.EXPENSE.ALL)).toStrictEqual(TransactionListItem); - }); - - it('should return TransactionListItem when type is INVOICE', () => { - expect(SearchUIUtils.getListItem(CONST.SEARCH.DATA_TYPES.INVOICE, CONST.SEARCH.STATUS.EXPENSE.ALL)).toStrictEqual(TransactionListItem); - }); - - it('should return TransactionGroupListItem when type is EXPENSE and groupBy is member', () => { - expect(SearchUIUtils.getListItem(CONST.SEARCH.DATA_TYPES.EXPENSE, CONST.SEARCH.STATUS.EXPENSE.ALL, CONST.SEARCH.GROUP_BY.FROM)).toStrictEqual(TransactionGroupListItem); - }); - - it('should return TransactionGroupListItem when type is TRIP and groupBy is member', () => { - expect(SearchUIUtils.getListItem(CONST.SEARCH.DATA_TYPES.TRIP, CONST.SEARCH.STATUS.EXPENSE.ALL, CONST.SEARCH.GROUP_BY.FROM)).toStrictEqual(TransactionGroupListItem); - }); - - it('should return TransactionGroupListItem when type is INVOICE and groupBy is member', () => { - expect(SearchUIUtils.getListItem(CONST.SEARCH.DATA_TYPES.INVOICE, CONST.SEARCH.STATUS.EXPENSE.ALL, CONST.SEARCH.GROUP_BY.FROM)).toStrictEqual(TransactionGroupListItem); - }); - - it('should return TransactionGroupListItem when type is EXPENSE and groupBy is category', () => { - expect(SearchUIUtils.getListItem(CONST.SEARCH.DATA_TYPES.EXPENSE, CONST.SEARCH.STATUS.EXPENSE.ALL, CONST.SEARCH.GROUP_BY.CATEGORY)).toStrictEqual(TransactionGroupListItem); - }); - - it('should return TransactionGroupListItem when type is EXPENSE and groupBy is merchant', () => { - expect(SearchUIUtils.getListItem(CONST.SEARCH.DATA_TYPES.EXPENSE, CONST.SEARCH.STATUS.EXPENSE.ALL, CONST.SEARCH.GROUP_BY.MERCHANT)).toStrictEqual(TransactionGroupListItem); - }); - }); - describe('Test getSections', () => { it('should return getReportActionsSections result when type is CHAT', () => { const [filteredReportActions, allReportActionsLength] = SearchUIUtils.getSections({ From fb4bcd346553f8a089e83619f5ce3113a76a35ff Mon Sep 17 00:00:00 2001 From: bartlomiej obudzinski Date: Mon, 6 Jul 2026 12:19:25 +0200 Subject: [PATCH 2/4] refactor: extract shared CommonSearchViewProps for Search views --- src/components/Search/ChatSearchView.tsx | 70 +---------------- .../Search/ExpenseFlatSearchView.tsx | 77 +----------------- .../Search/ExpenseGroupedSearchView.tsx | 75 +----------------- .../Search/ExpenseReportSearchView.tsx | 70 +---------------- src/components/Search/TaskSearchView.tsx | 70 +---------------- src/components/Search/index.tsx | 3 +- src/components/Search/searchViewProps.ts | 78 +++++++++++++++++++ src/libs/SearchUIUtils.ts | 2 +- 8 files changed, 99 insertions(+), 346 deletions(-) create mode 100644 src/components/Search/searchViewProps.ts diff --git a/src/components/Search/ChatSearchView.tsx b/src/components/Search/ChatSearchView.tsx index 82344661b64f..21f390a3fa3e 100644 --- a/src/components/Search/ChatSearchView.tsx +++ b/src/components/Search/ChatSearchView.tsx @@ -1,18 +1,13 @@ import type {ExtendedTargetedEvent} from '@components/SelectionList/ListItem/types'; -import type {TransactionPreviewData} from '@libs/actions/Search'; -import type {ModifiedMouseEvent} from '@libs/Navigation/helpers/openInternalRouteInNewTab'; - import CONST from '@src/CONST'; -import type {Transaction} from '@src/types/onyx'; -import type {ForwardedRef} from 'react'; -import type {NativeScrollEvent, NativeSyntheticEvent, StyleProp, ViewStyle} from 'react-native'; +import type {NativeSyntheticEvent} from 'react-native'; import React, {useImperativeHandle} from 'react'; import type {SearchListItem} from './SearchList/ListItem/types'; -import type {SearchColumnType, SearchQueryJSON} from './types'; +import type {CommonSearchViewProps} from './searchViewProps'; import useSearchListViewState from './hooks/useSearchListViewState'; import AnimatedExitRow from './primitives/AnimatedExitRow'; @@ -21,66 +16,7 @@ import BaseSearchList from './SearchList/BaseSearchList'; import ChatListItem from './SearchList/ListItem/ChatListItem'; import SearchListViewLayout from './SearchListViewLayout'; -/** Imperative handle the router uses for highlight-driven scrolling (mirrors SearchList's handle). */ -type SearchListHandle = { - scrollToIndex: (index: number, animated?: boolean) => void; -}; - -type ChatSearchViewProps = { - /** The chat search query. */ - queryJSON: SearchQueryJSON; - - /** The sorted rows to render (from the router's useSearchSnapshot). */ - data: SearchListItem[]; - - /** The columns to render in the list (drives the table min-width and header). */ - columns: SearchColumnType[]; - - /** Whether the list supports multi-select. */ - canSelectMultiple: boolean; - - /** Whether the action column uses its wider variant. */ - isActionColumnWide: boolean; - - /** Whether mobile selection mode is on. */ - isMobileSelectionModeEnabled: boolean; - - /** The column header element (undefined on narrow layouts). */ - SearchTableHeader?: React.JSX.Element; - - /** Whether a table header bar is shown above the list. */ - tableHeaderVisible: boolean; - - /** Whether everything has been loaded (gates the fully-checked select-all state). */ - hasLoadedAllTransactions?: boolean; - - /** Rows flagged for the post-create highlight animation (feeds BaseSearchList extraData). */ - newTransactions: Transaction[]; - - /** The navigation handler for a row tap (owned by the router). */ - onSelectRow: (item: SearchListItem, transactionPreviewData?: TransactionPreviewData, event?: ModifiedMouseEvent) => void; - - /** The list footer (pagination / pending skeleton). */ - ListFooterComponent?: React.JSX.Element; - - /** Fires when the list scrolls near its end (router's fetchMoreResults). */ - onEndReached: () => void; - - /** Fires on the list's first layout and on layout changes. */ - onLayout: () => void; - - /** Scroll handler forwarded to the list. */ - onScroll?: (event: NativeSyntheticEvent) => void; - - /** Content container style for the list. */ - contentContainerStyle: StyleProp; - - /** Outer container style for the list wrapper. */ - containerStyle: StyleProp; - - /** Imperative handle for highlight-driven scrolling, set by the router. */ - ref?: ForwardedRef; -}; +type ChatSearchViewProps = CommonSearchViewProps; const keyExtractor = (item: SearchListItem, index: number) => item.keyForList ?? `${index}`; diff --git a/src/components/Search/ExpenseFlatSearchView.tsx b/src/components/Search/ExpenseFlatSearchView.tsx index 8a0485f7cba0..aa30a299bbad 100644 --- a/src/components/Search/ExpenseFlatSearchView.tsx +++ b/src/components/Search/ExpenseFlatSearchView.tsx @@ -1,18 +1,14 @@ import type {ExtendedTargetedEvent} from '@components/SelectionList/ListItem/types'; -import type {TransactionPreviewData} from '@libs/actions/Search'; -import type {ModifiedMouseEvent} from '@libs/Navigation/helpers/openInternalRouteInNewTab'; - import CONST from '@src/CONST'; -import type {CardList, Transaction} from '@src/types/onyx'; +import type {CardList} from '@src/types/onyx'; -import type {ForwardedRef} from 'react'; -import type {NativeScrollEvent, NativeSyntheticEvent, StyleProp, ViewStyle} from 'react-native'; +import type {NativeSyntheticEvent} from 'react-native'; import React, {useImperativeHandle} from 'react'; import type {SearchListItem} from './SearchList/ListItem/types'; -import type {SearchColumnType, SearchQueryJSON} from './types'; +import type {CommonSearchViewProps} from './searchViewProps'; import useSearchListViewState from './hooks/useSearchListViewState'; import AnimatedExitRow from './primitives/AnimatedExitRow'; @@ -21,72 +17,7 @@ import BaseSearchList from './SearchList/BaseSearchList'; import TransactionListItem from './SearchList/ListItem/TransactionListItem'; import SearchListViewLayout from './SearchListViewLayout'; -/** Imperative handle the router uses for highlight-driven scrolling (mirrors SearchList's handle). */ -type SearchListHandle = { - scrollToIndex: (index: number, animated?: boolean) => void; -}; - -type ExpenseFlatSearchViewProps = { - /** The flat-expense search query. */ - queryJSON: SearchQueryJSON; - - /** The sorted, optimistic-stabilized rows to render (from the router's useSearchSnapshot). */ - data: SearchListItem[]; - - /** The columns to render in the list (fade-stabilized by the router). */ - columns: SearchColumnType[]; - - /** Whether the list supports multi-select. */ - canSelectMultiple: boolean; - - /** Whether the action column uses its wider variant (a deleted transaction is present). */ - isActionColumnWide: boolean; - - /** Precomputed attendee-tracking boolean (derived from policy-for-moving-expenses). */ - isAttendeesEnabledForMovingPolicy?: boolean; - - /** Non-personal and workspace cards for row rendering (subscribed once by the router). */ - nonPersonalAndWorkspaceCards?: CardList; - - /** Whether mobile selection mode is on. */ - isMobileSelectionModeEnabled: boolean; - - /** The column header element for the flat table (undefined on narrow layouts). */ - SearchTableHeader?: React.JSX.Element; - - /** Whether a table header bar is shown above the list. */ - tableHeaderVisible: boolean; - - /** Whether every transaction has been loaded (gates the fully-checked select-all state). */ - hasLoadedAllTransactions?: boolean; - - /** Transactions flagged for the post-create highlight animation (feeds BaseSearchList extraData). */ - newTransactions: Transaction[]; - - /** The navigation/thread-creation handler for a row tap (owned by the router). */ - onSelectRow: (item: SearchListItem, transactionPreviewData?: TransactionPreviewData, event?: ModifiedMouseEvent) => void; - - /** The list footer (pagination / pending-expense skeleton). */ - ListFooterComponent?: React.JSX.Element; - - /** Fires when the list scrolls near its end (router's fetchMoreResults). */ - onEndReached: () => void; - - /** Fires on the list's first layout and on layout changes. */ - onLayout: () => void; - - /** Scroll handler forwarded to the list. */ - onScroll?: (event: NativeSyntheticEvent) => void; - - /** Content container style for the list. */ - contentContainerStyle: StyleProp; - - /** Outer container style for the list wrapper. */ - containerStyle: StyleProp; - - /** Imperative handle for highlight-driven scrolling, set by the router. */ - ref?: ForwardedRef; -}; +type ExpenseFlatSearchViewProps = CommonSearchViewProps & {isAttendeesEnabledForMovingPolicy?: boolean; nonPersonalAndWorkspaceCards?: CardList}; const keyExtractor = (item: SearchListItem, index: number) => item.keyForList ?? `${index}`; diff --git a/src/components/Search/ExpenseGroupedSearchView.tsx b/src/components/Search/ExpenseGroupedSearchView.tsx index b9e797f707a9..f0d27ae2f340 100644 --- a/src/components/Search/ExpenseGroupedSearchView.tsx +++ b/src/components/Search/ExpenseGroupedSearchView.tsx @@ -3,9 +3,7 @@ import type {ExtendedTargetedEvent} from '@components/SelectionList/ListItem/typ import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import type {TransactionPreviewData} from '@libs/actions/Search'; import getPlatform from '@libs/getPlatform'; -import type {ModifiedMouseEvent} from '@libs/Navigation/helpers/openInternalRouteInNewTab'; import {isTransactionGroupListItemType, isTransactionMatchWithGroupItem, splitGroupsIntoPairs} from '@libs/SearchUIUtils'; import variables from '@styles/variables'; @@ -15,13 +13,13 @@ import ONYXKEYS from '@src/ONYXKEYS'; import {columnsSelector} from '@src/selectors/AdvancedSearchFiltersForm'; import type {CardList, Transaction} from '@src/types/onyx'; -import type {ForwardedRef} from 'react'; -import type {NativeScrollEvent, NativeSyntheticEvent, StyleProp, ViewStyle} from 'react-native'; +import type {NativeSyntheticEvent} from 'react-native'; import React, {useImperativeHandle, useState} from 'react'; import type {SearchListItem} from './SearchList/ListItem/types'; -import type {SearchColumnType, SearchQueryJSON, SelectedTransactions} from './types'; +import type {CommonSearchViewProps} from './searchViewProps'; +import type {SearchQueryJSON, SelectedTransactions} from './types'; import useSearchListViewState from './hooks/useSearchListViewState'; import AnimatedExitRow from './primitives/AnimatedExitRow'; @@ -33,72 +31,7 @@ import TransactionGroupListItem from './SearchList/ListItem/TransactionGroupList import {isGroupChildrenContainerItem, isGroupHeaderItem} from './SearchList/ListItem/types'; import SearchListViewLayout from './SearchListViewLayout'; -/** Imperative handle the router uses for highlight-driven scrolling (mirrors SearchList's handle). */ -type SearchListHandle = { - scrollToIndex: (index: number, animated?: boolean) => void; -}; - -type ExpenseGroupedSearchViewProps = { - /** The grouped-expense search query (a valid groupBy is set). */ - queryJSON: SearchQueryJSON; - - /** The sorted group rows to render (from the router's useSearchSnapshot). */ - data: SearchListItem[]; - - /** The columns to render in the list. */ - columns: SearchColumnType[]; - - /** Whether the list supports multi-select. */ - canSelectMultiple: boolean; - - /** Whether the action column uses its wider variant. */ - isActionColumnWide: boolean; - - /** Precomputed attendee-tracking boolean (derived from policy-for-moving-expenses). */ - isAttendeesEnabledForMovingPolicy?: boolean; - - /** Non-personal and workspace cards for row rendering (subscribed once by the router). */ - nonPersonalAndWorkspaceCards?: CardList; - - /** Whether mobile selection mode is on. */ - isMobileSelectionModeEnabled: boolean; - - /** The column header element (undefined on narrow layouts). */ - SearchTableHeader?: React.JSX.Element; - - /** Whether a table header bar is shown above the list. */ - tableHeaderVisible: boolean; - - /** Whether everything has been loaded (gates the fully-checked select-all state). */ - hasLoadedAllTransactions?: boolean; - - /** Transactions flagged for the post-create highlight animation. */ - newTransactions: Transaction[]; - - /** The navigation handler for a row tap (owned by the router). */ - onSelectRow: (item: SearchListItem, transactionPreviewData?: TransactionPreviewData, event?: ModifiedMouseEvent) => void; - - /** The list footer (pagination / pending skeleton). */ - ListFooterComponent?: React.JSX.Element; - - /** Fires when the list scrolls near its end (router's fetchMoreResults). */ - onEndReached: () => void; - - /** Fires on the list's first layout and on layout changes. */ - onLayout: () => void; - - /** Scroll handler forwarded to the list. */ - onScroll?: (event: NativeSyntheticEvent) => void; - - /** Content container style for the list. */ - contentContainerStyle: StyleProp; - - /** Outer container style for the list wrapper. */ - containerStyle: StyleProp; - - /** Imperative handle for highlight-driven scrolling, set by the router. */ - ref?: ForwardedRef; -}; +type ExpenseGroupedSearchViewProps = CommonSearchViewProps & {isAttendeesEnabledForMovingPolicy?: boolean; nonPersonalAndWorkspaceCards?: CardList}; const keyExtractor = (item: SearchListItem, index: number) => item.keyForList ?? `${index}`; diff --git a/src/components/Search/ExpenseReportSearchView.tsx b/src/components/Search/ExpenseReportSearchView.tsx index 9a29db9cecf5..1a8ba373e619 100644 --- a/src/components/Search/ExpenseReportSearchView.tsx +++ b/src/components/Search/ExpenseReportSearchView.tsx @@ -1,19 +1,16 @@ import type {ExtendedTargetedEvent} from '@components/SelectionList/ListItem/types'; -import type {TransactionPreviewData} from '@libs/actions/Search'; -import type {ModifiedMouseEvent} from '@libs/Navigation/helpers/openInternalRouteInNewTab'; import {isTransactionReportGroupListItemType} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; -import type {Transaction} from '@src/types/onyx'; -import type {ForwardedRef} from 'react'; -import type {NativeScrollEvent, NativeSyntheticEvent, StyleProp, ViewStyle} from 'react-native'; +import type {NativeSyntheticEvent} from 'react-native'; import React, {useImperativeHandle} from 'react'; import type {SearchListItem} from './SearchList/ListItem/types'; -import type {SearchColumnType, SearchQueryJSON, SelectedTransactions} from './types'; +import type {CommonSearchViewProps} from './searchViewProps'; +import type {SelectedTransactions} from './types'; import useSearchListViewState from './hooks/useSearchListViewState'; import AnimatedExitRow from './primitives/AnimatedExitRow'; @@ -22,66 +19,7 @@ import BaseSearchList from './SearchList/BaseSearchList'; import ExpenseReportListItem from './SearchList/ListItem/ExpenseReportListItem'; import SearchListViewLayout from './SearchListViewLayout'; -/** Imperative handle the router uses for highlight-driven scrolling (mirrors SearchList's handle). */ -type SearchListHandle = { - scrollToIndex: (index: number, animated?: boolean) => void; -}; - -type ExpenseReportSearchViewProps = { - /** The expense-report search query. */ - queryJSON: SearchQueryJSON; - - /** The sorted report rows to render (from the router's useSearchSnapshot). */ - data: SearchListItem[]; - - /** The columns to render in the list. */ - columns: SearchColumnType[]; - - /** Whether the list supports multi-select. */ - canSelectMultiple: boolean; - - /** Whether the action column uses its wider variant. */ - isActionColumnWide: boolean; - - /** Whether mobile selection mode is on. */ - isMobileSelectionModeEnabled: boolean; - - /** The column header element (undefined on narrow layouts). */ - SearchTableHeader?: React.JSX.Element; - - /** Whether a table header bar is shown above the list. */ - tableHeaderVisible: boolean; - - /** Whether everything has been loaded (gates the fully-checked select-all state). */ - hasLoadedAllTransactions?: boolean; - - /** Rows flagged for the post-create highlight animation (feeds BaseSearchList extraData). */ - newTransactions: Transaction[]; - - /** The navigation handler for a row tap (owned by the router). */ - onSelectRow: (item: SearchListItem, transactionPreviewData?: TransactionPreviewData, event?: ModifiedMouseEvent) => void; - - /** The list footer (pagination / pending skeleton). */ - ListFooterComponent?: React.JSX.Element; - - /** Fires when the list scrolls near its end (router's fetchMoreResults). */ - onEndReached: () => void; - - /** Fires on the list's first layout and on layout changes. */ - onLayout: () => void; - - /** Scroll handler forwarded to the list. */ - onScroll?: (event: NativeSyntheticEvent) => void; - - /** Content container style for the list. */ - contentContainerStyle: StyleProp; - - /** Outer container style for the list wrapper. */ - containerStyle: StyleProp; - - /** Imperative handle for highlight-driven scrolling, set by the router. */ - ref?: ForwardedRef; -}; +type ExpenseReportSearchViewProps = CommonSearchViewProps; const keyExtractor = (item: SearchListItem, index: number) => item.keyForList ?? `${index}`; diff --git a/src/components/Search/TaskSearchView.tsx b/src/components/Search/TaskSearchView.tsx index 385e261e3754..9779d5fb3be5 100644 --- a/src/components/Search/TaskSearchView.tsx +++ b/src/components/Search/TaskSearchView.tsx @@ -1,18 +1,13 @@ import type {ExtendedTargetedEvent} from '@components/SelectionList/ListItem/types'; -import type {TransactionPreviewData} from '@libs/actions/Search'; -import type {ModifiedMouseEvent} from '@libs/Navigation/helpers/openInternalRouteInNewTab'; - import CONST from '@src/CONST'; -import type {Transaction} from '@src/types/onyx'; -import type {ForwardedRef} from 'react'; -import type {NativeScrollEvent, NativeSyntheticEvent, StyleProp, ViewStyle} from 'react-native'; +import type {NativeSyntheticEvent} from 'react-native'; import React, {useImperativeHandle} from 'react'; import type {SearchListItem} from './SearchList/ListItem/types'; -import type {SearchColumnType, SearchQueryJSON} from './types'; +import type {CommonSearchViewProps} from './searchViewProps'; import useSearchListViewState from './hooks/useSearchListViewState'; import AnimatedExitRow from './primitives/AnimatedExitRow'; @@ -21,66 +16,7 @@ import BaseSearchList from './SearchList/BaseSearchList'; import TaskListItem from './SearchList/ListItem/TaskListItem'; import SearchListViewLayout from './SearchListViewLayout'; -/** Imperative handle the router uses for highlight-driven scrolling (mirrors SearchList's handle). */ -type SearchListHandle = { - scrollToIndex: (index: number, animated?: boolean) => void; -}; - -type TaskSearchViewProps = { - /** The task search query. */ - queryJSON: SearchQueryJSON; - - /** The sorted rows to render (from the router's useSearchSnapshot). */ - data: SearchListItem[]; - - /** The columns to render in the list (drives the table min-width and header). */ - columns: SearchColumnType[]; - - /** Whether the list supports multi-select. */ - canSelectMultiple: boolean; - - /** Whether the action column uses its wider variant. */ - isActionColumnWide: boolean; - - /** Whether mobile selection mode is on. */ - isMobileSelectionModeEnabled: boolean; - - /** The column header element (undefined on narrow layouts). */ - SearchTableHeader?: React.JSX.Element; - - /** Whether a table header bar is shown above the list. */ - tableHeaderVisible: boolean; - - /** Whether everything has been loaded (gates the fully-checked select-all state). */ - hasLoadedAllTransactions?: boolean; - - /** Rows flagged for the post-create highlight animation (feeds BaseSearchList extraData). */ - newTransactions: Transaction[]; - - /** The navigation handler for a row tap (owned by the router). */ - onSelectRow: (item: SearchListItem, transactionPreviewData?: TransactionPreviewData, event?: ModifiedMouseEvent) => void; - - /** The list footer (pagination / pending skeleton). */ - ListFooterComponent?: React.JSX.Element; - - /** Fires when the list scrolls near its end (router's fetchMoreResults). */ - onEndReached: () => void; - - /** Fires on the list's first layout and on layout changes. */ - onLayout: () => void; - - /** Scroll handler forwarded to the list. */ - onScroll?: (event: NativeSyntheticEvent) => void; - - /** Content container style for the list. */ - contentContainerStyle: StyleProp; - - /** Outer container style for the list wrapper. */ - containerStyle: StyleProp; - - /** Imperative handle for highlight-driven scrolling, set by the router. */ - ref?: ForwardedRef; -}; +type TaskSearchViewProps = CommonSearchViewProps; const keyExtractor = (item: SearchListItem, index: number) => item.keyForList ?? `${index}`; diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index a8b62a118521..7dcec8f873a6 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -89,6 +89,7 @@ import {View} from 'react-native'; import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; import type {ReportActionListItemType, SearchListItem, TransactionGroupListItemType, TransactionListItemType, TransactionReportGroupListItemType} from './SearchList/ListItem/types'; +import type {CommonSearchViewProps} from './searchViewProps'; import type {SearchColumnType, SearchParams, SearchQueryJSON, SearchSortBy, SortOrder} from './types'; import ChatSearchView from './ChatSearchView'; @@ -1054,7 +1055,7 @@ function Search({ // directly, and the snapshot, lifecycle and selection providers stay here so the data layer runs once. const isTransactionListView = type !== CONST.SEARCH.DATA_TYPES.CHAT && type !== CONST.SEARCH.DATA_TYPES.TASK && type !== CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT; - const commonViewProps = { + const commonViewProps: CommonSearchViewProps = { ref: searchListRef, queryJSON, data: stableSortedData, diff --git a/src/components/Search/searchViewProps.ts b/src/components/Search/searchViewProps.ts new file mode 100644 index 000000000000..094cc74302bd --- /dev/null +++ b/src/components/Search/searchViewProps.ts @@ -0,0 +1,78 @@ +import type {TransactionPreviewData} from '@libs/actions/Search'; +import type {ModifiedMouseEvent} from '@libs/Navigation/helpers/openInternalRouteInNewTab'; + +import type {Transaction} from '@src/types/onyx'; + +import type React from 'react'; +import type {ForwardedRef} from 'react'; +import type {NativeScrollEvent, NativeSyntheticEvent, StyleProp, ViewStyle} from 'react-native'; + +import type {SearchListItem} from './SearchList/ListItem/types'; +import type {SearchColumnType, SearchQueryJSON} from './types'; + +/** Imperative handle the router uses for highlight-driven scrolling. */ +type SearchListHandle = { + scrollToIndex: (index: number, animated?: boolean) => void; +}; + +/** + * Props shared by every dedicated Search view. The router (``) builds these once and spreads + * them into whichever view renders; each view narrows to its own extras on top of this shape. + */ +type CommonSearchViewProps = { + /** The search query for the current view. */ + queryJSON: SearchQueryJSON; + + /** The sorted rows to render (from the router's useSearchSnapshot). */ + data: SearchListItem[]; + + /** The columns to render in the list (drives the table min-width and header). */ + columns: SearchColumnType[]; + + /** Whether the list supports multi-select. */ + canSelectMultiple: boolean; + + /** Whether the action column uses its wider variant. */ + isActionColumnWide: boolean; + + /** Whether mobile selection mode is on. */ + isMobileSelectionModeEnabled: boolean; + + /** The column header element (undefined on narrow layouts). */ + SearchTableHeader?: React.JSX.Element; + + /** Whether a table header bar is shown above the list. */ + tableHeaderVisible: boolean; + + /** Whether everything has been loaded (gates the fully-checked select-all state). */ + hasLoadedAllTransactions?: boolean; + + /** Rows flagged for the post-create highlight animation (feeds BaseSearchList extraData). */ + newTransactions: Transaction[]; + + /** The navigation handler for a row tap (owned by the router). */ + onSelectRow: (item: SearchListItem, transactionPreviewData?: TransactionPreviewData, event?: ModifiedMouseEvent) => void; + + /** The list footer (pagination / pending skeleton). */ + ListFooterComponent?: React.JSX.Element; + + /** Fires when the list scrolls near its end (router's fetchMoreResults). */ + onEndReached: () => void; + + /** Fires on the list's first layout and on layout changes. */ + onLayout: () => void; + + /** Scroll handler forwarded to the list. */ + onScroll?: (event: NativeSyntheticEvent) => void; + + /** Content container style for the list. */ + contentContainerStyle: StyleProp; + + /** Outer container style for the list wrapper. */ + containerStyle: StyleProp; + + /** Imperative handle for highlight-driven scrolling, set by the router. */ + ref?: ForwardedRef; +}; + +export type {CommonSearchViewProps, SearchListHandle}; diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 0419e7ed8a7d..fbc8565b8cf6 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -6339,7 +6339,7 @@ function splitGroupsIntoPairs(data: SearchListItem[]): {splitData: SearchListIte /** * Checks whether a transaction belongs to the given group list item, based on the active groupBy. */ -function isTransactionMatchWithGroupItem(transaction: OnyxTypes.Transaction, groupItem: SearchListItem, groupBy: SearchGroupBy | undefined) { +function isTransactionMatchWithGroupItem(transaction: OnyxTypes.Transaction, groupItem: SearchListItem, groupBy: SearchGroupBy | undefined): boolean { if (groupBy === CONST.SEARCH.GROUP_BY.CARD) { return transaction.cardID === (groupItem as TransactionCardGroupListItemType).cardID; } From 719bc3324087c33a4f225947fa6e52554d9ea26b Mon Sep 17 00:00:00 2001 From: bartlomiej obudzinski Date: Mon, 6 Jul 2026 13:11:43 +0200 Subject: [PATCH 3/4] refactor: drop unused ListItemType and default-export CommonSearchViewProps --- src/components/Search/ChatSearchView.tsx | 2 +- src/components/Search/ExpenseFlatSearchView.tsx | 2 +- src/components/Search/ExpenseGroupedSearchView.tsx | 2 +- src/components/Search/ExpenseReportSearchView.tsx | 2 +- src/components/Search/TaskSearchView.tsx | 2 +- src/components/Search/index.tsx | 2 +- src/components/Search/searchViewProps.ts | 2 +- src/types/onyx/SearchResults.ts | 11 ----------- 8 files changed, 7 insertions(+), 18 deletions(-) diff --git a/src/components/Search/ChatSearchView.tsx b/src/components/Search/ChatSearchView.tsx index 21f390a3fa3e..16b1eb22bda2 100644 --- a/src/components/Search/ChatSearchView.tsx +++ b/src/components/Search/ChatSearchView.tsx @@ -7,7 +7,7 @@ import type {NativeSyntheticEvent} from 'react-native'; import React, {useImperativeHandle} from 'react'; import type {SearchListItem} from './SearchList/ListItem/types'; -import type {CommonSearchViewProps} from './searchViewProps'; +import type CommonSearchViewProps from './searchViewProps'; import useSearchListViewState from './hooks/useSearchListViewState'; import AnimatedExitRow from './primitives/AnimatedExitRow'; diff --git a/src/components/Search/ExpenseFlatSearchView.tsx b/src/components/Search/ExpenseFlatSearchView.tsx index aa30a299bbad..b91628961bd6 100644 --- a/src/components/Search/ExpenseFlatSearchView.tsx +++ b/src/components/Search/ExpenseFlatSearchView.tsx @@ -8,7 +8,7 @@ import type {NativeSyntheticEvent} from 'react-native'; import React, {useImperativeHandle} from 'react'; import type {SearchListItem} from './SearchList/ListItem/types'; -import type {CommonSearchViewProps} from './searchViewProps'; +import type CommonSearchViewProps from './searchViewProps'; import useSearchListViewState from './hooks/useSearchListViewState'; import AnimatedExitRow from './primitives/AnimatedExitRow'; diff --git a/src/components/Search/ExpenseGroupedSearchView.tsx b/src/components/Search/ExpenseGroupedSearchView.tsx index f0d27ae2f340..d31aded61723 100644 --- a/src/components/Search/ExpenseGroupedSearchView.tsx +++ b/src/components/Search/ExpenseGroupedSearchView.tsx @@ -18,7 +18,7 @@ import type {NativeSyntheticEvent} from 'react-native'; import React, {useImperativeHandle, useState} from 'react'; import type {SearchListItem} from './SearchList/ListItem/types'; -import type {CommonSearchViewProps} from './searchViewProps'; +import type CommonSearchViewProps from './searchViewProps'; import type {SearchQueryJSON, SelectedTransactions} from './types'; import useSearchListViewState from './hooks/useSearchListViewState'; diff --git a/src/components/Search/ExpenseReportSearchView.tsx b/src/components/Search/ExpenseReportSearchView.tsx index 1a8ba373e619..88503af338b4 100644 --- a/src/components/Search/ExpenseReportSearchView.tsx +++ b/src/components/Search/ExpenseReportSearchView.tsx @@ -9,7 +9,7 @@ import type {NativeSyntheticEvent} from 'react-native'; import React, {useImperativeHandle} from 'react'; import type {SearchListItem} from './SearchList/ListItem/types'; -import type {CommonSearchViewProps} from './searchViewProps'; +import type CommonSearchViewProps from './searchViewProps'; import type {SelectedTransactions} from './types'; import useSearchListViewState from './hooks/useSearchListViewState'; diff --git a/src/components/Search/TaskSearchView.tsx b/src/components/Search/TaskSearchView.tsx index 9779d5fb3be5..516365ffe339 100644 --- a/src/components/Search/TaskSearchView.tsx +++ b/src/components/Search/TaskSearchView.tsx @@ -7,7 +7,7 @@ import type {NativeSyntheticEvent} from 'react-native'; import React, {useImperativeHandle} from 'react'; import type {SearchListItem} from './SearchList/ListItem/types'; -import type {CommonSearchViewProps} from './searchViewProps'; +import type CommonSearchViewProps from './searchViewProps'; import useSearchListViewState from './hooks/useSearchListViewState'; import AnimatedExitRow from './primitives/AnimatedExitRow'; diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 7dcec8f873a6..4529d52d77f3 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -89,7 +89,7 @@ import {View} from 'react-native'; import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; import type {ReportActionListItemType, SearchListItem, TransactionGroupListItemType, TransactionListItemType, TransactionReportGroupListItemType} from './SearchList/ListItem/types'; -import type {CommonSearchViewProps} from './searchViewProps'; +import type CommonSearchViewProps from './searchViewProps'; import type {SearchColumnType, SearchParams, SearchQueryJSON, SearchSortBy, SortOrder} from './types'; import ChatSearchView from './ChatSearchView'; diff --git a/src/components/Search/searchViewProps.ts b/src/components/Search/searchViewProps.ts index 094cc74302bd..06d7f594e2a3 100644 --- a/src/components/Search/searchViewProps.ts +++ b/src/components/Search/searchViewProps.ts @@ -75,4 +75,4 @@ type CommonSearchViewProps = { ref?: ForwardedRef; }; -export type {CommonSearchViewProps, SearchListHandle}; +export default CommonSearchViewProps; diff --git a/src/types/onyx/SearchResults.ts b/src/types/onyx/SearchResults.ts index f6b2de4b4a34..caa95fa809b5 100644 --- a/src/types/onyx/SearchResults.ts +++ b/src/types/onyx/SearchResults.ts @@ -1,6 +1,3 @@ -import type ChatListItem from '@components/Search/SearchList/ListItem/ChatListItem'; -import type TransactionGroupListItem from '@components/Search/SearchList/ListItem/TransactionGroupListItem'; -import type TransactionListItem from '@components/Search/SearchList/ListItem/TransactionListItem'; import type {ReportActionListItemType, TaskListItemType, TransactionGroupListItemType, TransactionListItemType} from '@components/Search/SearchList/ListItem/types'; import type {SearchStatus} from '@components/Search/types'; @@ -24,13 +21,6 @@ import type {TransactionViolation} from './TransactionViolation'; /** Types of search data */ type SearchDataTypes = ValueOf; -/** Model of search result list item */ -type ListItemType = C extends typeof CONST.SEARCH.DATA_TYPES.CHAT - ? typeof ChatListItem - : T extends typeof CONST.SEARCH.STATUS.EXPENSE.ALL - ? typeof TransactionListItem - : typeof TransactionGroupListItem; - /** Model of search list item data type */ type ListItemDataType = C extends typeof CONST.SEARCH.DATA_TYPES.CHAT ? ReportActionListItemType[] @@ -331,7 +321,6 @@ type SearchResults = { export default SearchResults; export type { - ListItemType, ListItemDataType, SearchTask, SearchTransactionAction, From db0dc4b8b780152b3f245a0467b65e56bce44271 Mon Sep 17 00:00:00 2001 From: bartlomiej obudzinski Date: Mon, 6 Jul 2026 14:00:03 +0200 Subject: [PATCH 4/4] refactor: extract shared TransactionViewExtras type for expense views --- src/components/Search/ChatSearchView.tsx | 2 +- src/components/Search/ExpenseFlatSearchView.tsx | 5 ++--- src/components/Search/ExpenseGroupedSearchView.tsx | 6 +++--- src/components/Search/ExpenseReportSearchView.tsx | 2 +- src/components/Search/TaskSearchView.tsx | 2 +- src/components/Search/index.tsx | 2 +- src/components/Search/searchViewProps.ts | 13 +++++++++++-- 7 files changed, 20 insertions(+), 12 deletions(-) diff --git a/src/components/Search/ChatSearchView.tsx b/src/components/Search/ChatSearchView.tsx index 16b1eb22bda2..21f390a3fa3e 100644 --- a/src/components/Search/ChatSearchView.tsx +++ b/src/components/Search/ChatSearchView.tsx @@ -7,7 +7,7 @@ import type {NativeSyntheticEvent} from 'react-native'; import React, {useImperativeHandle} from 'react'; import type {SearchListItem} from './SearchList/ListItem/types'; -import type CommonSearchViewProps from './searchViewProps'; +import type {CommonSearchViewProps} from './searchViewProps'; import useSearchListViewState from './hooks/useSearchListViewState'; import AnimatedExitRow from './primitives/AnimatedExitRow'; diff --git a/src/components/Search/ExpenseFlatSearchView.tsx b/src/components/Search/ExpenseFlatSearchView.tsx index b91628961bd6..e4081d3bbf0a 100644 --- a/src/components/Search/ExpenseFlatSearchView.tsx +++ b/src/components/Search/ExpenseFlatSearchView.tsx @@ -1,14 +1,13 @@ import type {ExtendedTargetedEvent} from '@components/SelectionList/ListItem/types'; import CONST from '@src/CONST'; -import type {CardList} from '@src/types/onyx'; import type {NativeSyntheticEvent} from 'react-native'; import React, {useImperativeHandle} from 'react'; import type {SearchListItem} from './SearchList/ListItem/types'; -import type CommonSearchViewProps from './searchViewProps'; +import type {CommonSearchViewProps, TransactionViewExtras} from './searchViewProps'; import useSearchListViewState from './hooks/useSearchListViewState'; import AnimatedExitRow from './primitives/AnimatedExitRow'; @@ -17,7 +16,7 @@ import BaseSearchList from './SearchList/BaseSearchList'; import TransactionListItem from './SearchList/ListItem/TransactionListItem'; import SearchListViewLayout from './SearchListViewLayout'; -type ExpenseFlatSearchViewProps = CommonSearchViewProps & {isAttendeesEnabledForMovingPolicy?: boolean; nonPersonalAndWorkspaceCards?: CardList}; +type ExpenseFlatSearchViewProps = CommonSearchViewProps & TransactionViewExtras; const keyExtractor = (item: SearchListItem, index: number) => item.keyForList ?? `${index}`; diff --git a/src/components/Search/ExpenseGroupedSearchView.tsx b/src/components/Search/ExpenseGroupedSearchView.tsx index d31aded61723..dc834187779d 100644 --- a/src/components/Search/ExpenseGroupedSearchView.tsx +++ b/src/components/Search/ExpenseGroupedSearchView.tsx @@ -11,14 +11,14 @@ import variables from '@styles/variables'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {columnsSelector} from '@src/selectors/AdvancedSearchFiltersForm'; -import type {CardList, Transaction} from '@src/types/onyx'; +import type {Transaction} from '@src/types/onyx'; import type {NativeSyntheticEvent} from 'react-native'; import React, {useImperativeHandle, useState} from 'react'; import type {SearchListItem} from './SearchList/ListItem/types'; -import type CommonSearchViewProps from './searchViewProps'; +import type {CommonSearchViewProps, TransactionViewExtras} from './searchViewProps'; import type {SearchQueryJSON, SelectedTransactions} from './types'; import useSearchListViewState from './hooks/useSearchListViewState'; @@ -31,7 +31,7 @@ import TransactionGroupListItem from './SearchList/ListItem/TransactionGroupList import {isGroupChildrenContainerItem, isGroupHeaderItem} from './SearchList/ListItem/types'; import SearchListViewLayout from './SearchListViewLayout'; -type ExpenseGroupedSearchViewProps = CommonSearchViewProps & {isAttendeesEnabledForMovingPolicy?: boolean; nonPersonalAndWorkspaceCards?: CardList}; +type ExpenseGroupedSearchViewProps = CommonSearchViewProps & TransactionViewExtras; const keyExtractor = (item: SearchListItem, index: number) => item.keyForList ?? `${index}`; diff --git a/src/components/Search/ExpenseReportSearchView.tsx b/src/components/Search/ExpenseReportSearchView.tsx index 88503af338b4..1a8ba373e619 100644 --- a/src/components/Search/ExpenseReportSearchView.tsx +++ b/src/components/Search/ExpenseReportSearchView.tsx @@ -9,7 +9,7 @@ import type {NativeSyntheticEvent} from 'react-native'; import React, {useImperativeHandle} from 'react'; import type {SearchListItem} from './SearchList/ListItem/types'; -import type CommonSearchViewProps from './searchViewProps'; +import type {CommonSearchViewProps} from './searchViewProps'; import type {SelectedTransactions} from './types'; import useSearchListViewState from './hooks/useSearchListViewState'; diff --git a/src/components/Search/TaskSearchView.tsx b/src/components/Search/TaskSearchView.tsx index 516365ffe339..9779d5fb3be5 100644 --- a/src/components/Search/TaskSearchView.tsx +++ b/src/components/Search/TaskSearchView.tsx @@ -7,7 +7,7 @@ import type {NativeSyntheticEvent} from 'react-native'; import React, {useImperativeHandle} from 'react'; import type {SearchListItem} from './SearchList/ListItem/types'; -import type CommonSearchViewProps from './searchViewProps'; +import type {CommonSearchViewProps} from './searchViewProps'; import useSearchListViewState from './hooks/useSearchListViewState'; import AnimatedExitRow from './primitives/AnimatedExitRow'; diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 4529d52d77f3..7dcec8f873a6 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -89,7 +89,7 @@ import {View} from 'react-native'; import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; import type {ReportActionListItemType, SearchListItem, TransactionGroupListItemType, TransactionListItemType, TransactionReportGroupListItemType} from './SearchList/ListItem/types'; -import type CommonSearchViewProps from './searchViewProps'; +import type {CommonSearchViewProps} from './searchViewProps'; import type {SearchColumnType, SearchParams, SearchQueryJSON, SearchSortBy, SortOrder} from './types'; import ChatSearchView from './ChatSearchView'; diff --git a/src/components/Search/searchViewProps.ts b/src/components/Search/searchViewProps.ts index 06d7f594e2a3..40da64005c12 100644 --- a/src/components/Search/searchViewProps.ts +++ b/src/components/Search/searchViewProps.ts @@ -1,7 +1,7 @@ import type {TransactionPreviewData} from '@libs/actions/Search'; import type {ModifiedMouseEvent} from '@libs/Navigation/helpers/openInternalRouteInNewTab'; -import type {Transaction} from '@src/types/onyx'; +import type {CardList, Transaction} from '@src/types/onyx'; import type React from 'react'; import type {ForwardedRef} from 'react'; @@ -75,4 +75,13 @@ type CommonSearchViewProps = { ref?: ForwardedRef; }; -export default CommonSearchViewProps; +/** Extra props specific to the transaction views (expense/invoice/trip): attendee tracking and card rendering. */ +type TransactionViewExtras = { + /** Precomputed attendee-tracking boolean (derived from policy-for-moving-expenses). */ + isAttendeesEnabledForMovingPolicy?: boolean; + + /** Non-personal and workspace cards for row rendering (subscribed once by the router). */ + nonPersonalAndWorkspaceCards?: CardList; +}; + +export type {CommonSearchViewProps, TransactionViewExtras};