Skip to content

Perf: getTransactionsSections optimization #61898

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
232 changes: 176 additions & 56 deletions src/libs/SearchUIUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,10 +137,17 @@ function getTransactionItemCommonFormattedProperties(
): Pick<TransactionListItemType, 'formattedFrom' | 'formattedTo' | 'formattedTotal' | 'formattedMerchant' | 'date'> {
const isExpenseReport = transactionItem.reportType === CONST.REPORT.TYPE.EXPENSE;

const formattedFrom = formatPhoneNumber(getDisplayNameOrDefault(from));
const fromName = getDisplayNameOrDefault(from);
const formattedFrom = formatPhoneNumber(fromName);

// Sometimes the search data personal detail for the 'to' account might not hold neither the display name nor the login
// so for those cases we fallback to the display name of the personal detail data from onyx.
const formattedTo = formatPhoneNumber(getDisplayNameOrDefault(to, '', false) || getDisplayNameOrDefault(getPersonalDetailsForAccountID(to?.accountID)));
let toName = getDisplayNameOrDefault(to, '', false);
if (!toName && to?.accountID) {
toName = getDisplayNameOrDefault(getPersonalDetailsForAccountID(to?.accountID));
}

const formattedTo = formatPhoneNumber(toName);
const formattedTotal = getTransactionAmount(transactionItem, isExpenseReport);
const date = transactionItem?.modifiedCreated ? transactionItem.modifiedCreated : transactionItem?.created;
const merchant = getTransactionMerchant(transactionItem);
Expand Down Expand Up @@ -316,36 +323,153 @@ function getIOUReportName(data: OnyxTypes.SearchResults['data'], reportItem: Sea
function getTransactionsSections(data: OnyxTypes.SearchResults['data'], metadata: OnyxTypes.SearchResults['search']): TransactionListItemType[] {
const shouldShowMerchant = getShouldShowMerchant(data);
const doesDataContainAPastYearTransaction = shouldShowYear(data);
const shouldShowCategory = metadata?.columnsToShow?.shouldShowCategoryColumn;
const shouldShowTag = metadata?.columnsToShow?.shouldShowTagColumn;
const shouldShowTax = metadata?.columnsToShow?.shouldShowTaxColumn;

// Pre-filter transaction keys to avoid repeated checks
const transactionKeys = Object.keys(data).filter(isTransactionEntry);

// Use Map for faster lookups of personal details
const personalDetailsMap = new Map(Object.entries(data.personalDetailsList || {}));

const transactionsSections: TransactionListItemType[] = [];

for (const key of transactionKeys) {
const transactionItem = data[key];
const report = data[`${ONYXKEYS.COLLECTION.REPORT}${transactionItem.reportID}`];
const shouldShowBlankTo = isOpenExpenseReport(report);

// Use Map.get() for faster lookups with default values
const from = personalDetailsMap.get(transactionItem.accountID.toString()) ?? emptyPersonalDetails;
const to = transactionItem.managerID && !shouldShowBlankTo ? personalDetailsMap.get(transactionItem.managerID.toString()) ?? emptyPersonalDetails : emptyPersonalDetails;

const {formattedFrom, formattedTo, formattedTotal, formattedMerchant, date} = getTransactionItemCommonFormattedProperties(transactionItem, from, to);

const transactionSection: TransactionListItemType = {
action: getAction(data, key),
from,
to,
formattedFrom,
formattedTo: shouldShowBlankTo ? '' : formattedTo,
formattedTotal,
formattedMerchant,
date,
shouldShowMerchant,
shouldShowCategory,
shouldShowTag,
shouldShowTax,
keyForList: transactionItem.transactionID,
shouldShowYear: doesDataContainAPastYearTransaction,

// Manually copying all the properties from transactionItem
transactionID: transactionItem.transactionID,
created: transactionItem.created,
modifiedCreated: transactionItem.modifiedCreated,
amount: transactionItem.amount,
canDelete: transactionItem.canDelete,
canHold: transactionItem.canHold,
canUnhold: transactionItem.canUnhold,
modifiedAmount: transactionItem.modifiedAmount,
currency: transactionItem.currency,
modifiedCurrency: transactionItem.modifiedCurrency,
merchant: transactionItem.merchant,
modifiedMerchant: transactionItem.modifiedMerchant,
comment: transactionItem.comment,
category: transactionItem.category,
transactionType: transactionItem.transactionType,
reportType: transactionItem.reportType,
policyID: transactionItem.policyID,
parentTransactionID: transactionItem.parentTransactionID,
hasEReceipt: transactionItem.hasEReceipt,
accountID: transactionItem.accountID,
managerID: transactionItem.managerID,
reportID: transactionItem.reportID,
transactionThreadReportID: transactionItem.transactionThreadReportID,
isFromOneTransactionReport: transactionItem.isFromOneTransactionReport,
tag: transactionItem.tag,
};

transactionsSections.push(transactionSection);
}
return transactionsSections;
}

return Object.keys(data)
.filter(isTransactionEntry)
.map((key) => {
const transactionItem = data[key];
const report = data[`${ONYXKEYS.COLLECTION.REPORT}${transactionItem.reportID}`];
const shouldShowBlankTo = isOpenExpenseReport(report);
const from = data.personalDetailsList?.[transactionItem.accountID];
const to = transactionItem.managerID && !shouldShowBlankTo ? data.personalDetailsList?.[transactionItem.managerID] : emptyPersonalDetails;
/**
* @private
* Retrieves all transactions associated with a specific report ID from the search data.

const {formattedFrom, formattedTo, formattedTotal, formattedMerchant, date} = getTransactionItemCommonFormattedProperties(transactionItem, from, to);
*/
function getTransactionsForReport(data: OnyxTypes.SearchResults['data'], reportID: string): SearchTransaction[] {
return Object.entries(data)
.filter(([key, value]) => isTransactionEntry(key) && (value as SearchTransaction)?.reportID === reportID)
.map(([, value]) => value as SearchTransaction);
}

return {
...transactionItem,
action: getAction(data, key),
from,
to,
formattedFrom,
formattedTo: shouldShowBlankTo ? '' : formattedTo,
formattedTotal,
formattedMerchant,
date,
shouldShowMerchant,
shouldShowCategory: metadata?.columnsToShow?.shouldShowCategoryColumn,
shouldShowTag: metadata?.columnsToShow?.shouldShowTagColumn,
shouldShowTax: metadata?.columnsToShow?.shouldShowTaxColumn,
keyForList: transactionItem.transactionID,
shouldShowYear: doesDataContainAPastYearTransaction,
};
});
/**
* @private
* Extracts all transaction violations from the search data.
*/
function getViolations(data: OnyxTypes.SearchResults['data']): OnyxCollection<OnyxTypes.TransactionViolation[]> {
return Object.fromEntries(Object.entries(data).filter(([key]) => isViolationEntry(key))) as OnyxCollection<OnyxTypes.TransactionViolation[]>;
}

/**
* @private
* Retrieves a report from the search data based on the provided key.
*/
function getReportFromKey(data: OnyxTypes.SearchResults['data'], key: string): OnyxTypes.Report | undefined {
if (isTransactionEntry(key)) {
const transaction = data[key];
return data[`${ONYXKEYS.COLLECTION.REPORT}${transaction?.reportID}`] as OnyxTypes.Report;
}
if (isReportEntry(key)) {
return data[key] as OnyxTypes.Report;
}
return undefined;
}

/**
* @private
* Retrieves the chat report associated with a given report.
*/
function getChatReport(data: OnyxTypes.SearchResults['data'], report: OnyxTypes.Report) {
return data[`${ONYXKEYS.COLLECTION.REPORT}${report?.chatReportID}`] ?? {};
}

/**
* @private
* Retrieves the policy associated with a given report.
*/
function getPolicyFromKey(data: OnyxTypes.SearchResults['data'], report: OnyxTypes.Report) {
return data[`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`] ?? {};
}

/**
* @private
* Retrieves the report name-value pairs associated with a given report.
*/
function getReportNameValuePairsFromKey(data: OnyxTypes.SearchResults['data'], report: OnyxTypes.Report) {
return data[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID}`] ?? undefined;
}

/**
* @private
* Determines the permission flags for a user reviewing a report.
*/
function getReviewerPermissionFlags(
report: OnyxTypes.Report,
policy: OnyxTypes.Policy,
): {
isSubmitter: boolean;
isAdmin: boolean;
isApprover: boolean;
} {
return {
isSubmitter: report.ownerAccountID === currentAccountID,
isAdmin: policy.role === CONST.POLICY.ROLE.ADMIN,
isApprover: report.managerID === currentAccountID,
};
}

/**
Expand All @@ -355,13 +479,11 @@ function getTransactionsSections(data: OnyxTypes.SearchResults['data'], metadata
*/
function getAction(data: OnyxTypes.SearchResults['data'], key: string): SearchTransactionAction {
const isTransaction = isTransactionEntry(key);
const report = getReportFromKey(data, key);

if (!isTransaction && !isReportEntry(key)) {
return CONST.SEARCH.ACTION_TYPES.VIEW;
}

const transaction = isTransaction ? data[key] : undefined;
const report = isTransaction ? data[`${ONYXKEYS.COLLECTION.REPORT}${transaction?.reportID}`] : data[key];

// Tracked and unreported expenses don't have a report, so we return early.
if (!report) {
return CONST.SEARCH.ACTION_TYPES.VIEW;
Expand All @@ -370,44 +492,39 @@ function getAction(data: OnyxTypes.SearchResults['data'], key: string): SearchTr
if (isSettled(report)) {
return CONST.SEARCH.ACTION_TYPES.PAID;
}

if (isClosedReport(report)) {
return CONST.SEARCH.ACTION_TYPES.DONE;
}

const transaction = isTransaction ? data[key] : undefined;
// We need to check both options for a falsy value since the transaction might not have an error but the report associated with it might. We return early if there are any errors for performance reasons, so we don't need to compute any other possible actions.
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
if (transaction?.errors || report?.errors) {
return CONST.SEARCH.ACTION_TYPES.REVIEW;
}

// We don't need to run the logic if this is not a transaction or iou/expense report, so let's shortcircuit the logic for performance reasons
if (!isMoneyRequestReport(report)) {
return CONST.SEARCH.ACTION_TYPES.VIEW;
}

const allReportTransactions = (
isReportEntry(key)
? Object.entries(data)
.filter(([itemKey, value]) => isTransactionEntry(itemKey) && (value as SearchTransaction)?.reportID === report.reportID)
.map((item) => item[1])
: [transaction]
) as SearchTransaction[];

const allViolations = Object.fromEntries(Object.entries(data).filter(([itemKey]) => isViolationEntry(itemKey))) as OnyxCollection<OnyxTypes.TransactionViolation[]>;
const policy = data[`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`] ?? {};
const isSubmitter = report.ownerAccountID === currentAccountID;
const isAdmin = policy.role === CONST.POLICY.ROLE.ADMIN;
const isApprover = report.managerID === currentAccountID;
const shouldShowReview = hasViolations(report.reportID, allViolations, undefined, allReportTransactions) && (isSubmitter || isApprover || isAdmin);
let allReportTransactions: SearchTransaction[];
if (isReportEntry(key)) {
allReportTransactions = getTransactionsForReport(data, report.reportID);
} else {
allReportTransactions = transaction ? [transaction] : [];
}
// Get violations - optimize by using a Map for faster lookups
const allViolations = getViolations(data);
const policy = getPolicyFromKey(data, report) as OnyxTypes.Policy;
const {isSubmitter, isAdmin, isApprover} = getReviewerPermissionFlags(report, policy);

if (shouldShowReview) {
// Only check for violations if we need to (when user has permission to review)
if ((isSubmitter || isApprover || isAdmin) && hasViolations(report.reportID, allViolations, undefined, allReportTransactions)) {
return CONST.SEARCH.ACTION_TYPES.REVIEW;
}

// Submit/Approve/Pay can only be taken on transactions if the transaction is the only one on the report, otherwise `View` is the only option.
// If this condition is not met, return early for performance reasons
if (isTransaction && !data[key].isFromOneTransactionReport) {
if (isTransaction && !transaction?.isFromOneTransactionReport) {
return CONST.SEARCH.ACTION_TYPES.VIEW;
}

Expand All @@ -416,20 +533,23 @@ function getAction(data: OnyxTypes.SearchResults['data'], key: string): SearchTr
? data[`${ONYXKEYS.COLLECTION.POLICY}${report?.invoiceReceiver?.policyID}`]
: undefined;

const chatReport = data[`${ONYXKEYS.COLLECTION.REPORT}${report?.chatReportID}`] ?? {};
const chatReportRNVP = data[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.chatReportID}`] ?? undefined;
const chatReport = getChatReport(data, report);
const chatReportRNVP = data[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report.chatReportID}`] ?? undefined;
const canBePaid = canIOUBePaid(report, chatReport, policy, allReportTransactions, false, chatReportRNVP, invoiceReceiverPolicy);

if (canIOUBePaid(report, chatReport, policy, allReportTransactions, false, chatReportRNVP, invoiceReceiverPolicy) && !hasOnlyHeldExpenses(report.reportID, allReportTransactions)) {
if (canBePaid && !hasOnlyHeldExpenses(report.reportID, allReportTransactions)) {
return CONST.SEARCH.ACTION_TYPES.PAY;
}

const hasOnlyPendingCardOrScanningTransactions = allReportTransactions.length > 0 && allReportTransactions.every(isPendingCardOrScanningTransaction);

const isAllowedToApproveExpenseReport = isAllowedToApproveExpenseReportUtils(report, undefined, policy);

if (canApproveIOU(report, policy) && isAllowedToApproveExpenseReport && !hasOnlyPendingCardOrScanningTransactions) {
return CONST.SEARCH.ACTION_TYPES.APPROVE;
}

const reportNVP = data[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID}`] ?? undefined;
const reportNVP = getReportNameValuePairsFromKey(data, report);
const isArchived = isArchivedReport(reportNVP);

// We check for isAllowedToApproveExpenseReport because if the policy has preventSelfApprovals enabled, we disable the Submit action and in that case we want to show the View action instead
Expand Down
4 changes: 0 additions & 4 deletions tests/unit/Search/SearchUIUtilsTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -389,7 +389,6 @@ const transactionsListItems = [
created: '2024-12-21',
currency: 'USD',
date: '2024-12-21',
description: '',
formattedFrom: 'Admin',
formattedMerchant: 'Expense',
formattedTo: '',
Expand All @@ -401,7 +400,6 @@ const transactionsListItems = [
login: adminEmail,
},
hasEReceipt: false,
hasViolation: false,
isFromOneTransactionReport: true,
keyForList: '1',
managerID: 18439984,
Expand Down Expand Up @@ -442,7 +440,6 @@ const transactionsListItems = [
created: '2024-12-21',
currency: 'USD',
date: '2024-12-21',
description: '',
formattedFrom: 'Admin',
formattedMerchant: 'Expense',
formattedTo: 'Admin',
Expand All @@ -454,7 +451,6 @@ const transactionsListItems = [
login: adminEmail,
},
hasEReceipt: false,
hasViolation: true,
isFromOneTransactionReport: true,
keyForList: '2',
managerID: 18439984,
Expand Down
Loading