diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportNavigation.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportNavigation.tsx index cb832933a2407..416975a083acb 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportNavigation.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportNavigation.tsx @@ -14,6 +14,7 @@ import {saveLastSearchParams} from '@userActions/ReportNavigation'; import {search} from '@userActions/Search'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import {isActionLoadingSetSelector} from '@src/selectors/ReportMetaData'; type MoneyRequestReportNavigationProps = { reportID?: string; @@ -25,6 +26,7 @@ function MoneyRequestReportNavigation({reportID, shouldDisplayNarrowVersion}: Mo const [currentSearchResults] = useOnyx(`${ONYXKEYS.COLLECTION.SNAPSHOT}${lastSearchQuery?.queryJSON?.hash}`, {canBeMissing: true}); const currentUserDetails = useCurrentUserPersonalDetails(); const {localeCompare, formatPhoneNumber} = useLocalize(); + const [isActionLoadingSet = new Set()] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}`, {canBeMissing: true, selector: isActionLoadingSetSelector}); const [exportReportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS, { canEvict: false, @@ -37,17 +39,18 @@ function MoneyRequestReportNavigation({reportID, shouldDisplayNarrowVersion}: Mo const {type, status, sortBy, sortOrder, groupBy} = lastSearchQuery?.queryJSON ?? {}; let results: Array = []; if (!!type && !!currentSearchResults?.data && !!currentSearchResults?.search) { - const searchData = getSections( + const searchData = getSections({ type, - currentSearchResults.data, - currentUserDetails.accountID, - currentUserDetails.email ?? '', + data: currentSearchResults.data, + currentAccountID: currentUserDetails.accountID, + currentUserEmail: currentUserDetails.email ?? '', formatPhoneNumber, groupBy, - exportReportActions, - lastSearchQuery?.searchKey, - archivedReportsIdSet, - ); + reportActions: exportReportActions, + currentSearch: lastSearchQuery?.searchKey, + archivedReportsIDList: archivedReportsIdSet, + isActionLoadingSet, + }); results = getSortedSections(type, status ?? '', searchData, localeCompare, sortBy, sortOrder, groupBy).map((value) => value.reportID); } const allReports = results; diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index b91a445af2806..49a41c69d1d0d 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -64,6 +64,7 @@ import NAVIGATORS from '@src/NAVIGATORS'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; +import {isActionLoadingSetSelector} from '@src/selectors/ReportMetaData'; import type {OutstandingReportsByPolicyIDDerivedValue} from '@src/types/onyx'; import type Policy from '@src/types/onyx/Policy'; import type Report from '@src/types/onyx/Report'; @@ -258,6 +259,7 @@ function Search({ const [outstandingReportsByPolicyID] = useOnyx(ONYXKEYS.DERIVED.OUTSTANDING_REPORTS_BY_POLICY_ID, {canBeMissing: true}); const [violations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS, {canBeMissing: true}); const {accountID, email} = useCurrentUserPersonalDetails(); + const [isActionLoadingSet = new Set()] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}`, {canBeMissing: true, selector: isActionLoadingSetSelector}); // Filter violations based on user visibility const filteredViolations = useMemo(() => { @@ -406,9 +408,21 @@ function Search({ return [[], 0]; } - const data1 = getSections(type, searchResults.data, accountID, email ?? '', formatPhoneNumber, validGroupBy, exportReportActions, searchKey, archivedReportsIdSet, queryJSON); + const data1 = getSections({ + type, + data: searchResults.data, + currentAccountID: accountID, + currentUserEmail: email ?? '', + formatPhoneNumber, + groupBy: validGroupBy, + reportActions: exportReportActions, + currentSearch: searchKey, + archivedReportsIDList: archivedReportsIdSet, + queryJSON, + isActionLoadingSet, + }); return [data1, data1.length]; - }, [searchKey, exportReportActions, validGroupBy, isDataLoaded, searchResults, type, archivedReportsIdSet, formatPhoneNumber, accountID, queryJSON, email]); + }, [searchKey, exportReportActions, validGroupBy, isDataLoaded, searchResults, type, archivedReportsIdSet, formatPhoneNumber, accountID, queryJSON, email, isActionLoadingSet]); useEffect(() => { /** We only want to display the skeleton for the status filters the first time we load them for a specific data type */ diff --git a/src/components/SelectionListWithSections/Search/ReportListItemHeader.tsx b/src/components/SelectionListWithSections/Search/ReportListItemHeader.tsx index 797c201c674e2..2307830ec3a90 100644 --- a/src/components/SelectionListWithSections/Search/ReportListItemHeader.tsx +++ b/src/components/SelectionListWithSections/Search/ReportListItemHeader.tsx @@ -16,6 +16,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {handleActionButtonPress} from '@userActions/Search'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import {isActionLoadingSelector} from '@src/selectors/ReportMetaData'; import type {Policy} from '@src/types/onyx'; import type {SearchReport} from '@src/types/onyx/SearchResults'; import ActionCell from './ActionCell'; @@ -108,6 +109,7 @@ function HeaderFirstRow({ const StyleUtils = useStyleUtils(); const {isLargeScreenWidth} = useResponsiveLayout(); const theme = useTheme(); + const [isActionLoading] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportItem.reportID}`, {canBeMissing: true, selector: isActionLoadingSelector}); const {total, currency} = useMemo(() => { let reportTotal = reportItem.total ?? 0; @@ -180,7 +182,7 @@ function HeaderFirstRow({ action={reportItem.action} goToItem={handleOnButtonPress} isSelected={reportItem.isSelected} - isLoading={reportItem.isActionLoading} + isLoading={isActionLoading} policyID={reportItem.policyID} reportID={reportItem.reportID} hash={reportItem.hash} diff --git a/src/components/SelectionListWithSections/Search/TransactionGroupListItem.tsx b/src/components/SelectionListWithSections/Search/TransactionGroupListItem.tsx index a74a1f396fc2e..318f8764bf190 100644 --- a/src/components/SelectionListWithSections/Search/TransactionGroupListItem.tsx +++ b/src/components/SelectionListWithSections/Search/TransactionGroupListItem.tsx @@ -35,6 +35,7 @@ import {getSections} from '@libs/SearchUIUtils'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import {isActionLoadingSetSelector} from '@src/selectors/ReportMetaData'; import type {ReportAction, ReportActions} from '@src/types/onyx'; import CardListItemHeader from './CardListItemHeader'; import MemberListItemHeader from './MemberListItemHeader'; @@ -94,6 +95,7 @@ function TransactionGroupListItem({ const isExpenseReportType = searchType === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT; const [transactionsVisibleLimit, setTransactionsVisibleLimit] = useState(CONST.TRANSACTION.RESULTS_PAGE_SIZE as number); const [isExpanded, setIsExpanded] = useState(false); + const [isActionLoadingSet = new Set()] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}`, {canBeMissing: true, selector: isActionLoadingSetSelector}); const transactions = useMemo(() => { if (isExpenseReportType) { @@ -102,18 +104,19 @@ function TransactionGroupListItem({ if (!transactionsSnapshot?.data) { return []; } - const sectionData = getSections( - CONST.SEARCH.DATA_TYPES.EXPENSE, - transactionsSnapshot?.data, - accountID, - currentUserDetails.email ?? '', + const sectionData = getSections({ + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + data: transactionsSnapshot?.data, + currentAccountID: accountID, + currentUserEmail: currentUserDetails.email ?? '', formatPhoneNumber, - ) as TransactionListItemType[]; + isActionLoadingSet, + }) as TransactionListItemType[]; return sectionData.map((transactionItem) => ({ ...transactionItem, isSelected: selectedTransactionIDsSet.has(transactionItem.transactionID), })); - }, [isExpenseReportType, transactionsSnapshot?.data, accountID, formatPhoneNumber, groupItem.transactions, selectedTransactionIDsSet, currentUserDetails.email]); + }, [isExpenseReportType, transactionsSnapshot?.data, accountID, formatPhoneNumber, groupItem.transactions, selectedTransactionIDsSet, currentUserDetails.email, isActionLoadingSet]); const selectedItemsLength = useMemo(() => { return transactions.reduce((acc, transaction) => { diff --git a/src/components/SelectionListWithSections/Search/TransactionListItem.tsx b/src/components/SelectionListWithSections/Search/TransactionListItem.tsx index 13f8511c54874..18fb5d8c8f89b 100644 --- a/src/components/SelectionListWithSections/Search/TransactionListItem.tsx +++ b/src/components/SelectionListWithSections/Search/TransactionListItem.tsx @@ -24,6 +24,7 @@ import {isViolationDismissed, shouldShowViolation} from '@libs/TransactionUtils' import variables from '@styles/variables'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import {isActionLoadingSelector} from '@src/selectors/ReportMetaData'; import type {Policy, ReportAction, ReportActions} from '@src/types/onyx'; import type {SearchReport} from '@src/types/onyx/SearchResults'; import type {TransactionViolation} from '@src/types/onyx/TransactionViolation'; @@ -58,6 +59,8 @@ function TransactionListItem({ return (snapshot?.data?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionItem.reportID}`] ?? {}) as SearchReport; }, [snapshot, transactionItem.reportID]); + const [isActionLoading] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${transactionItem.reportID}`, {canBeMissing: true, selector: isActionLoadingSelector}); + const snapshotPolicy = useMemo(() => { return (snapshot?.data?.[`${ONYXKEYS.COLLECTION.POLICY}${transactionItem.policyID}`] ?? {}) as Policy; }, [snapshot, transactionItem.policyID]); @@ -176,7 +179,7 @@ function TransactionListItem({ onCheckboxPress={handleCheckboxPress} shouldUseNarrowLayout={!isLargeScreenWidth} columns={columns} - isActionLoading={isLoading ?? transactionItem.isActionLoading ?? snapshotReport.isActionLoading} + isActionLoading={isLoading ?? transactionItem.isActionLoading ?? isActionLoading} isSelected={!!transactionItem.isSelected} dateColumnSize={dateColumnSize} amountColumnSize={amountColumnSize} diff --git a/src/components/SelectionListWithSections/Search/UserInfoAndActionButtonRow.tsx b/src/components/SelectionListWithSections/Search/UserInfoAndActionButtonRow.tsx index f8768b1ddf135..eba0588ee621a 100644 --- a/src/components/SelectionListWithSections/Search/UserInfoAndActionButtonRow.tsx +++ b/src/components/SelectionListWithSections/Search/UserInfoAndActionButtonRow.tsx @@ -1,7 +1,6 @@ -import React, {useMemo} from 'react'; +import React from 'react'; import {View} from 'react-native'; import type {StyleProp, ViewStyle} from 'react-native'; -import {useSearchContext} from '@components/Search/SearchContext'; import type {TransactionListItemType, TransactionReportGroupListItemType} from '@components/SelectionListWithSections/types'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; @@ -11,7 +10,7 @@ import {isCorrectSearchUserName} from '@libs/SearchUIUtils'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {SearchReport} from '@src/types/onyx/SearchResults'; +import {isActionLoadingSelector} from '@src/selectors/ReportMetaData'; import ActionCell from './ActionCell'; import UserInfoCellsWithArrow from './UserInfoCellsWithArrow'; @@ -32,19 +31,13 @@ function UserInfoAndActionButtonRow({ const {isLargeScreenWidth} = useResponsiveLayout(); const {translate} = useLocalize(); const transactionItem = item as unknown as TransactionListItemType; - const {currentSearchHash} = useSearchContext(); - const [snapshot] = useOnyx(`${ONYXKEYS.COLLECTION.SNAPSHOT}${currentSearchHash}`, {canBeMissing: true}); + const [isActionLoading] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${transactionItem.reportID}`, {canBeMissing: true, selector: isActionLoadingSelector}); const hasFromSender = !!item?.from && !!item?.from?.accountID && !!item?.from?.displayName; const hasToRecipient = !!item?.to && !!item?.to?.accountID && !!item?.to?.displayName; const participantFromDisplayName = item?.from?.displayName ?? item?.from?.login ?? translate('common.hidden'); const participantToDisplayName = item?.to?.displayName ?? item?.to?.login ?? translate('common.hidden'); const shouldShowToRecipient = hasFromSender && hasToRecipient && !!item?.to?.accountID && !!isCorrectSearchUserName(participantToDisplayName); - - const snapshotReport = useMemo(() => { - // eslint-disable-next-line @typescript-eslint/no-deprecated - return (snapshot?.data?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionItem.reportID}`] ?? {}) as SearchReport; - }, [snapshot, transactionItem.reportID]); - + const isLoading = 'isActionLoading' in item ? item?.isActionLoading : isActionLoading; return ( ; type ArchivedReportsIDSet = ReadonlySet; +type GetSectionsParams = { + type: SearchDataTypes; + data: OnyxTypes.SearchResults['data']; + currentAccountID: number | undefined; + currentUserEmail: string; + formatPhoneNumber: LocaleContextProps['formatPhoneNumber']; + groupBy?: SearchGroupBy; + reportActions?: Record; + currentSearch?: SearchKey; + archivedReportsIDList?: ArchivedReportsIDSet; + queryJSON?: SearchQueryJSON; + isActionLoadingSet?: ReadonlySet; +}; + /** * Returns a list of all possible searches in the LHN, along with their query & hash. * *NOTE* When rendering the LHN, you should use the "createTypeMenuSections" method, which @@ -1418,6 +1432,7 @@ function getReportSections( currentAccountID: number | undefined, currentUserEmail: string, formatPhoneNumber: LocaleContextProps['formatPhoneNumber'], + isActionLoadingSet: ReadonlySet | undefined, reportActions: Record = {}, ): TransactionGroupListItemType[] { const shouldShowMerchant = getShouldShowMerchant(data); @@ -1455,7 +1470,7 @@ function getReportSections( const actions = reportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportItem.reportID}`]; let shouldShow = true; - if (queryJSON && !reportItem.isActionLoading) { + if (queryJSON && isActionLoadingSet?.has(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportItem.reportID}`)) { if (queryJSON.type === CONST.SEARCH.DATA_TYPES.EXPENSE) { const status = queryJSON.status; @@ -1669,18 +1684,19 @@ function getListItem(type: SearchDataTypes, status: SearchStatus, groupBy?: Sear /** * Organizes data into appropriate list sections for display based on the type of search results. */ -function getSections( - type: SearchDataTypes, - data: OnyxTypes.SearchResults['data'], - currentAccountID: number | undefined, - currentUserEmail: string, - formatPhoneNumber: LocaleContextProps['formatPhoneNumber'], - groupBy?: SearchGroupBy, - reportActions: Record = {}, - currentSearch: SearchKey = CONST.SEARCH.SEARCH_KEYS.EXPENSES, - archivedReportsIDList?: ArchivedReportsIDSet, - queryJSON?: SearchQueryJSON, -) { +function getSections({ + type, + data, + currentAccountID, + currentUserEmail, + formatPhoneNumber, + groupBy, + reportActions = {}, + currentSearch = CONST.SEARCH.SEARCH_KEYS.EXPENSES, + archivedReportsIDList, + queryJSON, + isActionLoadingSet, +}: GetSectionsParams) { if (type === CONST.SEARCH.DATA_TYPES.CHAT) { return getReportActionsSections(data); } @@ -1689,7 +1705,7 @@ function getSections( } if (type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT) { - return getReportSections(data, currentSearch, currentAccountID, currentUserEmail, formatPhoneNumber, reportActions); + return getReportSections(data, currentSearch, currentAccountID, currentUserEmail, formatPhoneNumber, isActionLoadingSet, reportActions); } if (groupBy) { diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index f9a897a18b26f..bf3bca4629f8b 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -41,7 +41,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import {FILTER_KEYS} from '@src/types/form/SearchAdvancedFiltersForm'; import type {SearchAdvancedFiltersForm} from '@src/types/form/SearchAdvancedFiltersForm'; -import type {ExportTemplate, LastPaymentMethod, LastPaymentMethodType, Policy, ReportAction, ReportActions, Transaction} from '@src/types/onyx'; +import type {ExportTemplate, LastPaymentMethod, LastPaymentMethodType, Policy, ReportAction, ReportActions, ReportMetadata, Transaction} from '@src/types/onyx'; import type {PaymentInformation} from '@src/types/onyx/LastPaymentMethod'; import type {ConnectionName} from '@src/types/onyx/Policy'; import type {SearchReport, SearchTransaction} from '@src/types/onyx/SearchResults'; @@ -453,23 +453,43 @@ function holdMoneyRequestOnSearch(hash: number, transactionIDList: string[], com // eslint-disable-next-line @typescript-eslint/no-deprecated function submitMoneyRequestOnSearch(hash: number, reportList: SearchReport[], policy: Policy[], transactionIDList?: string[], currentSearchKey?: SearchKey) { // eslint-disable-next-line @typescript-eslint/no-deprecated - const createOnyxData = (update: Partial | Partial | null): OnyxUpdate[] => [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`, - value: { - data: transactionIDList - ? (Object.fromEntries(transactionIDList.map((transactionID) => [`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, update])) as Partial) - : // eslint-disable-next-line @typescript-eslint/no-deprecated - (Object.fromEntries(reportList.map((report) => [`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, update])) as Partial), + const createOnyxData = (update: Partial | Partial | null, shouldRemoveReportFromView = false): OnyxUpdate[] => { + // eslint-disable-next-line @typescript-eslint/no-deprecated + let data: Partial | Partial | undefined; + + if (transactionIDList) { + data = Object.fromEntries(transactionIDList.map((transactionID) => [`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, update])) as Partial; + } else if (shouldRemoveReportFromView) { + // eslint-disable-next-line @typescript-eslint/no-deprecated + data = Object.fromEntries(reportList.map((report) => [`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, null])) as Partial; + } + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`, + value: { + data, + }, }, - }, - ]; + ]; + reportList.forEach((report) => { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${report.reportID}`, + value: { + ...update, + }, + }); + }); + + return optimisticData; + }; const optimisticData: OnyxUpdate[] = createOnyxData({isActionLoading: true}); const failureData: OnyxUpdate[] = createOnyxData({isActionLoading: false}); // If we are on the 'Submit' suggested search, remove the report from the view once the action is taken, don't wait for the view to be re-fetched via Search - const successData: OnyxUpdate[] = currentSearchKey === CONST.SEARCH.SEARCH_KEYS.SUBMIT ? createOnyxData(null) : createOnyxData({isActionLoading: false}); + const successData: OnyxUpdate[] = currentSearchKey === CONST.SEARCH.SEARCH_KEYS.SUBMIT ? createOnyxData({isActionLoading: false}, true) : createOnyxData({isActionLoading: false}); // eslint-disable-next-line @typescript-eslint/no-deprecated const report = (reportList.at(0) ?? {}) as SearchReport; @@ -486,18 +506,38 @@ function submitMoneyRequestOnSearch(hash: number, reportList: SearchReport[], po function approveMoneyRequestOnSearch(hash: number, reportIDList: string[], transactionIDList?: string[], currentSearchKey?: SearchKey) { // eslint-disable-next-line @typescript-eslint/no-deprecated - const createOnyxData = (update: Partial | Partial | null): OnyxUpdate[] => [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`, - value: { - data: transactionIDList - ? (Object.fromEntries(transactionIDList.map((transactionID) => [`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, update])) as Partial) - : // eslint-disable-next-line @typescript-eslint/no-deprecated - (Object.fromEntries(reportIDList.map((reportID) => [`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, update])) as Partial), + const createOnyxData = (update: Partial | Partial | null, shouldRemoveReportFromView = false): OnyxUpdate[] => { + // eslint-disable-next-line @typescript-eslint/no-deprecated + let data: Partial | Partial | undefined; + + if (transactionIDList) { + data = Object.fromEntries(transactionIDList.map((transactionID) => [`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, update])) as Partial; + } else if (shouldRemoveReportFromView) { + // eslint-disable-next-line @typescript-eslint/no-deprecated + data = Object.fromEntries(reportIDList.map((reportID) => [`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, null])) as Partial; + } + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`, + value: { + data, + }, }, - }, - ]; + ]; + reportIDList.forEach((reportID) => { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`, + value: { + ...update, + }, + }); + }); + + return optimisticData; + }; const optimisticData: OnyxUpdate[] = createOnyxData({isActionLoading: true}); const failureData: OnyxUpdate[] = createOnyxData({isActionLoading: false, errors: getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage')}); @@ -505,7 +545,7 @@ function approveMoneyRequestOnSearch(hash: number, reportIDList: string[], trans // If we are on the 'Approve', `Unapproved cash` or the `Unapproved company cards` suggested search, remove the report from the view once the action is taken, don't wait for the view to be re-fetched via Search const approveActionSuggestedSearches: Partial = [CONST.SEARCH.SEARCH_KEYS.APPROVE, CONST.SEARCH.SEARCH_KEYS.UNAPPROVED_CASH, CONST.SEARCH.SEARCH_KEYS.UNAPPROVED_CARD]; - const successData: OnyxUpdate[] = approveActionSuggestedSearches.includes(currentSearchKey) ? createOnyxData(null) : createOnyxData({isActionLoading: false}); + const successData: OnyxUpdate[] = approveActionSuggestedSearches.includes(currentSearchKey) ? createOnyxData({isActionLoading: false}, true) : createOnyxData({isActionLoading: false}); playSound(SOUNDS.SUCCESS); API.write(WRITE_COMMANDS.APPROVE_MONEY_REQUEST_ON_SEARCH, {hash, reportIDList}, {optimisticData, failureData, successData}); @@ -516,31 +556,42 @@ function exportToIntegrationOnSearch(hash: number, reportID: string, connectionN const successAction: OptimisticExportIntegrationAction = {...optimisticAction, pendingAction: null}; const optimisticReportActionID = optimisticAction.reportActionID; - // eslint-disable-next-line @typescript-eslint/no-deprecated - const createOnyxData = (update: Partial | Partial | null, reportAction?: OptimisticExportIntegrationAction | null): OnyxUpdate[] => [ - // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830 - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`, - value: { - data: { - [`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]: update, + const createOnyxData = (update: Partial | null, reportAction?: OptimisticExportIntegrationAction | null, shouldRemoveReportFromView = false): OnyxUpdate[] => { + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`, + value: { + ...update, }, }, - }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, - value: { - [optimisticReportActionID]: reportAction, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, + value: { + [optimisticReportActionID]: reportAction, + }, }, - }, - ]; + ]; + if (shouldRemoveReportFromView) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`, + value: { + data: { + [`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]: null, + }, + }, + }); + } + return optimisticData; + }; const optimisticData: OnyxUpdate[] = createOnyxData({isActionLoading: true}, optimisticAction); const failureData: OnyxUpdate[] = createOnyxData({errors: getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), isActionLoading: false}, null); // If we are on the 'Export' suggested search, remove the report from the view once the action is taken, don't wait for the view to be re-fetched via Search - const successData: OnyxUpdate[] = currentSearchKey === CONST.SEARCH.SEARCH_KEYS.EXPORT ? createOnyxData(null, successAction) : createOnyxData({isActionLoading: false}, successAction); + const successData: OnyxUpdate[] = + currentSearchKey === CONST.SEARCH.SEARCH_KEYS.EXPORT ? createOnyxData({isActionLoading: false}, successAction, true) : createOnyxData({isActionLoading: false}, successAction); const params = { reportIDList: reportID, @@ -556,23 +607,44 @@ function exportToIntegrationOnSearch(hash: number, reportID: string, connectionN function payMoneyRequestOnSearch(hash: number, paymentData: PaymentData[], transactionIDList?: string[], currentSearchKey?: SearchKey) { // eslint-disable-next-line @typescript-eslint/no-deprecated - const createOnyxData = (update: Partial | Partial | null): OnyxUpdate[] => [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`, - value: { - data: transactionIDList - ? (Object.fromEntries(transactionIDList.map((transactionID) => [`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, update])) as Partial) - : // eslint-disable-next-line @typescript-eslint/no-deprecated - (Object.fromEntries(paymentData.map((item) => [`${ONYXKEYS.COLLECTION.REPORT}${item.reportID}`, update])) as Partial), + const createOnyxData = (update: Partial | Partial | null, shouldRemoveReportFromView = false): OnyxUpdate[] => { + // eslint-disable-next-line @typescript-eslint/no-deprecated + let data: Partial | Partial | undefined; + + if (transactionIDList) { + data = Object.fromEntries(transactionIDList.map((transactionID) => [`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, update])) as Partial; + } else if (shouldRemoveReportFromView) { + // eslint-disable-next-line @typescript-eslint/no-deprecated + data = Object.fromEntries(paymentData.map((item) => [`${ONYXKEYS.COLLECTION.REPORT}${item.reportID}`, null])) as Partial; + } + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`, + value: { + data, + }, }, - }, - ]; + ]; + + paymentData.forEach((item) => { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${item.reportID}`, + value: { + ...update, + }, + }); + }); + + return optimisticData; + }; const optimisticData: OnyxUpdate[] = createOnyxData({isActionLoading: true}); const failureData: OnyxUpdate[] = createOnyxData({isActionLoading: false, errors: getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage')}); // If we are on the 'Pay' suggested search, remove the report from the view once the action is taken, don't wait for the view to be re-fetched via Search - const successData: OnyxUpdate[] = currentSearchKey === CONST.SEARCH.SEARCH_KEYS.PAY ? createOnyxData(null) : createOnyxData({isActionLoading: false}); + const successData: OnyxUpdate[] = currentSearchKey === CONST.SEARCH.SEARCH_KEYS.PAY ? createOnyxData({isActionLoading: false}, true) : createOnyxData({isActionLoading: false}); // eslint-disable-next-line rulesdir/no-api-side-effects-method API.makeRequestWithSideEffects( diff --git a/src/selectors/ReportMetaData.ts b/src/selectors/ReportMetaData.ts new file mode 100644 index 0000000000000..5d05a2bbb43b5 --- /dev/null +++ b/src/selectors/ReportMetaData.ts @@ -0,0 +1,20 @@ +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import type {ReportMetadata} from '@src/types/onyx'; + +const isActionLoadingSelector = (reportMetadata: OnyxEntry | undefined) => reportMetadata?.isActionLoading ?? false; + +const isActionLoadingSetSelector = (all: OnyxCollection): ReadonlySet => { + const ids = new Set(); + if (!all) { + return ids; + } + + for (const [key, value] of Object.entries(all)) { + if (value?.isActionLoading) { + ids.add(key); + } + } + return ids; +}; + +export {isActionLoadingSelector, isActionLoadingSetSelector}; diff --git a/src/types/onyx/ReportMetadata.ts b/src/types/onyx/ReportMetadata.ts index 2c9e0e9f0d2bd..a006bd90c5b22 100644 --- a/src/types/onyx/ReportMetadata.ts +++ b/src/types/onyx/ReportMetadata.ts @@ -43,6 +43,12 @@ type ReportMetadata = { /** Pending members of the report */ pendingChatMembers?: PendingChatMember[]; + + /** Whether the action is loading */ + isActionLoading?: boolean; + + /** Whether the report has violations or errors */ + errors?: OnyxCommon.Errors; }; export default ReportMetadata; diff --git a/src/types/onyx/SearchResults.ts b/src/types/onyx/SearchResults.ts index 15cefb66ef09a..f00b4554f221a 100644 --- a/src/types/onyx/SearchResults.ts +++ b/src/types/onyx/SearchResults.ts @@ -139,9 +139,6 @@ type SearchReport = { /** For expense reports, this is the total amount requested */ unheldTotal?: number; - /** Whether the action is loading */ - isActionLoading?: boolean; - /** Whether the report has violations or errors */ errors?: OnyxCommon.Errors; diff --git a/tests/unit/Search/SearchUIUtilsTest.ts b/tests/unit/Search/SearchUIUtilsTest.ts index 07805516abc03..dc18150161621 100644 --- a/tests/unit/Search/SearchUIUtilsTest.ts +++ b/tests/unit/Search/SearchUIUtilsTest.ts @@ -1763,11 +1763,27 @@ describe('SearchUIUtils', () => { describe('Test getSections', () => { it('should return getReportActionsSections result when type is CHAT', () => { - expect(SearchUIUtils.getSections(CONST.SEARCH.DATA_TYPES.CHAT, searchResults.data, 2074551, '', formatPhoneNumber)).toStrictEqual(reportActionListItems); + expect( + SearchUIUtils.getSections({ + type: CONST.SEARCH.DATA_TYPES.CHAT, + data: searchResults.data, + currentAccountID: 2074551, + currentUserEmail: '', + formatPhoneNumber, + }), + ).toStrictEqual(reportActionListItems); }); it('should return getTransactionsSections result when groupBy is undefined', () => { - expect(SearchUIUtils.getSections(CONST.SEARCH.DATA_TYPES.EXPENSE, searchResults.data, 20745, '', formatPhoneNumber)).toEqual(transactionsListItems); + expect( + SearchUIUtils.getSections({ + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + data: searchResults.data, + currentAccountID: 20745, + currentUserEmail: '', + formatPhoneNumber, + }), + ).toEqual(transactionsListItems); }); it('should include iouRequestType property for distance transactions', () => { @@ -1785,7 +1801,13 @@ describe('SearchUIUtils', () => { }, }; - const result = SearchUIUtils.getSections(CONST.SEARCH.DATA_TYPES.EXPENSE, testSearchResults.data, 2074551, '', formatPhoneNumber) as TransactionListItemType[]; + const result = SearchUIUtils.getSections({ + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + data: testSearchResults.data, + currentAccountID: 2074551, + currentUserEmail: '', + formatPhoneNumber, + }) as TransactionListItemType[]; const distanceTransaction = result.find((item) => item.transactionID === distanceTransactionID); @@ -1811,7 +1833,13 @@ describe('SearchUIUtils', () => { }, }; - const result = SearchUIUtils.getSections(CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT, testSearchResults.data, 2074551, '', formatPhoneNumber) as TransactionGroupListItemType[]; + const result = SearchUIUtils.getSections({ + type: CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT, + data: testSearchResults.data, + currentAccountID: 2074551, + currentUserEmail: '', + formatPhoneNumber, + }) as TransactionGroupListItemType[]; const reportGroup = result.find((group) => group.transactions?.some((transaction) => transaction.transactionID === distanceTransactionID)); @@ -1825,7 +1853,15 @@ describe('SearchUIUtils', () => { }); it('should return getReportSections result when type is EXPENSE REPORT', () => { - expect(SearchUIUtils.getSections(CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT, searchResults.data, 2074551, '', formatPhoneNumber)).toStrictEqual(transactionReportGroupListItems); + expect( + SearchUIUtils.getSections({ + type: CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT, + data: searchResults.data, + currentAccountID: 2074551, + currentUserEmail: '', + formatPhoneNumber, + }), + ).toStrictEqual(transactionReportGroupListItems); }); it('should handle data where transaction keys appear before report keys in getReportSections', () => { @@ -1857,8 +1893,20 @@ describe('SearchUIUtils', () => { [`policy_${policyID}`]: searchResults.data[`policy_${policyID}`], }; - const resultTransactionFirst = SearchUIUtils.getSections(CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT, testDataTransactionFirst, 2074551, '', formatPhoneNumber); - const resultReportFirst = SearchUIUtils.getSections(CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT, testDataReportFirst, 2074551, '', formatPhoneNumber); + const resultTransactionFirst = SearchUIUtils.getSections({ + type: CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT, + data: testDataTransactionFirst, + currentAccountID: 2074551, + currentUserEmail: '', + formatPhoneNumber, + }); + const resultReportFirst = SearchUIUtils.getSections({ + type: CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT, + data: testDataReportFirst, + currentAccountID: 2074551, + currentUserEmail: '', + formatPhoneNumber, + }); expect(resultTransactionFirst).toBeDefined(); expect(Array.isArray(resultTransactionFirst)).toBe(true); @@ -1871,20 +1919,41 @@ describe('SearchUIUtils', () => { }); it('should return getMemberSections result when type is EXPENSE and groupBy is from', () => { - expect(SearchUIUtils.getSections(CONST.SEARCH.DATA_TYPES.EXPENSE, searchResultsGroupByFrom.data, 2074551, '', formatPhoneNumber, CONST.SEARCH.GROUP_BY.FROM)).toStrictEqual( - transactionMemberGroupListItems, - ); + expect( + SearchUIUtils.getSections({ + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + data: searchResultsGroupByFrom.data, + currentAccountID: 2074551, + currentUserEmail: '', + formatPhoneNumber, + groupBy: CONST.SEARCH.GROUP_BY.FROM, + }), + ).toStrictEqual(transactionMemberGroupListItems); }); it('should return getCardSections result when type is EXPENSE and groupBy is card', () => { - expect(SearchUIUtils.getSections(CONST.SEARCH.DATA_TYPES.EXPENSE, searchResultsGroupByCard.data, 2074551, '', formatPhoneNumber, CONST.SEARCH.GROUP_BY.CARD)).toStrictEqual( - transactionCardGroupListItems, - ); + expect( + SearchUIUtils.getSections({ + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + data: searchResultsGroupByCard.data, + currentAccountID: 2074551, + currentUserEmail: '', + formatPhoneNumber, + groupBy: CONST.SEARCH.GROUP_BY.CARD, + }), + ).toStrictEqual(transactionCardGroupListItems); }); it('should return getWithdrawalIDSections result when type is EXPENSE and groupBy is withdrawal-id', () => { expect( - SearchUIUtils.getSections(CONST.SEARCH.DATA_TYPES.EXPENSE, searchResultsGroupByWithdrawalID.data, 2074551, '', formatPhoneNumber, CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID), + SearchUIUtils.getSections({ + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + data: searchResultsGroupByWithdrawalID.data, + currentAccountID: 2074551, + currentUserEmail: '', + formatPhoneNumber, + groupBy: CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID, + }), ).toStrictEqual(transactionWithdrawalIDGroupListItems); }); });