diff --git a/src/components/Search/SearchBulkActionsButton.tsx b/src/components/Search/SearchBulkActionsButton.tsx index a7eb749e88ef..5bb7a3277037 100644 --- a/src/components/Search/SearchBulkActionsButton.tsx +++ b/src/components/Search/SearchBulkActionsButton.tsx @@ -106,8 +106,14 @@ function SearchBulkActionsButton({queryJSON}: SearchBulkActionsButtonProps) { return reportIDs.size; } - return selectedTransactionsKeys.length; - }, [selectedTransactions, selectedTransactionsKeys.length, isExpenseReportType]); + return selectedTransactionsKeys.reduce((count, key) => { + if (key.startsWith(CONST.SEARCH.GROUP_PREFIX)) { + const group = searchData?.[key as keyof typeof searchData] as {count?: number} | undefined; + return count + (group?.count ?? 0); + } + return count + 1; + }, 0); + }, [selectedTransactions, selectedTransactionsKeys, isExpenseReportType, searchData]); const selectionButtonText = areAllMatchingItemsSelected ? translate('search.exportAll.allMatchingItemsSelected') : translate('workspace.common.selected', {count: selectedItemsCount}); diff --git a/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx b/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx index 72d8fd1657da..f8fdcde1b50d 100644 --- a/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx +++ b/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx @@ -164,9 +164,7 @@ function TransactionGroupListItem({ const isSelectAllChecked = isEmptyReportSelected || (selectedItemsLength === transactionsWithoutPendingDelete.length && transactionsWithoutPendingDelete.length > 0); const isIndeterminate = selectedItemsLength > 0 && selectedItemsLength !== transactionsWithoutPendingDelete.length; - // Currently only the transaction report groups have transactions where the empty view makes sense const shouldDisplayEmptyView = isEmpty && isExpenseReportType; - const isDisabledOrEmpty = isEmpty || isDisabled; const refreshTransactions = () => { if (!groupItem.transactionsQueryJSON) { @@ -278,7 +276,7 @@ function TransactionGroupListItem({ }; const onPress = (event?: ModifiedMouseEvent) => { - if (isExpenseReportType || transactions.length === 0) { + if (isExpenseReportType) { onSelectRow(item, transactionPreviewData, event); } if (!isExpenseReportType) { @@ -313,7 +311,7 @@ function TransactionGroupListItem({ ({ ({ ({ ({ ({ ({ ({ ({ ({ { - if (type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT && isTransactionGroupListItemArray(data)) { - return data.filter((item) => item.transactions.length === 0); + if (isTransactionGroupListItemArray(data)) { + return data.filter((item) => item.transactions.length === 0 && item.keyForList); } return []; - }, [data, type]); + }, [data]); const selectedItemsLength = useMemo(() => { const selectedTransactionsCount = flattenedItems.reduce((acc, item) => { @@ -244,7 +244,7 @@ function SearchList({ return acc + (isTransactionSelected ? 1 : 0); }, 0); - if (type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT && isTransactionGroupListItemArray(data)) { + if (isTransactionGroupListItemArray(data)) { const selectedEmptyReports = emptyReports.reduce((acc, item) => { const isEmptyReportSelected = !!(item.keyForList && selectedTransactions[item.keyForList]?.isSelected); return acc + (isEmptyReportSelected ? 1 : 0); @@ -254,10 +254,10 @@ function SearchList({ } return selectedTransactionsCount; - }, [flattenedItems, type, data, emptyReports, selectedTransactions]); + }, [flattenedItems, data, emptyReports, selectedTransactions]); const totalItems = useMemo(() => { - if (type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT && isTransactionGroupListItemArray(data)) { + 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) { @@ -275,7 +275,7 @@ function SearchList({ return true; }); return selectableTransactions.length; - }, [data, type, flattenedItems, emptyReports]); + }, [data, flattenedItems, emptyReports]); const {translate} = useLocalize(); const {isOffline} = useNetwork(); diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 7b98b998000a..483a43c9177d 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -173,7 +173,33 @@ function mapTransactionItemToSelectedEntry( ]; } -function mapEmptyReportToSelectedEntry(item: TransactionReportGroupListItemType): [string, SelectedTransactionInfo] { +function mapEmptyReportToSelectedEntry(item: TransactionReportGroupListItemType | TransactionGroupListItemType): [string, SelectedTransactionInfo] { + if (isTransactionReportGroupListItemType(item)) { + const currency = item.currency ?? ''; + return [ + item.keyForList ?? '', + { + isFromOneTransactionReport: false, + isSelected: true, + canHold: false, + canSplit: false, + canReject: false, + hasBeenSplit: false, + isHeld: false, + canUnhold: false, + canChangeReport: false, + action: item.action ?? CONST.SEARCH.ACTION_TYPES.VIEW, + reportID: item.reportID, + policyID: item.policyID ?? CONST.POLICY.ID_FAKE, + amount: item.totalDisplaySpend ?? item.total ?? 0, + currency, + ...(currency ? {groupCurrency: currency} : {}), + }, + ]; + } + + const currency = item.currency ?? ''; + return [ item.keyForList ?? '', { @@ -186,11 +212,12 @@ function mapEmptyReportToSelectedEntry(item: TransactionReportGroupListItemType) isHeld: false, canUnhold: false, canChangeReport: false, - action: item.action ?? CONST.SEARCH.ACTION_TYPES.VIEW, + action: CONST.SEARCH.ACTION_TYPES.VIEW, reportID: item.reportID, policyID: item.policyID ?? CONST.POLICY.ID_FAKE, - amount: 0, - currency: '', + amount: item.total ?? 0, + currency, + ...(currency ? {groupCurrency: currency} : {}), }, ]; } @@ -654,9 +681,8 @@ function Search({ // 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; + return item.transactions.length === 0 || !snapshot || !snapshot?.search?.hasMoreResults; }); }, [validGroupBy, baseFilteredData, groupByTransactionSnapshots]); @@ -754,7 +780,7 @@ function Search({ continue; } - if (transactionGroup.transactions.length === 0 && isTransactionReportGroupListItemType(transactionGroup)) { + if (transactionGroup.transactions.length === 0) { const reportKey = transactionGroup.keyForList; if (transactionGroup.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { continue; @@ -773,15 +799,18 @@ function Search({ // This ensures report-level selection persists when new transactions are added. // Also check if the report itself was selected (when it was empty) by checking the reportID key const reportKey = transactionGroup.keyForList; - const wasReportSelected = reportKey && reportKey in selectedTransactions; - - const hasAnySelected = isExpenseReportType && (wasReportSelected || transactionGroup.transactions.some((transaction) => transaction.transactionID in selectedTransactions)); + const wasReportSelected = !!(reportKey && reportKey in selectedTransactions); + const hasIndividualSelectedInGroup = transactionGroup.transactions.some( + (transaction) => (!!transaction.keyForList && transaction.keyForList in selectedTransactions) || transaction.transactionID in selectedTransactions, + ); + const propagateSelectionToAllRows = (isExpenseReportType && (wasReportSelected || hasIndividualSelectedInGroup)) || (wasReportSelected && !isExpenseReportType); for (const transactionItem of transactionGroup.transactions) { - const isSelected = transactionItem.transactionID in selectedTransactions; + const listKey = transactionItem.keyForList ?? transactionItem.transactionID; + const isSelected = listKey in selectedTransactions || transactionItem.transactionID in selectedTransactions; - // Include transaction if: already individually selected, part of select-all, or (for expense reports) part of a partially-selected report - const shouldInclude = isSelected || areAllMatchingItemsSelected || (isExpenseReportType && hasAnySelected); + // Include transaction if: already individually selected, part of select-all, or group-level propagation (expense report / empty group expanded) + const shouldInclude = isSelected || areAllMatchingItemsSelected || propagateSelectionToAllRows; if (!shouldInclude) { continue; } @@ -805,7 +834,9 @@ function Search({ const isItemUnreported = transactionItem.reportID === CONST.REPORT.UNREPORTED_REPORT_ID; const reportForSplit = transactionItem.report ?? (isItemUnreported ? selfDMReport : undefined); - newTransactionList[transactionItem.transactionID] = { + const previousSelection = selectedTransactions[listKey] ?? selectedTransactions[transactionItem.transactionID]; + + newTransactionList[listKey] = { transaction: transactionItem, action: transactionItem.action, canHold: canHoldRequest, @@ -822,7 +853,7 @@ function Search({ policy: transactionItem.policy, }), - isSelected: areAllMatchingItemsSelected || selectedTransactions[transactionItem.transactionID]?.isSelected || isExpenseReportType, + isSelected: areAllMatchingItemsSelected || !!previousSelection?.isSelected || propagateSelectionToAllRows, canReject: canRejectRequest, reportID: transactionItem.reportID, policyID: transactionItem.report?.policyID, @@ -836,6 +867,7 @@ function Search({ reportAction: transactionItem.reportAction, isFromOneTransactionReport: isOneTransactionReport(transactionItem.report), report: transactionItem.report, + groupKey: previousSelection?.groupKey ?? (propagateSelectionToAllRows && !isExpenseReportType ? reportKey : undefined), }; } } @@ -844,7 +876,8 @@ function Search({ if (!Object.hasOwn(transactionItem, 'transactionID') || !('transactionID' in transactionItem)) { continue; } - if (!(transactionItem.transactionID in selectedTransactions) && !areAllMatchingItemsSelected) { + const listKey = transactionItem.keyForList ?? transactionItem.transactionID; + if (!(listKey in selectedTransactions) && !(transactionItem.transactionID in selectedTransactions) && !areAllMatchingItemsSelected) { continue; } @@ -864,7 +897,9 @@ function Search({ const isItemUnreported = transactionItem.reportID === CONST.REPORT.UNREPORTED_REPORT_ID; const reportForSplit = transactionItem.report ?? (isItemUnreported ? selfDMReport : undefined); - newTransactionList[transactionItem.transactionID] = { + const flatPreviousSelection = selectedTransactions[listKey] ?? selectedTransactions[transactionItem.transactionID]; + + newTransactionList[listKey] = { transaction: transactionItem, action: transactionItem.action, canHold: canHoldRequest, @@ -881,7 +916,7 @@ function Search({ policy: transactionItem.policy, }), - isSelected: areAllMatchingItemsSelected || selectedTransactions[transactionItem.transactionID].isSelected, + isSelected: areAllMatchingItemsSelected || !!flatPreviousSelection?.isSelected, canReject: canRejectRequest, reportID: transactionItem.reportID, policyID: transactionItem.report?.policyID, @@ -977,15 +1012,15 @@ function Search({ } return (filteredData as TransactionGroupListItemType[]).reduce((count, item) => { - // For empty reports, count the report itself as a selectable item - if (item.transactions.length === 0 && isTransactionReportGroupListItemType(item)) { + // For empty groups, count the group itself as a selectable item + if (item.transactions.length === 0 && item.keyForList) { if (item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { return count; } return count + 1; } - // For regular reports, count all transactions except pending delete ones + // For groups with transactions, count all transactions except pending delete ones const selectableTransactions = item.transactions.filter((transaction) => !isTransactionPendingDelete(transaction)); return count + selectableTransactions.length; @@ -1036,6 +1071,17 @@ function Search({ selfDMReport, isProduction, ); + + // Tag individual transactions with their parent group key so export filtering can derive the group when needed. + if (areItemsGrouped) { + const parentGroup = (filteredData as TransactionGroupListItemType[]).find((group) => + group.transactions.some((transaction) => transaction.keyForList === item.keyForList), + ); + if (parentGroup?.keyForList && updatedTransactions[item.keyForList]) { + updatedTransactions[item.keyForList] = {...updatedTransactions[item.keyForList], groupKey: parentGroup.keyForList}; + } + } + setSelectedTransactions(updatedTransactions); updateSelectAllMatchingItemsState(updatedTransactions); return; @@ -1043,19 +1089,14 @@ function Search({ const currentTransactions = itemTransactions ?? item.transactions; - // Handle empty reports - treat the report itself as selectable - if (currentTransactions.length === 0 && isTransactionReportGroupListItemType(item)) { + if (currentTransactions.length === 0 && item.keyForList) { const reportKey = item.keyForList; - if (!reportKey) { - return; - } if (item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { return; } if (selectedTransactions[reportKey]?.isSelected) { - // Deselect the empty report const reducedSelectedTransactions: SelectedTransactions = { ...selectedTransactions, }; @@ -1101,7 +1142,7 @@ function Search({ searchResults?.data?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${itemTransaction?.comment?.originalTransactionID}`] ?? transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${itemTransaction?.comment?.originalTransactionID}`]; const itemParentReport = searchResults?.data?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionItem.report?.parentReportID}`] as OnyxEntry; - return mapTransactionItemToSelectedEntry( + const [key, entry] = mapTransactionItemToSelectedEntry( transactionItem, itemTransaction, originalItemTransaction, @@ -1113,6 +1154,7 @@ function Search({ selfDMReport, isProduction, ); + return [key, {...entry, groupKey: item.keyForList}]; }), ), }; @@ -1130,6 +1172,8 @@ function Search({ outstandingReportsByPolicyID, selfDMReport, isProduction, + areItemsGrouped, + filteredData, ], ); @@ -1422,7 +1466,7 @@ function Search({ let updatedTransactions: SelectedTransactions; if (areItemsGrouped) { const allSelections: Array<[string, SelectedTransactionInfo]> = (filteredData as TransactionGroupListItemType[]).flatMap((item) => { - if (item.transactions.length === 0 && isTransactionReportGroupListItemType(item) && item.keyForList) { + if (item.transactions.length === 0 && item.keyForList) { if (item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { return []; } @@ -1434,7 +1478,7 @@ function Search({ const itemTransaction = transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionItem.transactionID}`] as OnyxEntry; const originalItemTransaction = transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${itemTransaction?.comment?.originalTransactionID}`]; const itemParentReport = searchResults?.data?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionItem.report?.parentReportID}`] as OnyxEntry; - return mapTransactionItemToSelectedEntry( + const [key, entry] = mapTransactionItemToSelectedEntry( transactionItem, itemTransaction, originalItemTransaction, @@ -1446,6 +1490,7 @@ function Search({ selfDMReport, isProduction, ); + return [key, {...entry, groupKey: item.keyForList}] as [string, SelectedTransactionInfo]; }); }); updatedTransactions = Object.fromEntries(allSelections); diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts index 408484e57015..5c3554f73da3 100644 --- a/src/components/Search/types.ts +++ b/src/components/Search/types.ts @@ -88,6 +88,9 @@ type SelectedTransactionInfo = { reportAction?: ReportAction; report?: Report; + + /** The group key this transaction belongs to when in a grouped view */ + groupKey?: string; }; /** Model of selected transactions */ diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index cee3278748e2..7d4a249c7853 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -10,7 +10,7 @@ import type {PaymentMethodType} from '@components/KYCWall/types'; import {ModalActions} from '@components/Modal/Global/ModalContext'; import type {PopoverMenuItem} from '@components/PopoverMenu'; import {useSearchQueryContext, useSearchResultsContext, useSearchSelectionActions, useSearchSelectionContext} from '@components/Search/SearchContext'; -import type {BulkPaySelectionData, PaymentData, SearchColumnType, SearchQueryJSON} from '@components/Search/types'; +import type {BulkPaySelectionData, PaymentData, SearchColumnType, SearchFilterKey, SearchQueryJSON, SelectedTransactions} from '@components/Search/types'; import {unholdRequest} from '@libs/actions/IOU/Hold'; import {setupMergeTransactionDataAndNavigate} from '@libs/actions/MergeTransaction'; import {deleteAppReport, exportReportToPDF, markAsManuallyExported, moveIOUReportToPolicy, moveIOUReportToPolicyAndInviteSubmitter} from '@libs/actions/Report'; @@ -51,8 +51,8 @@ import { isIOUReport as isIOUReportUtil, isSelfDM, } from '@libs/ReportUtils'; -import {isDefaultExpensesQuery, serializeQueryJSONForBackend} from '@libs/SearchQueryUtils'; -import {getColumnsToShow, getSearchColumnTranslationKey, getValidGroupBy, navigateToSearchRHP, shouldShowDeleteOption} from '@libs/SearchUIUtils'; +import {buildSearchQueryJSON, buildSearchQueryString, isDefaultExpensesQuery, serializeQueryJSONForBackend} from '@libs/SearchQueryUtils'; +import {getColumnsToShow, getSearchColumnTranslationKey, getSelectedGroupFilterEntry, getValidGroupBy, navigateToSearchRHP, shouldShowDeleteOption} from '@libs/SearchUIUtils'; import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; import { getOriginalTransactionWithSplitInfo, @@ -75,6 +75,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import {columnsSelector} from '@src/selectors/AdvancedSearchFiltersForm'; import type {BillingGraceEndPeriod, Policy, Report, ReportNameValuePairs, SearchResults, Transaction, TransactionViolations} from '@src/types/onyx'; +import type {SearchResultDataType} from '@src/types/onyx/SearchResults'; import type DeepValueOf from '@src/types/utils/DeepValueOf'; import useAllPolicyExpenseChatReportActions from './useAllPolicyExpenseChatReportActions'; import useAllTransactions from './useAllTransactions'; @@ -125,6 +126,54 @@ function getRestrictedPolicyID( ); } +function addSelectedGroupsFilter(queryJSON: SearchQueryJSON, selectedTransactions: SelectedTransactions, searchData: SearchResultDataType | undefined): SearchQueryJSON { + const {groupBy} = queryJSON; + if (!groupBy || !searchData) { + return queryJSON; + } + + const groupKeys = new Set(); + for (const [key, value] of Object.entries(selectedTransactions)) { + if (key.startsWith(CONST.SEARCH.GROUP_PREFIX)) { + groupKeys.add(key); + } else if (value.groupKey) { + groupKeys.add(value.groupKey); + } + } + + if (groupKeys.size === 0) { + return queryJSON; + } + + const filterEntries: Array<{key: SearchFilterKey; value: string | number}> = []; + for (const key of groupKeys) { + const group = searchData[key as keyof SearchResultDataType]; + if (!group) { + continue; + } + const entry = getSelectedGroupFilterEntry(groupBy, group); + if (entry) { + filterEntries.push(entry); + } + } + + if (filterEntries.length === 0) { + return queryJSON; + } + + const filterKey = filterEntries.at(0)?.key; + if (!filterKey) { + return queryJSON; + } + const newFlatFilters = queryJSON.flatFilters.filter((filter) => filter.key !== filterKey); + newFlatFilters.push({ + key: filterKey, + filters: filterEntries.map((e) => ({operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, value: e.value})), + }); + + return buildSearchQueryJSON(buildSearchQueryString({...queryJSON, flatFilters: newFlatFilters})) ?? queryJSON; +} + type ShouldShowBulkDuplicateParams = { selectedTransactionsKeys: string[]; selectedTransactions: Record; @@ -479,23 +528,25 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { setIsOfflineModalVisible(true); return; } + const serializedQuery = queryJSON ? serializeQueryJSONForBackend(queryJSON) : JSON.stringify(queryJSON); if (areAllMatchingItemsSelected) { queueExportSearchWithTemplate({ templateName, templateType, - jsonQuery: queryJSON ? serializeQueryJSONForBackend(queryJSON) : JSON.stringify(queryJSON), + jsonQuery: serializedQuery, reportIDList: [], transactionIDList: [], policyID, }); } else { + const isGroupExport = !!queryJSON?.groupBy && selectedTransactionsKeys.some((key) => key.startsWith(CONST.SEARCH.GROUP_PREFIX)); queueExportSearchWithTemplate({ templateName, templateType, - jsonQuery: '{}', - reportIDList: selectedTransactionReportIDs, - transactionIDList: selectedTransactionsKeys, + jsonQuery: isGroupExport ? serializeQueryJSONForBackend(addSelectedGroupsFilter(queryJSON, selectedTransactions, currentSearchResults?.data)) : '{}', + reportIDList: isGroupExport ? [] : selectedTransactionReportIDs, + transactionIDList: isGroupExport ? [] : selectedTransactionsKeys, policyID, }); } @@ -513,6 +564,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { }, [ selectedReports, + selectedTransactions, isOffline, areAllMatchingItemsSelected, showConfirmModal, @@ -607,14 +659,18 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { return; } + const isGroupExport = !!queryJSON?.groupBy && selectedTransactionsKeys.some((key) => key.startsWith(CONST.SEARCH.GROUP_PREFIX)); let didFail = false; const exportParameters = getCSVExportParameters(isBasicExport); + const reportIDList = selectedReports.length > 0 ? selectedReportIDs : selectedTransactionReportIDs; await exportSearchItemsToCSV( { query: status, - jsonQuery: exportParameters.jsonQuery, - reportIDList: selectedReports.length > 0 ? selectedReportIDs : selectedTransactionReportIDs, - transactionIDList: selectedTransactionsKeys, + jsonQuery: isGroupExport + ? serializeQueryJSONForBackend(addSelectedGroupsFilter(queryJSON, selectedTransactions, currentSearchResults?.data)) + : exportParameters.jsonQuery, + reportIDList: isGroupExport ? [] : reportIDList, + transactionIDList: isGroupExport ? [] : selectedTransactionsKeys, isBasicExport: exportParameters.isBasicExport, exportColumnLabels: exportParameters.exportColumnLabels, }, @@ -633,9 +689,11 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { isOffline, status, areAllMatchingItemsSelected, + queryJSON, selectedReports, selectedReportIDs, selectedTransactionReportIDs, + selectedTransactions, selectedTransactionsKeys, translate, clearSelectedTransactions, @@ -643,6 +701,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { hash, selectAllMatchingItems, getCSVExportParameters, + currentSearchResults?.data, ], ); @@ -1099,6 +1158,9 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const connectedIntegration = getConnectedIntegration(policy); const isReportsTab = isExpenseReportType; + const includesGroupExport = Object.entries(selectedTransactions).some( + ([key, selectedTransaction]) => key.startsWith(CONST.SEARCH.GROUP_PREFIX) && !selectedTransaction?.transaction, + ); const canReportBeExported = (report: (typeof selectedReports)[0], exportOption: ValueOf) => { if (!report.reportID) { @@ -1228,7 +1290,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { }); } - if (!allSelectedAreDeleted) { + if (!allSelectedAreDeleted && !includesGroupExport) { for (const template of exportTemplates) { const isStandardTemplate = template.templateName === CONST.REPORT.EXPORT_OPTIONS.EXPENSE_LEVEL_EXPORT || template.templateName === CONST.REPORT.EXPORT_OPTIONS.REPORT_LEVEL_EXPORT; @@ -1248,15 +1310,30 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { return exportOptions; }; - const exportButtonOption: DropdownOption & Pick = { - icon: expensifyIcons.Export, - rightIcon: expensifyIcons.ArrowRight, - text: translate('common.export'), - backButtonText: translate('common.export'), - value: CONST.SEARCH.BULK_ACTION_TYPES.EXPORT, - shouldCloseModalOnSelect: true, - subMenuItems: getExportOptions(), - }; + const subMenuItems = getExportOptions(); + const singleExportSubMenuItem = subMenuItems.length === 1 ? subMenuItems.at(0) : undefined; + + const exportButtonOption: DropdownOption & Pick = singleExportSubMenuItem + ? { + icon: expensifyIcons.Export, + text: singleExportSubMenuItem.text, + value: CONST.SEARCH.BULK_ACTION_TYPES.EXPORT, + shouldCloseModalOnSelect: singleExportSubMenuItem.shouldCloseModalOnSelect ?? true, + shouldCallAfterModalHide: singleExportSubMenuItem.shouldCallAfterModalHide, + onSelected: () => singleExportSubMenuItem.onSelected?.(), + description: singleExportSubMenuItem.description, + displayInDefaultIconColor: singleExportSubMenuItem.displayInDefaultIconColor, + additionalIconStyles: singleExportSubMenuItem.additionalIconStyles, + } + : { + icon: expensifyIcons.Export, + rightIcon: expensifyIcons.ArrowRight, + text: translate('common.export'), + backButtonText: translate('common.export'), + value: CONST.SEARCH.BULK_ACTION_TYPES.EXPORT, + shouldCloseModalOnSelect: true, + subMenuItems, + }; if (areAllMatchingItemsSelected) { return [exportButtonOption]; diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 7a8406ad0908..a1d9de6c68d3 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -2905,6 +2905,31 @@ function getReportSections({ return [reportIDToTransactionsValues, reportIDToTransactionsValues.length, hasDeletedTransaction]; } +function getSelectedGroupFilterEntry(groupBy: string, groupData: unknown): {key: SearchFilterKey; value: string | number} | undefined { + switch (groupBy) { + case CONST.SEARCH.GROUP_BY.FROM: + return {key: CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, value: (groupData as SearchMemberGroup).accountID}; + case CONST.SEARCH.GROUP_BY.CARD: + return {key: CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID, value: (groupData as SearchCardGroup).cardID}; + case CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID: + return {key: CONST.SEARCH.SYNTAX_FILTER_KEYS.WITHDRAWAL_ID, value: (groupData as SearchWithdrawalIDGroup).entryID}; + case CONST.SEARCH.GROUP_BY.CATEGORY: { + const category = (groupData as SearchCategoryGroup).category; + return {key: CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY, value: !category ? CONST.SEARCH.CATEGORY_EMPTY_VALUE : category}; + } + case CONST.SEARCH.GROUP_BY.MERCHANT: { + const merchant = (groupData as SearchMerchantGroup).merchant; + return {key: CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT, value: merchant === '' ? CONST.SEARCH.MERCHANT_EMPTY_VALUE : merchant}; + } + case CONST.SEARCH.GROUP_BY.TAG: { + const tag = (groupData as SearchTagGroup).tag; + return {key: CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG, value: tag === '' || tag === '(untagged)' ? CONST.SEARCH.TAG_EMPTY_VALUE : tag}; + } + default: + return undefined; + } +} + function buildSpecificGroupQuery(queryJSON: SearchQueryJSON, filterKey: SearchFilterKey, filterValue: string | number): SearchQueryJSON | undefined { const newFlatFilters = queryJSON.flatFilters.filter((filter) => filter.key !== filterKey); newFlatFilters.push({key: filterKey, filters: [{operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, value: filterValue}]}); @@ -6043,6 +6068,7 @@ export { getSearchReportAvatarProps, isTodoSearch, getActiveGroupSearchHashes, + getSelectedGroupFilterEntry, adjustTimeRangeToDateFilters, getDateDisplayValue, shouldShowFilter, diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 6395d8404733..4c1d0fcb886c 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -129,6 +129,10 @@ function SearchPage({route}: SearchPageProps) { const currency = metadata?.currency ?? selectedTransactionItems.at(0)?.groupCurrency ?? selectedTransactionItems.at(0)?.currency; const numberOfExpense = shouldUseClientTotal ? selectedTransactionsKeys.reduce((count, key) => { + if (key.startsWith(CONST.SEARCH.GROUP_PREFIX)) { + const group = currentSearchResults?.data?.[key as keyof typeof currentSearchResults.data] as {count?: number} | undefined; + return count + (group?.count ?? 0); + } const item = selectedTransactions[key]; if (item.action === CONST.SEARCH.ACTION_TYPES.VIEW && key === item.reportID) { return count; @@ -139,7 +143,7 @@ function SearchPage({route}: SearchPageProps) { const total = shouldUseClientTotal ? selectedTransactionItems.reduce((acc, transaction) => acc - (transaction.groupAmount ?? -Math.abs(transaction.amount)), 0) : metadata?.total; return {count: numberOfExpense, total, currency}; - }, [areAllMatchingItemsSelected, metadata?.count, metadata?.currency, metadata?.total, selectedTransactions, selectedTransactionsKeys, shouldAllowFooterTotals]); + }, [areAllMatchingItemsSelected, metadata?.count, metadata?.currency, metadata?.total, selectedTransactions, selectedTransactionsKeys, shouldAllowFooterTotals, currentSearchResults]); const onSortPressedCallback = useCallback(() => { setIsSorting(true);