diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index ccbed243a3ba3..eebcc94492973 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -19,18 +19,14 @@ import type { } from '@components/SelectionListWithSections/types'; import SearchRowSkeleton from '@components/Skeletons/SearchRowSkeleton'; import {useWideRHPActions} from '@components/WideRHPContextProvider'; -import useActionLoadingReportIDs from '@hooks/useActionLoadingReportIDs'; -import useArchivedReportsIdSet from '@hooks/useArchivedReportsIdSet'; -import useCardFeedsForDisplay from '@hooks/useCardFeedsForDisplay'; import useConfirmModal from '@hooks/useConfirmModal'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; -import useMultipleSnapshots from '@hooks/useMultipleSnapshots'; import useNetwork from '@hooks/useNetwork'; -import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import usePrevious from '@hooks/usePrevious'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useSearchData from '@hooks/useSearchData'; import useSearchHighlightAndScroll from '@hooks/useSearchHighlightAndScroll'; import useSearchShouldCalculateTotals from '@hooks/useSearchShouldCalculateTotals'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -43,15 +39,12 @@ import Log from '@libs/Log'; import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; import type {PlatformStackNavigationProp} from '@libs/Navigation/PlatformStackNavigation/types'; import {isSplitAction} from '@libs/ReportSecondaryActionUtils'; -import {canEditFieldOfMoneyRequest, canHoldUnholdReportAction, canRejectReportAction, isOneTransactionReport, selectFilteredReportActions} from '@libs/ReportUtils'; +import {canEditFieldOfMoneyRequest, canHoldUnholdReportAction, canRejectReportAction, isOneTransactionReport} from '@libs/ReportUtils'; import {buildCannedSearchQuery, buildSearchQueryString} from '@libs/SearchQueryUtils'; import { createAndOpenSearchTransactionThread, - getColumnsToShow, getListItem, - getSections, getSortedSections, - getSuggestedSearches, getWideAmountIndicators, isGroupedItemArray, isReportActionListItemType, @@ -75,8 +68,7 @@ import NAVIGATORS from '@src/NAVIGATORS'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; -import {columnsSelector} from '@src/selectors/AdvancedSearchFiltersForm'; -import type {OutstandingReportsByPolicyIDDerivedValue, SaveSearch, Transaction} from '@src/types/onyx'; +import type {OutstandingReportsByPolicyIDDerivedValue, Transaction} from '@src/types/onyx'; import type SearchResults from '@src/types/onyx/SearchResults'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import arraysEqual from '@src/utils/arraysEqual'; @@ -241,49 +233,51 @@ function Search({ } = useSearchActionsContext(); const [offset, setOffset] = useState(0); - const [transactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION); - const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); - const previousTransactions = usePrevious(transactions); - const [reportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS); - const [outstandingReportsByPolicyID] = useOnyx(ONYXKEYS.DERIVED.OUTSTANDING_REPORTS_BY_POLICY_ID); - const [violations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); - const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); - const {accountID, email, login} = useCurrentUserPersonalDetails(); - const isActionLoadingSet = useActionLoadingReportIDs(); - const [allReportMetadata] = useOnyx(ONYXKEYS.COLLECTION.REPORT_METADATA); - const [visibleColumns] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM, {selector: columnsSelector}); - const [customCardNames] = useOnyx(ONYXKEYS.NVP_EXPENSIFY_COMPANY_CARDS_CUSTOM_NAMES); - const [cardList] = useOnyx(ONYXKEYS.CARD_LIST); - const isExpenseReportType = type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT; const {markReportIDAsMultiTransactionExpense, unmarkReportIDAsMultiTransactionExpense} = useWideRHPActions(); - const archivedReportsIdSet = useArchivedReportsIdSet(); - - const [exportReportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS, { - canEvict: false, + // There's a race condition in Onyx which makes it return data from the previous Search, so in addition to checking that the data is loaded + // we also need to check that the searchResults matches the type and status of the current search + const isDataLoaded = shouldUseLiveData || isSearchDataLoaded(searchResults, queryJSON); - selector: selectFilteredReportActions, + const { + sections: filteredData, + allDataLength, + filteredDataLength, + columns: currentColumns, + customCardNames, + violations, + searchKey, + savedSearchName, + transactions, + reportActions, + outstandingReportsByPolicyID, + introSelected, + accountID, + login, + searchDataType, + hasErrors, + shouldShowLoadingState, + shouldShowLoadingMoreItems, + hasLoadedAllTransactions, + } = useSearchData({ + queryJSON, + searchResults, + isDataLoaded, + shouldUseLiveData, + searchRequestResponseStatusCode, }); - const [cardFeeds, cardFeedsResult] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER); - const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); - - const {defaultCardFeed} = useCardFeedsForDisplay(); - const suggestedSearches = useMemo(() => getSuggestedSearches(accountID, defaultCardFeed?.id), [defaultCardFeed?.id, accountID]); - const searchKey = useMemo(() => Object.values(suggestedSearches).find((search) => search.recentSearchHash === recentSearchHash)?.key, [suggestedSearches, recentSearchHash]); - const searchDataType = useMemo(() => (shouldUseLiveData ? CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT : searchResults?.search?.type), [shouldUseLiveData, searchResults?.search?.type]); + const {email} = useCurrentUserPersonalDetails(); const shouldCalculateTotals = useSearchShouldCalculateTotals(searchKey, hash, offset === 0); + const previousTransactions = usePrevious(transactions); const previousReportActions = usePrevious(reportActions); - const {translate, localeCompare, formatPhoneNumber} = useLocalize(); + const {translate, localeCompare} = useLocalize(); const searchListRef = useRef(null); const spanExistedOnMount = useRef(!!getSpan(CONST.TELEMETRY.SPAN_NAVIGATE_AFTER_EXPENSE_CREATE)); - const savedSearchSelector = useCallback((searches: OnyxEntry) => searches?.[hash], [hash]); - const [savedSearch] = useOnyx(ONYXKEYS.SAVED_SEARCHES, {selector: savedSearchSelector}); - const handleDEWModalOpen = useCallback(() => { if (onDEWModalOpen) { onDEWModalOpen(); @@ -321,9 +315,6 @@ function Search({ const prevValidGroupBy = usePrevious(validGroupBy); const isSearchResultsEmpty = !searchResults?.data || isSearchResultsEmptyUtil(searchResults, validGroupBy); - // When grouping by card, we need cardFeeds to display feed names - const isCardFeedsLoading = validGroupBy === CONST.SEARCH.GROUP_BY.CARD && cardFeedsResult?.status === 'loading'; - useEffect(() => { if (prevValidGroupBy === validGroupBy) { return; @@ -389,152 +380,10 @@ function Search({ shouldUseLiveData, }); - // There's a race condition in Onyx which makes it return data from the previous Search, so in addition to checking that the data is loaded - // we also need to check that the searchResults matches the type and status of the current search - const isDataLoaded = shouldUseLiveData || isSearchDataLoaded(searchResults, queryJSON); - - const hasErrors = Object.keys(searchResults?.errors ?? {}).length > 0 && !isOffline; - - // For to-do searches, we never show loading state since the data is always available locally from Onyx - const shouldShowLoadingState = - !shouldUseLiveData && - !isOffline && - (!isDataLoaded || - (!!searchResults?.search.isLoading && Array.isArray(searchResults?.data) && searchResults?.data.length === 0) || - (hasErrors && searchRequestResponseStatusCode === null) || - isCardFeedsLoading); - const shouldShowLoadingMoreItems = !shouldShowLoadingState && searchResults?.search?.isLoading && searchResults?.search?.offset > 0; const prevIsSearchResultEmpty = usePrevious(isSearchResultsEmpty); - const [baseFilteredData, filteredDataLength, allDataLength] = useMemo(() => { - if (searchResults === undefined || !isDataLoaded) { - return [[], 0, 0]; - } - - // Group-by option cannot be used for chats or tasks - const isChat = type === CONST.SEARCH.DATA_TYPES.CHAT; - const isTask = type === CONST.SEARCH.DATA_TYPES.TASK; - if (validGroupBy && (isChat || isTask)) { - return [[], 0, 0]; - } - - const [filteredData1, allLength] = getSections({ - type, - data: searchResults.data, - policies, - currentAccountID: accountID, - currentUserEmail: email ?? '', - translate, - formatPhoneNumber, - bankAccountList, - groupBy: validGroupBy, - reportActions: exportReportActions, - currentSearch: searchKey, - archivedReportsIDList: archivedReportsIdSet, - queryJSON, - isActionLoadingSet, - cardFeeds, - isOffline, - allTransactionViolations: violations, - customCardNames, - allReportMetadata, - cardList, - }); - return [filteredData1, filteredData1.length, allLength]; - }, [ - searchKey, - isOffline, - exportReportActions, - validGroupBy, - isDataLoaded, - searchResults, - type, - archivedReportsIdSet, - translate, - formatPhoneNumber, - accountID, - queryJSON, - email, - isActionLoadingSet, - cardFeeds, - policies, - bankAccountList, - violations, - customCardNames, - allReportMetadata, - cardList, - ]); - // For group-by views, each grouped item has a transactionsQueryJSON with a hash pointing to a separate snapshot // containing its individual transactions. We collect these hashes and fetch their snapshots to enrich the grouped items. - const groupByTransactionHashes = useMemo(() => { - if (!validGroupBy) { - return []; - } - return (baseFilteredData as TransactionGroupListItemType[]) - .map((item) => (item.transactionsQueryJSON?.hash ? String(item.transactionsQueryJSON.hash) : undefined)) - .filter((hashValue): hashValue is string => !!hashValue); - }, [validGroupBy, baseFilteredData]); - - const groupByTransactionSnapshots = useMultipleSnapshots(groupByTransactionHashes); - - const filteredData = useMemo(() => { - if (!validGroupBy || isExpenseReportType) { - return baseFilteredData; - } - - const enriched = (baseFilteredData as TransactionGroupListItemType[]).map((item) => { - const snapshot = item.transactionsQueryJSON?.hash ? groupByTransactionSnapshots[String(item.transactionsQueryJSON.hash)] : undefined; - if (!snapshot?.data) { - return item; - } - - const [transactions1] = getSections({ - type: CONST.SEARCH.DATA_TYPES.EXPENSE, - data: snapshot.data, - currentAccountID: accountID, - currentUserEmail: email ?? '', - bankAccountList, - translate, - formatPhoneNumber, - isActionLoadingSet, - cardFeeds, - allReportMetadata, - cardList, - }); - return {...item, transactions: transactions1 as TransactionListItemType[]}; - }); - - return enriched; - }, [ - validGroupBy, - isExpenseReportType, - baseFilteredData, - groupByTransactionSnapshots, - accountID, - email, - translate, - formatPhoneNumber, - isActionLoadingSet, - cardFeeds, - bankAccountList, - allReportMetadata, - cardList, - ]); - - const hasLoadedAllTransactions = useMemo(() => { - if (!validGroupBy) { - return true; - } - // For group-by views, check if all transactions in groups have been loaded - return (baseFilteredData as TransactionGroupListItemType[]).every((item) => { - const snapshot = item.transactionsQueryJSON?.hash || item.transactionsQueryJSON?.hash === 0 ? groupByTransactionSnapshots[String(item.transactionsQueryJSON.hash)] : undefined; - // If snapshot doesn't exist, the group hasn't been expanded yet (transactions not loaded) - // If snapshot exists and has hasMoreResults: true, not all transactions are loaded - return !!snapshot && !snapshot?.search?.hasMoreResults; - }); - }, [validGroupBy, baseFilteredData, groupByTransactionSnapshots]); - useEffect(() => { /** We only want to display the skeleton for the status filters the first time we load them for a specific data type */ setShouldShowFiltersBarLoading(shouldShowLoadingState && lastSearchType !== type); @@ -1015,13 +864,6 @@ function Search({ ], ); - const currentColumns = useMemo(() => { - if (!searchResults?.data) { - return []; - } - return getColumnsToShow(accountID, searchResults?.data, visibleColumns, false, searchDataType, validGroupBy); - }, [accountID, searchResults?.data, searchDataType, visibleColumns, validGroupBy]); - const opacity = useSharedValue(1); const animatedStyle = useAnimatedStyle(() => ({ opacity: opacity.get(), @@ -1033,7 +875,7 @@ function Search({ // If columns have changed, trigger an animation before settings columnsToShow to prevent // new columns appearing before the fade out animation happens useEffect(() => { - if ((previousColumns && currentColumns && arraysEqual(previousColumns, currentColumns)) || offset === 0 || isSmallScreenWidth) { + if (offset === 0 || isSmallScreenWidth || (previousColumns && currentColumns && arraysEqual(previousColumns, currentColumns))) { setColumnsToShow(currentColumns); return; } @@ -1280,14 +1122,7 @@ function Search({ if (shouldShowChartView && isGroupedItemArray(sortedData)) { cancelSpan(CONST.TELEMETRY.SPAN_NAVIGATE_AFTER_EXPENSE_CREATE); - let chartTitle = translate(`search.chartTitles.${validGroupBy}`); - if (savedSearch) { - if (savedSearch.name !== savedSearch.query) { - chartTitle = savedSearch.name; - } - } else if (searchKey && suggestedSearches[searchKey]) { - chartTitle = translate(suggestedSearches[searchKey].translationPath); - } + const chartTitle = savedSearchName ?? translate(`search.chartTitles.${validGroupBy}`); return ( diff --git a/src/components/SelectionListWithSections/Search/ExpenseReportListItemRow.tsx b/src/components/SelectionListWithSections/Search/ExpenseReportListItemRow.tsx index 9a3ce979e62c3..be5d2c4c34cfd 100644 --- a/src/components/SelectionListWithSections/Search/ExpenseReportListItemRow.tsx +++ b/src/components/SelectionListWithSections/Search/ExpenseReportListItemRow.tsx @@ -14,6 +14,7 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import getBase62ReportID from '@libs/getBase62ReportID'; import {getMoneyRequestSpendBreakdown} from '@libs/ReportUtils'; +import {resolveExportedIconNames} from '@libs/SearchFormatUtils'; import {isScanning as isTransactionScanning} from '@libs/TransactionUtils'; import variables from '@styles/variables'; import CONST from '@src/CONST'; @@ -197,7 +198,7 @@ function ExpenseReportListItemRow({ ), [CONST.SEARCH.TABLE_COLUMNS.EXPORTED_TO]: ( - + ), [CONST.SEARCH.TABLE_COLUMNS.ACTION]: ( diff --git a/src/components/SelectionListWithSections/Search/ExportedIconCell.tsx b/src/components/SelectionListWithSections/Search/ExportedIconCell.tsx index 559ec617a5c20..7fb84387c1c44 100644 --- a/src/components/SelectionListWithSections/Search/ExportedIconCell.tsx +++ b/src/components/SelectionListWithSections/Search/ExportedIconCell.tsx @@ -5,104 +5,71 @@ import Icon from '@components/Icon'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import {getOriginalMessage, isExportedToIntegrationAction} from '@libs/ReportActionsUtils'; import CONST from '@src/CONST'; -import type {ReportAction} from '@src/types/onyx'; type ExportedIconCellProps = { - reportActions?: ReportAction[]; + /** Pre-resolved list of integration icon names to display, from resolveExportedIconNames() */ + exportedIconNames: string[]; }; -function ExportedIconCell({reportActions}: ExportedIconCellProps) { +function ExportedIconCell({exportedIconNames}: ExportedIconCellProps) { const theme = useTheme(); const styles = useThemeStyles(); - const actions = reportActions ?? []; const icons = useMemoizedLazyExpensifyIcons(['NetSuiteSquare', 'XeroSquare', 'IntacctSquare', 'QBOSquare', 'Table', 'ZenefitsSquare', 'BillComSquare', 'CertiniaSquare']); - let isExportedToCsv = false; - let isExportedToNetsuite = false; - let isExportedToXero = false; - let isExportedToIntacct = false; - let isExportedToQuickbooksOnline = false; - let isExportedToQuickbooksDesktop = false; - let isExportedToCertinia = false; - let isExportedToBillCom = false; - let isExportedToZenefits = false; - - for (const action of actions) { - if (action.actionName === CONST.REPORT.ACTIONS.TYPE.EXPORTED_TO_CSV) { - isExportedToCsv = true; - } - - if (isExportedToIntegrationAction(action)) { - const message = getOriginalMessage(action); - const label = message?.label; - const type = message?.type; - isExportedToCsv = isExportedToCsv || type === CONST.EXPORT_TEMPLATE; - isExportedToXero = isExportedToXero || label === CONST.EXPORT_LABELS.XERO; - isExportedToNetsuite = isExportedToNetsuite || label === CONST.EXPORT_LABELS.NETSUITE; - isExportedToQuickbooksOnline = isExportedToQuickbooksOnline || label === CONST.EXPORT_LABELS.QBO; - isExportedToQuickbooksDesktop = isExportedToQuickbooksDesktop || label === CONST.EXPORT_LABELS.QBD; - isExportedToZenefits = isExportedToZenefits || label === CONST.EXPORT_LABELS.ZENEFITS; - isExportedToBillCom = isExportedToBillCom || label === CONST.EXPORT_LABELS.BILLCOM; - isExportedToCertinia = isExportedToCertinia || label === CONST.EXPORT_LABELS.CERTINIA; - isExportedToIntacct = isExportedToIntacct || label === CONST.EXPORT_LABELS.INTACCT || label === CONST.EXPORT_LABELS.SAGE_INTACCT; - } - } - return ( - {isExportedToCsv && ( + {exportedIconNames.includes('Table') && ( )} - {isExportedToNetsuite && ( + {exportedIconNames.includes('NetSuiteSquare') && ( )} - {isExportedToXero && ( + {exportedIconNames.includes('XeroSquare') && ( )} - {isExportedToIntacct && ( + {exportedIconNames.includes('IntacctSquare') && ( )} - {(isExportedToQuickbooksOnline || isExportedToQuickbooksDesktop) && ( + {exportedIconNames.includes('QBOSquare') && ( )} - {isExportedToCertinia && ( + {exportedIconNames.includes('CertiniaSquare') && ( )} - {isExportedToBillCom && ( + {exportedIconNames.includes('BillComSquare') && ( )} - {isExportedToZenefits && ( + {exportedIconNames.includes('ZenefitsSquare') && ( - + ); default: diff --git a/src/hooks/useSearchData.ts b/src/hooks/useSearchData.ts new file mode 100644 index 0000000000000..430b19969c225 --- /dev/null +++ b/src/hooks/useSearchData.ts @@ -0,0 +1,327 @@ +import {useCallback, useMemo} from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import type {SearchQueryJSON} from '@components/Search/types'; +import type {TransactionGroupListItemType, TransactionListItemType} from '@components/SelectionListWithSections/types'; +import {selectFilteredReportActions} from '@libs/ReportUtils'; +import {getColumnsToShow, getSections, getSuggestedSearches} from '@libs/SearchUIUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import {columnsSelector} from '@src/selectors/AdvancedSearchFiltersForm'; +import type {SaveSearch} from '@src/types/onyx'; +import type SearchResults from '@src/types/onyx/SearchResults'; +import type {SearchDataTypes} from '@src/types/onyx/SearchResults'; +import useActionLoadingReportIDs from './useActionLoadingReportIDs'; +import useArchivedReportsIdSet from './useArchivedReportsIdSet'; +import useCardFeedsForDisplay from './useCardFeedsForDisplay'; +import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; +import useLocalize from './useLocalize'; +import useMultipleSnapshots from './useMultipleSnapshots'; +import useNetwork from './useNetwork'; +import useOnyx from './useOnyx'; + +type UseSearchDataParams = { + queryJSON: SearchQueryJSON; + searchResults: SearchResults | undefined; + isDataLoaded: boolean; + shouldUseLiveData?: boolean; + searchRequestResponseStatusCode?: number | null; +}; + +type UseSearchDataResult = { + /** Formatted section data ready for the list */ + sections: ReturnType[0]; + /** Total count of all items across all pages */ + allDataLength: number; + /** Count of items in the current page */ + filteredDataLength: number; + /** Visible columns computed from user preferences and data */ + columns: ReturnType; + /** Card feed names keyed by fundID */ + customCardNames: Record | undefined; + /** Transaction violations collection */ + violations: ReturnType>[0]; + /** Report actions filtered for export icons */ + exportReportActions: ReturnType>[0]; + /** Card feeds collection */ + cardFeeds: ReturnType>[0]; + /** Whether card feeds are still loading (relevant for GROUP_BY.CARD) */ + cardFeedsLoading: boolean; + /** The key of the current saved/suggested search, used for search requests */ + searchKey: string | undefined; + /** Name of the current saved search for chart title */ + savedSearchName: string | undefined; + /** All transactions, needed by useSearchHighlightAndScroll */ + transactions: ReturnType>[0]; + /** All report actions, needed by useSearchHighlightAndScroll */ + reportActions: ReturnType>[0]; + /** Outstanding reports by policy, needed for selection state computation */ + outstandingReportsByPolicyID: ReturnType>[0]; + /** Whether the user has dismissed the intro (affects transaction thread creation) */ + introSelected: ReturnType>[0]; + /** Suggested search type menu items */ + suggestedSearches: ReturnType; + /** Current user's account ID */ + accountID: number; + /** Current user's login email */ + login: string | undefined; + /** Bank account list, needed for group-by enrichment */ + bankAccountList: ReturnType>[0]; + /** Action loading report IDs set, needed for group-by enrichment */ + isActionLoadingSet: ReadonlySet; + /** All report metadata, needed for group-by enrichment */ + allReportMetadata: ReturnType>[0]; + /** Card list, needed for group-by enrichment */ + cardList: ReturnType>[0]; + /** The resolved search data type (expense_report when using live data) */ + searchDataType: SearchDataTypes | undefined; + /** Whether the search response has errors (and we're not offline) */ + hasErrors: boolean; + /** Whether to show the full-page loading state */ + shouldShowLoadingState: boolean; + /** Whether to show "loading more" at the bottom (pagination) */ + shouldShowLoadingMoreItems: boolean; + /** For group-by views: true when all group snapshots are loaded and have no more results */ + hasLoadedAllTransactions: boolean; +}; + +/** + * Consolidates all Onyx subscriptions needed by the Search component and + * computes section data + column visibility in one place. + * + * The component can then focus purely on selection state, bulk actions, + * scroll handling, and navigation. + */ +function useSearchData({ + queryJSON, + searchResults, + isDataLoaded, + shouldUseLiveData, + searchRequestResponseStatusCode, +}: UseSearchDataParams): UseSearchDataResult { + const {type, hash, recentSearchHash, groupBy} = queryJSON; + const validGroupBy = groupBy && Object.values(CONST.SEARCH.GROUP_BY).includes(groupBy) ? groupBy : undefined; + + const {isOffline} = useNetwork(); + const {translate, formatPhoneNumber} = useLocalize(); + const {accountID, email, login} = useCurrentUserPersonalDetails(); + const isActionLoadingSet = useActionLoadingReportIDs(); + const archivedReportsIdSet = useArchivedReportsIdSet(); + const {defaultCardFeed} = useCardFeedsForDisplay(); + + // --- Onyx subscriptions --- + const [transactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION); + const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); + const [reportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS); + const [outstandingReportsByPolicyID] = useOnyx(ONYXKEYS.DERIVED.OUTSTANDING_REPORTS_BY_POLICY_ID); + const [violations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); + const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); + const [allReportMetadata] = useOnyx(ONYXKEYS.COLLECTION.REPORT_METADATA); + const [visibleColumns] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM, {selector: columnsSelector}); + const [customCardNames] = useOnyx(ONYXKEYS.NVP_EXPENSIFY_COMPANY_CARDS_CUSTOM_NAMES); + const [cardList] = useOnyx(ONYXKEYS.CARD_LIST); + const [exportReportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS, { + canEvict: false, + selector: selectFilteredReportActions, + }); + const [cardFeeds, cardFeedsResult] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER); + const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); + + const savedSearchSelector = useCallback((searches: OnyxEntry) => searches?.[hash], [hash]); + const [savedSearch] = useOnyx(ONYXKEYS.SAVED_SEARCHES, {selector: savedSearchSelector}); + + // --- Derived values --- + const suggestedSearches = useMemo(() => getSuggestedSearches(accountID, defaultCardFeed?.id), [defaultCardFeed?.id, accountID]); + const searchKey = useMemo(() => Object.values(suggestedSearches).find((search) => search.recentSearchHash === recentSearchHash)?.key, [suggestedSearches, recentSearchHash]); + + let savedSearchName: string | undefined; + if (savedSearch?.name && savedSearch.name !== savedSearch.query) { + savedSearchName = savedSearch.name; + } else if (searchKey && suggestedSearches[searchKey]) { + savedSearchName = translate(suggestedSearches[searchKey].translationPath); + } + + const cardFeedsLoading = validGroupBy === CONST.SEARCH.GROUP_BY.CARD && cardFeedsResult?.status === 'loading'; + + const searchDataType: SearchDataTypes | undefined = shouldUseLiveData ? CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT : searchResults?.search?.type; + + const hasErrors = Object.keys(searchResults?.errors ?? {}).length > 0 && !isOffline; + + const shouldShowLoadingState = + !shouldUseLiveData && + !isOffline && + (!isDataLoaded || + (!!searchResults?.search?.isLoading && Array.isArray(searchResults?.data) && searchResults?.data.length === 0) || + (hasErrors && searchRequestResponseStatusCode === null) || + cardFeedsLoading); + + const shouldShowLoadingMoreItems = + !shouldShowLoadingState && !!searchResults?.search?.isLoading && (searchResults?.search?.offset ?? 0) > 0; + + // --- Section building --- + const [sections, allDataLength] = useMemo(() => { + if (searchResults === undefined || !isDataLoaded) { + return [[], 0] as [[], 0]; + } + + const isChat = type === CONST.SEARCH.DATA_TYPES.CHAT; + const isTask = type === CONST.SEARCH.DATA_TYPES.TASK; + if (validGroupBy && (isChat || isTask)) { + return [[], 0] as [[], 0]; + } + + return getSections({ + type, + data: searchResults.data, + policies, + currentAccountID: accountID, + currentUserEmail: email ?? '', + translate, + formatPhoneNumber, + bankAccountList, + groupBy: validGroupBy, + reportActions: exportReportActions, + currentSearch: searchKey, + archivedReportsIDList: archivedReportsIdSet, + queryJSON, + isActionLoadingSet, + cardFeeds, + isOffline, + allTransactionViolations: violations, + customCardNames, + allReportMetadata, + cardList, + }); + }, [ + searchKey, + isOffline, + exportReportActions, + validGroupBy, + isDataLoaded, + searchResults, + type, + archivedReportsIdSet, + translate, + formatPhoneNumber, + accountID, + queryJSON, + email, + isActionLoadingSet, + cardFeeds, + policies, + bankAccountList, + violations, + customCardNames, + allReportMetadata, + cardList, + ]); + + const filteredDataLength = sections.length; + + const isExpenseReportType = type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT; + + const groupByTransactionHashes = useMemo(() => { + if (!validGroupBy) { + return []; + } + return (sections as TransactionGroupListItemType[]) + .map((item) => (item.transactionsQueryJSON?.hash != null ? String(item.transactionsQueryJSON.hash) : undefined)) + .filter((hashValue): hashValue is string => !!hashValue); + }, [validGroupBy, sections]); + + const groupByTransactionSnapshots = useMultipleSnapshots(groupByTransactionHashes); + + const sectionsToReturn = useMemo(() => { + if (!validGroupBy || isExpenseReportType) { + return sections; + } + return (sections as TransactionGroupListItemType[]).map((item) => { + const snapshot = + item.transactionsQueryJSON?.hash != null ? groupByTransactionSnapshots[String(item.transactionsQueryJSON.hash)] : undefined; + if (!snapshot?.data) { + return item; + } + const [transactions1] = getSections({ + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + data: snapshot.data, + currentAccountID: accountID, + currentUserEmail: email ?? '', + bankAccountList, + translate, + formatPhoneNumber, + isActionLoadingSet, + cardFeeds, + allReportMetadata, + cardList, + }); + return {...item, transactions: transactions1 as TransactionListItemType[]}; + }); + }, [ + validGroupBy, + isExpenseReportType, + sections, + groupByTransactionSnapshots, + accountID, + email, + bankAccountList, + translate, + formatPhoneNumber, + isActionLoadingSet, + cardFeeds, + allReportMetadata, + cardList, + ]); + + const hasLoadedAllTransactions = useMemo(() => { + if (!validGroupBy) { + return true; + } + return (sections as TransactionGroupListItemType[]).every((item) => { + const snapshot = + item.transactionsQueryJSON?.hash != null || item.transactionsQueryJSON?.hash === 0 + ? groupByTransactionSnapshots[String(item.transactionsQueryJSON.hash)] + : undefined; + return !!snapshot && !snapshot?.search?.hasMoreResults; + }); + }, [validGroupBy, sections, groupByTransactionSnapshots]); + + // --- Column visibility --- + const searchResultsData = searchResults?.data; + const columns = useMemo(() => { + if (!searchResultsData) { + return []; + } + return getColumnsToShow(accountID, searchResultsData, visibleColumns, false, searchDataType, validGroupBy); + }, [accountID, searchResultsData, searchDataType, visibleColumns, validGroupBy]); + + return { + sections: sectionsToReturn, + allDataLength, + filteredDataLength, + columns, + customCardNames, + violations, + exportReportActions, + cardFeeds, + cardFeedsLoading, + searchKey, + savedSearchName, + transactions, + reportActions, + outstandingReportsByPolicyID, + introSelected, + suggestedSearches, + accountID, + login, + bankAccountList, + isActionLoadingSet, + allReportMetadata, + cardList, + searchDataType, + hasErrors, + shouldShowLoadingState, + shouldShowLoadingMoreItems, + hasLoadedAllTransactions, + }; +} + +export default useSearchData; diff --git a/src/libs/SearchFormatUtils.ts b/src/libs/SearchFormatUtils.ts new file mode 100644 index 0000000000000..9890c04fade1c --- /dev/null +++ b/src/libs/SearchFormatUtils.ts @@ -0,0 +1,143 @@ +import type {LocalizedTranslate} from '@components/LocaleContextProvider'; +import type {ThemeColors} from '@styles/theme/types'; +import CONST from '@src/CONST'; +import type {Report, ReportAction} from '@src/types/onyx'; +import {convertToDisplayString} from './CurrencyUtils'; +import DateUtils from './DateUtils'; +import {getOriginalMessage, isExportedToIntegrationAction} from './ReportActionsUtils'; +import {getPolicyName, getReportStatusColorStyle, getReportStatusTranslation, getWorkspaceIcon} from './ReportUtils'; + +type FormattedReportStatus = { + text: string; + backgroundColor: string; + textColor: string; +} | null; + +type FormattedWorkspace = { + name: string; + icon: ReturnType; +} | null; + +type ExpensifyIconName = 'NetSuiteSquare' | 'XeroSquare' | 'IntacctSquare' | 'QBOSquare' | 'Table' | 'ZenefitsSquare' | 'BillComSquare' | 'CertiniaSquare'; + +/** + * Formats a date string for display in a search result row. + * Automatically switches between short (MMM D) and long (MMM D, YYYY) format based on whether the date is from a past year. + */ +function formatSearchDate(date: string): string { + return DateUtils.formatWithUTCTimeZone(date, DateUtils.doesDateBelongToAPastYear(date) ? CONST.DATE.MONTH_DAY_YEAR_ABBR_FORMAT : CONST.DATE.MONTH_DAY_ABBR_FORMAT); +} + +/** + * Formats a numeric amount + currency code into a display string, e.g. "$42.00". + * Returns "Receipt Scanning" translation when the expense is still being scanned. + */ +function formatSearchTotal(total: number, currency: string, isScanning: boolean, translate: LocalizedTranslate): string { + if (isScanning) { + return translate('iou.receiptStatusTitle'); + } + return convertToDisplayString(total, currency); +} + +/** + * Resolves the display text and color styles for a report status badge. + * Returns null when there is no valid status to show. + */ +function formatReportStatus(theme: ThemeColors, translate: LocalizedTranslate, stateNum?: number, statusNum?: number): FormattedReportStatus { + const statusText = getReportStatusTranslation({stateNum, statusNum, translate}); + const colorStyle = getReportStatusColorStyle(theme, stateNum, statusNum); + + if (!statusText || !colorStyle) { + return null; + } + + return { + text: statusText, + backgroundColor: colorStyle.backgroundColor, + textColor: colorStyle.textColor, + }; +} + +/** + * Resolves the workspace avatar icon and policy name for a report. + * Returns null when the report is not an expense/invoice report or when icon/name are missing. + */ +function formatWorkspace(report?: Report): FormattedWorkspace { + if (report?.type !== CONST.REPORT.TYPE.EXPENSE && report?.type !== CONST.REPORT.TYPE.INVOICE) { + return null; + } + const icon = getWorkspaceIcon(report); + const name = getPolicyName({report}); + + if (!icon || !name) { + return null; + } + + return {name, icon}; +} + +/** + * Resolves which integration export icon names should be shown for a given set of report actions. + * Returns an array of ExpensifyIconName strings — e.g. ['NetSuiteSquare', 'XeroSquare']. + * The caller is responsible for loading the actual icon assets via useMemoizedLazyExpensifyIcons. + */ +function resolveExportedIconNames(reportActions: ReportAction[]): ExpensifyIconName[] { + let isExportedToCsv = false; + let isExportedToNetsuite = false; + let isExportedToXero = false; + let isExportedToIntacct = false; + let isExportedToQuickbooks = false; + let isExportedToCertinia = false; + let isExportedToBillCom = false; + let isExportedToZenefits = false; + + for (const action of reportActions) { + if (action.actionName === CONST.REPORT.ACTIONS.TYPE.EXPORTED_TO_CSV) { + isExportedToCsv = true; + } + + if (isExportedToIntegrationAction(action)) { + const message = getOriginalMessage(action); + const label = message?.label; + const type = message?.type; + isExportedToCsv = isExportedToCsv || type === CONST.EXPORT_TEMPLATE; + isExportedToXero = isExportedToXero || label === CONST.EXPORT_LABELS.XERO; + isExportedToNetsuite = isExportedToNetsuite || label === CONST.EXPORT_LABELS.NETSUITE; + isExportedToQuickbooks = isExportedToQuickbooks || label === CONST.EXPORT_LABELS.QBO || label === CONST.EXPORT_LABELS.QBD; + isExportedToZenefits = isExportedToZenefits || label === CONST.EXPORT_LABELS.ZENEFITS; + isExportedToBillCom = isExportedToBillCom || label === CONST.EXPORT_LABELS.BILLCOM; + isExportedToCertinia = isExportedToCertinia || label === CONST.EXPORT_LABELS.CERTINIA; + isExportedToIntacct = isExportedToIntacct || label === CONST.EXPORT_LABELS.INTACCT || label === CONST.EXPORT_LABELS.SAGE_INTACCT; + } + } + + const iconNames: ExpensifyIconName[] = []; + if (isExportedToCsv) { + iconNames.push('Table'); + } + if (isExportedToNetsuite) { + iconNames.push('NetSuiteSquare'); + } + if (isExportedToXero) { + iconNames.push('XeroSquare'); + } + if (isExportedToIntacct) { + iconNames.push('IntacctSquare'); + } + if (isExportedToQuickbooks) { + iconNames.push('QBOSquare'); + } + if (isExportedToCertinia) { + iconNames.push('CertiniaSquare'); + } + if (isExportedToBillCom) { + iconNames.push('BillComSquare'); + } + if (isExportedToZenefits) { + iconNames.push('ZenefitsSquare'); + } + return iconNames; +} + +export type {FormattedReportStatus, FormattedWorkspace, ExpensifyIconName}; +export {formatSearchDate, formatSearchTotal, formatReportStatus, formatWorkspace, resolveExportedIconNames};