diff --git a/cspell.json b/cspell.json index 43623b13c1356..c980641f202b4 100644 --- a/cspell.json +++ b/cspell.json @@ -239,6 +239,7 @@ "formatjs", "Français", "Frederico", + "freetext", "frontpart", "fullstory", "FWTV", diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 05a6e4d7d08f0..105a0e151ef5f 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -662,6 +662,7 @@ const CONST = { BETAS: { ALL: 'all', ASAP_SUBMIT: 'asapSubmit', + AUTH_AUTO_REPORT_TITLE: 'authAutoReportTitle', DEFAULT_ROOMS: 'defaultRooms', P2P_DISTANCE_REQUESTS: 'p2pDistanceRequests', SPOTNANA_TRAVEL: 'spotnanaTravel', @@ -1513,6 +1514,9 @@ const CONST = { APPLY_AIRSHIP_UPDATES: 'apply_airship_updates', APPLY_PUSHER_UPDATES: 'apply_pusher_updates', APPLY_HTTPS_UPDATES: 'apply_https_updates', + COMPUTE_REPORT_NAME: 'compute_report_name', + COMPUTE_REPORT_NAME_FOR_NEW_REPORT: 'compute_report_name_for_new_report', + UPDATE_OPTIMISTIC_REPORT_NAMES: 'update_optimistic_report_names', COLD: 'cold', WARM: 'warm', REPORT_ACTION_ITEM_LAYOUT_DEBOUNCE_TIME: 1500, diff --git a/src/libs/API/index.ts b/src/libs/API/index.ts index 50225e8c232ec..4157c20e04902 100644 --- a/src/libs/API/index.ts +++ b/src/libs/API/index.ts @@ -7,6 +7,8 @@ import Log from '@libs/Log'; import {handleDeletedAccount, HandleUnusedOptimisticID, Logging, Pagination, Reauthentication, RecheckConnection, SaveResponseInOnyx} from '@libs/Middleware'; import {isOffline} from '@libs/Network/NetworkStore'; import {push as pushToSequentialQueue, waitForIdle as waitForSequentialQueueIdle} from '@libs/Network/SequentialQueue'; +import * as OptimisticReportNames from '@libs/OptimisticReportNames'; +import {getUpdateContext, initialize as initializeOptimisticReportNamesContext} from '@libs/OptimisticReportNamesConnectionManager'; import Pusher from '@libs/Pusher'; import {processWithMiddleware, use} from '@libs/Request'; import {getAll, getLength as getPersistedRequestsLength} from '@userActions/PersistedRequests'; @@ -15,7 +17,7 @@ import type OnyxRequest from '@src/types/onyx/Request'; import type {PaginatedRequest, PaginationConfig, RequestConflictResolver} from '@src/types/onyx/Request'; import type Response from '@src/types/onyx/Response'; import type {ApiCommand, ApiRequestCommandParameters, ApiRequestType, CommandOfType, ReadCommand, SideEffectRequestCommand, WriteCommand} from './types'; -import {READ_COMMANDS} from './types'; +import {READ_COMMANDS, WRITE_COMMANDS} from './types'; // Setup API middlewares. Each request made will pass through a series of middleware functions that will get called in sequence (each one passing the result of the previous to the next). // Note: The ordering here is intentional as we want to Log, Recheck Connection, Reauthenticate, and Save the Response in Onyx. Errors thrown in one middleware will bubble to the next. @@ -42,6 +44,11 @@ use(Pagination); // middlewares after this, because the SequentialQueue depends on the result of this middleware to pause the queue (if needed) to bring the app to an up-to-date state. use(SaveResponseInOnyx); +// Initialize OptimisticReportNames context on module load +initializeOptimisticReportNamesContext().catch(() => { + Log.warn('Failed to initialize OptimisticReportNames context'); +}); + let requestIndex = 0; type OnyxData = { @@ -74,7 +81,22 @@ function prepareRequest( const {optimisticData, ...onyxDataWithoutOptimisticData} = onyxData; if (optimisticData && shouldApplyOptimisticData) { Log.info('[API] Applying optimistic data', false, {command, type}); - Onyx.update(optimisticData); + + // Process optimistic data through report name middleware + // Skip for OpenReport command to avoid unnecessary processing + if (command === WRITE_COMMANDS.OPEN_REPORT) { + Onyx.update(optimisticData); + } else { + try { + const context = getUpdateContext(); + const processedOptimisticData = OptimisticReportNames.updateOptimisticReportNamesFromUpdates(optimisticData, context); + Onyx.update(processedOptimisticData); + } catch (error) { + Log.warn('[API] Failed to process optimistic report names', {error}); + // Fallback to original optimistic data if processing fails + Onyx.update(optimisticData); + } + } } const isWriteRequest = type === CONST.API_REQUEST_TYPE.WRITE; diff --git a/src/libs/Formula.ts b/src/libs/Formula.ts new file mode 100644 index 0000000000000..e00efa0e597ba --- /dev/null +++ b/src/libs/Formula.ts @@ -0,0 +1,502 @@ +import type {OnyxEntry} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; +import CONST from '@src/CONST'; +import type Policy from '@src/types/onyx/Policy'; +import type Report from '@src/types/onyx/Report'; +import {getCurrencySymbol} from './CurrencyUtils'; +import {getAllReportActions} from './ReportActionsUtils'; +import {getReportTransactions} from './ReportUtils'; +import {getCreated, isPartialTransaction} from './TransactionUtils'; + +type FormulaPart = { + /** The original definition from the formula */ + definition: string; + + /** The type of formula part (report, field, user, etc.) */ + type: ValueOf; + + /** The field path for accessing data (e.g., ['type'], ['startdate'], ['total']) */ + fieldPath: string[]; + + /** Functions to apply to the computed value (e.g., ['frontPart']) */ + functions: string[]; +}; + +type FormulaContext = { + report: Report; + policy: OnyxEntry; +}; + +const FORMULA_PART_TYPES = { + REPORT: 'report', + FIELD: 'field', + USER: 'user', + FREETEXT: 'freetext', +} as const; + +/** + * Extract formula parts from a formula string, handling nested braces and escapes + * Based on OldDot Formula.extract method + */ +function extract(formula: string, opener = '{', closer = '}'): string[] { + if (!formula || typeof formula !== 'string') { + return []; + } + + const letters = formula.split(''); + const sections: string[] = []; + let nesting = 0; + let start = 0; + + for (let i = 0; i < letters.length; i++) { + // Found an escape character, skip the next character + if (letters.at(i) === '\\') { + i++; + continue; + } + + // Found an opener, save the spot + if (letters.at(i) === opener) { + if (nesting === 0) { + start = i; + } + nesting++; + } + + // Found a closer, decrement the nesting and possibly extract it + if (letters.at(i) === closer && nesting > 0) { + nesting--; + if (nesting === 0) { + sections.push(formula.substring(start, i + 1)); + } + } + } + + return sections; +} + +/** + * Parse a formula string into an array of formula parts + * Based on OldDot Formula.parse method + */ +function parse(formula: string): FormulaPart[] { + if (!formula || typeof formula !== 'string') { + return []; + } + + const parts: FormulaPart[] = []; + const formulaParts = extract(formula); + + // If no formula parts found, treat the entire string as free text + if (formulaParts.length === 0) { + if (formula.trim()) { + parts.push({ + definition: formula, + type: FORMULA_PART_TYPES.FREETEXT, + fieldPath: [], + functions: [], + }); + } + return parts; + } + + // Process the formula by splitting on formula parts to preserve free text + let lastIndex = 0; + + formulaParts.forEach((part) => { + const partIndex = formula.indexOf(part, lastIndex); + + // Add any free text before this formula part + if (partIndex > lastIndex) { + const freeText = formula.substring(lastIndex, partIndex); + if (freeText) { + parts.push({ + definition: freeText, + type: FORMULA_PART_TYPES.FREETEXT, + fieldPath: [], + functions: [], + }); + } + } + + // Add the formula part + parts.push(parsePart(part)); + lastIndex = partIndex + part.length; + }); + + // Add any remaining free text after the last formula part + if (lastIndex < formula.length) { + const freeText = formula.substring(lastIndex); + if (freeText) { + parts.push({ + definition: freeText, + type: FORMULA_PART_TYPES.FREETEXT, + fieldPath: [], + functions: [], + }); + } + } + + return parts; +} + +/** + * Parse a single formula part definition into a FormulaPart object + * Based on OldDot Formula.parsePart method + */ +function parsePart(definition: string): FormulaPart { + const part: FormulaPart = { + definition, + type: FORMULA_PART_TYPES.FREETEXT, + fieldPath: [], + functions: [], + }; + + // If it doesn't start and end with braces, it's free text + if (!definition.startsWith('{') || !definition.endsWith('}')) { + return part; + } + + // Remove the braces and trim + const cleanDefinition = definition.slice(1, -1).trim(); + if (!cleanDefinition) { + return part; + } + + // Split on | to separate functions + const segments = cleanDefinition.split('|'); + const fieldSegment = segments.at(0); + const functions = segments.slice(1); + + // Split the field segment on : to get the field path + const fieldPath = fieldSegment?.split(':'); + const type = fieldPath?.at(0)?.toLowerCase(); + + // Determine the formula part type + if (type === 'report') { + part.type = FORMULA_PART_TYPES.REPORT; + } else if (type === 'field') { + part.type = FORMULA_PART_TYPES.FIELD; + } else if (type === 'user') { + part.type = FORMULA_PART_TYPES.USER; + } + + // Set field path (excluding the type) + part.fieldPath = fieldPath?.slice(1) ?? []; + part.functions = functions; + + return part; +} + +/** + * Compute the value of a formula given a context + */ +function compute(formula: string, context: FormulaContext): string { + if (!formula || typeof formula !== 'string') { + return ''; + } + + const parts = parse(formula); + let result = ''; + + for (const part of parts) { + let value = ''; + + switch (part.type) { + case FORMULA_PART_TYPES.REPORT: + value = computeReportPart(part, context); + value = value === '' ? part.definition : value; + break; + case FORMULA_PART_TYPES.FIELD: + value = computeFieldPart(part); + break; + case FORMULA_PART_TYPES.USER: + value = computeUserPart(part); + break; + case FORMULA_PART_TYPES.FREETEXT: + value = part.definition; + break; + default: + // If we don't recognize the part type, use the original definition + value = part.definition; + } + + // Apply any functions to the computed value + value = applyFunctions(value, part.functions); + result += value; + } + + return result; +} + +/** + * Compute the value of a report formula part + */ +function computeReportPart(part: FormulaPart, context: FormulaContext): string { + const {report, policy} = context; + const [field, format] = part.fieldPath; + + if (!field) { + return part.definition; + } + + switch (field.toLowerCase()) { + case 'type': + return formatType(report.type); + case 'startdate': + return formatDate(getOldestTransactionDate(report.reportID), format); + case 'total': + return formatAmount(report.total, getCurrencySymbol(report.currency ?? '') ?? report.currency); + case 'currency': + return report.currency ?? ''; + case 'policyname': + case 'workspacename': + return policy?.name ?? ''; + case 'created': + // Backend will always return at least one report action (of type created) and its date is equal to report's creation date + // We can make it slightly more efficient in the future by ensuring report.created is always present in backend's responses + return formatDate(getOldestReportActionDate(report.reportID), format); + default: + return part.definition; + } +} + +/** + * Compute the value of a field formula part + */ +function computeFieldPart(part: FormulaPart): string { + // Field computation will be implemented later + return part.definition; +} + +/** + * Compute the value of a user formula part + */ +function computeUserPart(part: FormulaPart): string { + // User computation will be implemented later + return part.definition; +} + +/** + * Apply functions to a computed value + */ +function applyFunctions(value: string, functions: string[]): string { + let result = value; + + for (const func of functions) { + const [functionName, ...args] = func.split(':'); + + switch (functionName.toLowerCase()) { + case 'frontpart': + result = getFrontPart(result); + break; + case 'substr': + result = getSubstring(result, args); + break; + case 'domain': + result = getDomainName(result); + break; + default: + // Unknown function, leave value as is + break; + } + } + + return result; +} + +/** + * Get the front part of an email or first word of a string + */ +function getFrontPart(value: string): string { + const trimmed = value.trim(); + + // If it's an email, return the part before @ + if (trimmed.includes('@')) { + return trimmed.split('@').at(0) ?? ''; + } + + // Otherwise, return the first word + return trimmed.split(' ').at(0) ?? ''; +} + +/** + * Get the domain name of an email or URL + */ +function getDomainName(value: string): string { + const trimmed = value.trim(); + + // If it's an email, return the part after @ + if (trimmed.includes('@')) { + return trimmed.split('@').at(1) ?? ''; + } + + return ''; +} + +/** + * Get substring of a value + */ +function getSubstring(value: string, args: string[]): string { + const start = parseInt(args.at(0) ?? '', 10) || 0; + const length = args.at(1) ? parseInt(args.at(1) ?? '', 10) : undefined; + + if (length !== undefined) { + return value.substring(start, start + length); + } + + return value.substring(start); +} + +/** + * Format a date value with support for multiple date formats + */ +function formatDate(dateString: string | undefined, format = 'yyyy-MM-dd'): string { + if (!dateString) { + return ''; + } + + try { + const date = new Date(dateString); + if (Number.isNaN(date.getTime())) { + return ''; + } + + const year = date.getFullYear(); + const month = date.getMonth() + 1; + const day = date.getDate(); + const monthNames = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']; + const shortMonthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + + switch (format) { + case 'M/dd/yyyy': + return `${month}/${day.toString().padStart(2, '0')}/${year}`; + case 'MMMM dd, yyyy': + return `${monthNames.at(month - 1)} ${day.toString().padStart(2, '0')}, ${year}`; + case 'dd MMM yyyy': + return `${day.toString().padStart(2, '0')} ${shortMonthNames.at(month - 1)} ${year}`; + case 'yyyy/MM/dd': + return `${year}/${month.toString().padStart(2, '0')}/${day.toString().padStart(2, '0')}`; + case 'MMMM, yyyy': + return `${monthNames.at(month - 1)}, ${year}`; + case 'yy/MM/dd': + return `${year.toString().slice(-2)}/${month.toString().padStart(2, '0')}/${day.toString().padStart(2, '0')}`; + case 'dd/MM/yy': + return `${day.toString().padStart(2, '0')}/${month.toString().padStart(2, '0')}/${year.toString().slice(-2)}`; + case 'yyyy': + return year.toString(); + case 'MM/dd/yyyy': + return `${month.toString().padStart(2, '0')}/${day.toString().padStart(2, '0')}/${year}`; + case 'yyyy-MM-dd': + default: + return `${year}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}`; + } + } catch { + return ''; + } +} + +/** + * Format an amount value + */ +function formatAmount(amount: number | undefined, currency: string | undefined): string { + if (amount === undefined) { + return ''; + } + + const absoluteAmount = Math.abs(amount); + const formattedAmount = (absoluteAmount / 100).toFixed(2); + + if (currency) { + return `${currency}${formattedAmount}`; + } + + return formattedAmount; +} + +/** + * Get the date of the oldest report action for a given report + */ +function getOldestReportActionDate(reportID: string): string | undefined { + if (!reportID) { + return undefined; + } + + const reportActions = getAllReportActions(reportID); + if (!reportActions || Object.keys(reportActions).length === 0) { + return undefined; + } + + let oldestDate: string | undefined; + + Object.values(reportActions).forEach((action) => { + if (!action?.created) { + return; + } + + if (oldestDate && action.created > oldestDate) { + return; + } + oldestDate = action.created; + }); + + return oldestDate; +} + +/** + * Format a report type to its human-readable string + */ +function formatType(type: string | undefined): string { + if (!type) { + return ''; + } + + const typeMapping: Record = { + [CONST.REPORT.TYPE.EXPENSE]: 'Expense Report', + [CONST.REPORT.TYPE.INVOICE]: 'Invoice', + [CONST.REPORT.TYPE.CHAT]: 'Chat', + [CONST.REPORT.UNSUPPORTED_TYPE.BILL]: 'Bill', + [CONST.REPORT.UNSUPPORTED_TYPE.PAYCHECK]: 'Paycheck', + [CONST.REPORT.TYPE.IOU]: 'IOU', + [CONST.REPORT.TYPE.TASK]: 'Task', + trip: 'Trip', + }; + + return typeMapping[type.toLowerCase()] || type; +} + +/** + * Get the date of the oldest transaction for a given report + */ +function getOldestTransactionDate(reportID: string): string | undefined { + if (!reportID) { + return undefined; + } + + const transactions = getReportTransactions(reportID); + if (!transactions || transactions.length === 0) { + return new Date().toISOString(); + } + + let oldestDate: string | undefined; + + transactions.forEach((transaction) => { + const created = getCreated(transaction); + if (!created) { + return; + } + if (oldestDate && created >= oldestDate) { + return; + } + if (isPartialTransaction(transaction)) { + return; + } + oldestDate = created; + }); + + return oldestDate; +} + +export {FORMULA_PART_TYPES, compute, extract, parse}; + +export type {FormulaContext, FormulaPart}; diff --git a/src/libs/OptimisticReportNames.ts b/src/libs/OptimisticReportNames.ts new file mode 100644 index 0000000000000..8f4e48f984168 --- /dev/null +++ b/src/libs/OptimisticReportNames.ts @@ -0,0 +1,330 @@ +import type {OnyxUpdate} from 'react-native-onyx'; +import Onyx from 'react-native-onyx'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {OnyxKey} from '@src/ONYXKEYS'; +import type Policy from '@src/types/onyx/Policy'; +import type Report from '@src/types/onyx/Report'; +import Timing from './actions/Timing'; +import {compute, FORMULA_PART_TYPES, parse} from './Formula'; +import type {FormulaContext} from './Formula'; +import Log from './Log'; +import {getUpdateContextAsync} from './OptimisticReportNamesConnectionManager'; +import type {UpdateContext} from './OptimisticReportNamesConnectionManager'; +import Performance from './Performance'; +import Permissions from './Permissions'; +import {getTitleReportField, isArchivedReport} from './ReportUtils'; + +/** + * Get the object type from an Onyx key + */ +function determineObjectTypeByKey(key: string): 'report' | 'policy' | 'unknown' { + if (key.startsWith(ONYXKEYS.COLLECTION.REPORT)) { + return 'report'; + } + if (key.startsWith(ONYXKEYS.COLLECTION.POLICY)) { + return 'policy'; + } + return 'unknown'; +} + +/** + * Extract report ID from an Onyx key + */ +function getReportIDFromKey(key: string): string { + return key.replace(ONYXKEYS.COLLECTION.REPORT, ''); +} + +/** + * Extract policy ID from an Onyx key + */ +function getPolicyIDFromKey(key: string): string { + return key.replace(ONYXKEYS.COLLECTION.POLICY, ''); +} + +/** + * Extract transaction ID from an Onyx key + */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars -- this will be used in near future +function getTransactionIDFromKey(key: string): string { + return key.replace(ONYXKEYS.COLLECTION.TRANSACTION, ''); +} + +/** + * Get report by ID from the reports collection + */ +function getReportByID(reportID: string, allReports: Record): Report | undefined { + return allReports[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; +} + +/** + * Get policy by ID from the policies collection + */ +function getPolicyByID(policyID: string | undefined, allPolicies: Record): Policy | undefined { + if (!policyID) { + return; + } + return allPolicies[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; +} + +/** + * Get all reports associated with a policy ID + */ +function getReportsByPolicyID(policyID: string, allReports: Record, context: UpdateContext): Report[] { + if (policyID === CONST.POLICY.ID_FAKE) { + return []; + } + return Object.values(allReports).filter((report) => { + if (report?.policyID !== policyID) { + return false; + } + + // Filter by type - only reports that support custom names + if (!isValidReportType(report.type)) { + return false; + } + + // Filter by state - exclude reports in high states (like approved or higher) + const stateThreshold = CONST.REPORT.STATE_NUM.APPROVED; + if (report.stateNum && report.stateNum > stateThreshold) { + return false; + } + + // Filter by isArchived - exclude archived reports + const reportNameValuePairs = context.allReportNameValuePairs[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID}`]; + if (isArchivedReport(reportNameValuePairs)) { + return false; + } + + return true; + }); +} + +/** + * Generate the Onyx key for a report + */ +function getReportKey(reportID: string): OnyxKey { + return `${ONYXKEYS.COLLECTION.REPORT}${reportID}` as OnyxKey; +} + +/** + * Check if a report should have its name automatically computed + */ +function shouldComputeReportName(report: Report, policy: Policy | undefined): boolean { + // Only compute names for expense reports with policies that have title fields + if (!report || !policy) { + return false; + } + + // Check if the report is an expense report + if (!isValidReportType(report.type)) { + return false; + } + + // Check if the policy has a title field with a formula + const titleField = getTitleReportField(policy.fieldList ?? {}); + if (!titleField?.defaultValue) { + return false; + } + return true; +} + +function isValidReportType(reportType?: string): boolean { + if (!reportType) { + return false; + } + return ( + reportType === CONST.REPORT.TYPE.EXPENSE || + reportType === CONST.REPORT.TYPE.INVOICE || + reportType === CONST.REPORT.UNSUPPORTED_TYPE.BILL || + reportType === CONST.REPORT.UNSUPPORTED_TYPE.PAYCHECK || + reportType === 'trip' + ); +} + +/** + * Compute a new report name if needed based on an optimistic update + */ +function computeReportNameIfNeeded(report: Report | undefined, incomingUpdate: OnyxUpdate, context: UpdateContext): string | null { + Performance.markStart(CONST.TIMING.COMPUTE_REPORT_NAME); + Timing.start(CONST.TIMING.COMPUTE_REPORT_NAME); + + const {allPolicies} = context; + + // If no report is provided, extract it from the update (for new reports) + const targetReport = report ?? (incomingUpdate.value as Report); + + if (!targetReport) { + Performance.markEnd(CONST.TIMING.COMPUTE_REPORT_NAME); + Timing.end(CONST.TIMING.COMPUTE_REPORT_NAME); + return null; + } + + const policy = getPolicyByID(targetReport.policyID, allPolicies); + + if (!shouldComputeReportName(targetReport, policy)) { + Performance.markEnd(CONST.TIMING.COMPUTE_REPORT_NAME); + Timing.end(CONST.TIMING.COMPUTE_REPORT_NAME); + return null; + } + + const titleField = getTitleReportField(policy?.fieldList ?? {}); + if (!titleField?.defaultValue) { + Performance.markEnd(CONST.TIMING.COMPUTE_REPORT_NAME); + Timing.end(CONST.TIMING.COMPUTE_REPORT_NAME); + return null; + } + + // Quick check: see if the update might affect the report name + const updateType = determineObjectTypeByKey(incomingUpdate.key); + const formula = titleField.defaultValue; + const formulaParts = parse(formula); + + // Check if any formula part might be affected by this update + const isAffected = formulaParts.some((part) => { + if (part.type === FORMULA_PART_TYPES.REPORT) { + // Checking if the formula part is affected in this manner works, but it could certainly be more precise. + // For example, a policy update only affects the part if the formula in the policy changed, or if the report part references a field on the policy. + // However, if we run into performance problems, this would be a good place to optimize. + return updateType === 'report' || updateType === 'policy'; + } + if (part.type === FORMULA_PART_TYPES.FIELD) { + return updateType === 'report'; + } + return false; + }); + + if (!isAffected) { + Performance.markEnd(CONST.TIMING.COMPUTE_REPORT_NAME); + Timing.end(CONST.TIMING.COMPUTE_REPORT_NAME); + return null; + } + + // Build context with the updated data + const updatedReport = + updateType === 'report' && targetReport.reportID === getReportIDFromKey(incomingUpdate.key) ? {...targetReport, ...(incomingUpdate.value as Partial)} : targetReport; + + const updatedPolicy = updateType === 'policy' && targetReport.policyID === getPolicyIDFromKey(incomingUpdate.key) ? {...(policy ?? {}), ...(incomingUpdate.value as Policy)} : policy; + + // Compute the new name + const formulaContext: FormulaContext = { + report: updatedReport, + policy: updatedPolicy, + }; + + const newName = compute(formula, formulaContext); + + // Only return an update if the name actually changed + if (newName && newName !== targetReport.reportName) { + Log.info('[OptimisticReportNames] Report name computed', false, { + reportID: targetReport.reportID, + oldName: targetReport.reportName, + newName, + formula, + updateType, + isNewReport: !report, + }); + + Performance.markEnd(CONST.TIMING.COMPUTE_REPORT_NAME); + Timing.end(CONST.TIMING.COMPUTE_REPORT_NAME); + return newName; + } + + Performance.markEnd(CONST.TIMING.COMPUTE_REPORT_NAME); + Timing.end(CONST.TIMING.COMPUTE_REPORT_NAME); + return null; +} + +/** + * Update optimistic report names based on incoming updates + * This is the main middleware function that processes optimistic data + */ +function updateOptimisticReportNamesFromUpdates(updates: OnyxUpdate[], context: UpdateContext): OnyxUpdate[] { + Performance.markStart(CONST.TIMING.UPDATE_OPTIMISTIC_REPORT_NAMES); + Timing.start(CONST.TIMING.UPDATE_OPTIMISTIC_REPORT_NAMES); + + const {betas, allReports} = context; + + // Check if the feature is enabled + if (!Permissions.canUseCustomReportNames(betas)) { + Performance.markEnd(CONST.TIMING.UPDATE_OPTIMISTIC_REPORT_NAMES); + Timing.end(CONST.TIMING.UPDATE_OPTIMISTIC_REPORT_NAMES); + return updates; + } + + Log.info('[OptimisticReportNames] Processing optimistic updates for report names', false, { + updatesCount: updates.length, + }); + + const additionalUpdates: OnyxUpdate[] = []; + + for (const update of updates) { + const objectType = determineObjectTypeByKey(update.key); + let affectedReports: Report[] = []; + + switch (objectType) { + case 'report': { + const reportID = getReportIDFromKey(update.key); + const report = getReportByID(reportID, allReports); + + // Handle both existing and new reports with the same function + const reportNameUpdate = computeReportNameIfNeeded(report, update, context); + + if (reportNameUpdate) { + additionalUpdates.push({ + key: getReportKey(reportID), + onyxMethod: Onyx.METHOD.MERGE, + value: { + reportName: reportNameUpdate, + }, + }); + } + break; + } + + case 'policy': { + const policyID = getPolicyIDFromKey(update.key); + affectedReports = getReportsByPolicyID(policyID, allReports, context); + break; + } + + default: + continue; + } + + for (const report of affectedReports) { + const reportNameUpdate = computeReportNameIfNeeded(report, update, context); + + if (reportNameUpdate) { + additionalUpdates.push({ + key: getReportKey(report.reportID), + onyxMethod: Onyx.METHOD.MERGE, + value: { + reportName: reportNameUpdate, + }, + }); + } + } + } + + Log.info('[OptimisticReportNames] Processing completed', false, { + additionalUpdatesCount: additionalUpdates.length, + totalUpdatesReturned: updates.length + additionalUpdates.length, + }); + + Performance.markEnd(CONST.TIMING.UPDATE_OPTIMISTIC_REPORT_NAMES); + Timing.end(CONST.TIMING.UPDATE_OPTIMISTIC_REPORT_NAMES); + + return updates.concat(additionalUpdates); +} + +/** + * Creates update context for optimistic report name processing. + * This should be called before processing optimistic updates + */ +function createUpdateContext(): Promise { + return getUpdateContextAsync(); +} + +export {updateOptimisticReportNamesFromUpdates, computeReportNameIfNeeded, createUpdateContext, shouldComputeReportName}; +export type {UpdateContext}; diff --git a/src/libs/OptimisticReportNamesConnectionManager.ts b/src/libs/OptimisticReportNamesConnectionManager.ts new file mode 100644 index 0000000000000..fe71791edce54 --- /dev/null +++ b/src/libs/OptimisticReportNamesConnectionManager.ts @@ -0,0 +1,123 @@ +import type {OnyxEntry} from 'react-native-onyx'; +import Onyx from 'react-native-onyx'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type Beta from '@src/types/onyx/Beta'; +import type Policy from '@src/types/onyx/Policy'; +import type Report from '@src/types/onyx/Report'; +import type ReportNameValuePairs from '@src/types/onyx/ReportNameValuePairs'; + +type UpdateContext = { + betas: OnyxEntry; + allReports: Record; + allPolicies: Record; + allReportNameValuePairs: Record; +}; + +let betas: OnyxEntry; +let allReports: Record; +let allPolicies: Record; +let allReportNameValuePairs: Record; +let isInitialized = false; +let connectionsInitializedCount = 0; +const totalConnections = 4; +let initializationPromise: Promise | null = null; + +/** + * Initialize persistent connections to Onyx data needed for OptimisticReportNames + * This is called lazily when OptimisticReportNames functionality is first used + * Returns a Promise that resolves when all connections have received their initial data + * + * We use Onyx.connectWithoutView because we do not use this in React components and this logic is not tied to the UI. + * This is a centralized system that needs access to all objects of several types, so that when any updates affect + * the computed report names, we can compute the new names according to the formula and add the necessary updates. + * It wouldn't be possible to do this without connecting to all the data. + * + */ +function initialize(): Promise { + if (isInitialized) { + return Promise.resolve(); + } + + if (initializationPromise) { + return initializationPromise; + } + + initializationPromise = new Promise((resolve) => { + const checkAndMarkInitialized = () => { + connectionsInitializedCount++; + if (connectionsInitializedCount === totalConnections) { + isInitialized = true; + resolve(); + } + }; + + // Connect to BETAS + Onyx.connectWithoutView({ + key: ONYXKEYS.BETAS, + callback: (val) => { + betas = val; + checkAndMarkInitialized(); + }, + }); + + // Connect to all REPORTS + Onyx.connectWithoutView({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (val) => { + allReports = (val as Record) ?? {}; + checkAndMarkInitialized(); + }, + }); + + // Connect to all POLICIES + Onyx.connectWithoutView({ + key: ONYXKEYS.COLLECTION.POLICY, + waitForCollectionCallback: true, + callback: (val) => { + allPolicies = (val as Record) ?? {}; + checkAndMarkInitialized(); + }, + }); + + // Connect to all REPORT_NAME_VALUE_PAIRS + Onyx.connectWithoutView({ + key: ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, + waitForCollectionCallback: true, + callback: (val) => { + allReportNameValuePairs = (val as Record) ?? {}; + checkAndMarkInitialized(); + }, + }); + }); + + return initializationPromise; +} + +/** + * Get the current update context synchronously + * Must be called after initialize() has completed + */ +function getUpdateContext(): UpdateContext { + if (!isInitialized) { + throw new Error('OptimisticReportNamesConnectionManager not initialized. Call initialize() first.'); + } + + return { + betas, + allReports: allReports ?? {}, + allPolicies: allPolicies ?? {}, + allReportNameValuePairs: allReportNameValuePairs ?? {}, + }; +} + +/** + * Get the current update context as a promise for backward compatibility + * Initializes connections lazily on first use + */ +function getUpdateContextAsync(): Promise { + return initialize().then(() => getUpdateContext()); +} + +export {initialize, getUpdateContext, getUpdateContextAsync}; +export type {UpdateContext}; diff --git a/src/libs/Permissions.ts b/src/libs/Permissions.ts index 408df3326275e..cc36adff7ec93 100644 --- a/src/libs/Permissions.ts +++ b/src/libs/Permissions.ts @@ -7,6 +7,11 @@ function canUseAllBetas(betas: OnyxEntry): boolean { return !!betas?.includes(CONST.BETAS.ALL); } +// eslint-disable-next-line rulesdir/no-beta-handler +function canUseCustomReportNames(betas: OnyxEntry): boolean { + return isBetaEnabled(CONST.BETAS.AUTH_AUTO_REPORT_TITLE, betas); +} + // eslint-disable-next-line rulesdir/no-beta-handler function isBlockedFromSpotnanaTravel(betas: OnyxEntry): boolean { // Don't check for all betas or nobody can use test travel on dev @@ -30,6 +35,7 @@ function isBetaEnabled(beta: Beta, betas: OnyxEntry): boolean { } export default { + canUseCustomReportNames, canUseLinkPreviews, isBlockedFromSpotnanaTravel, isBetaEnabled, diff --git a/tests/perf-test/Formula.perf-test.ts b/tests/perf-test/Formula.perf-test.ts new file mode 100644 index 0000000000000..dfe574172d326 --- /dev/null +++ b/tests/perf-test/Formula.perf-test.ts @@ -0,0 +1,143 @@ +import {measureFunction} from 'reassure'; +import {compute, extract, parse} from '@libs/Formula'; +import type {FormulaContext} from '@libs/Formula'; +import type {Policy, Report} from '@src/types/onyx'; + +describe('[CustomFormula] Performance Tests', () => { + const mockReport = { + reportID: '123', + reportName: 'Test Report', + total: -10000, + currency: 'USD', + lastVisibleActionCreated: '2025-01-15T10:30:00Z', + policyID: 'policy1', + } as Report; + + const mockPolicy = { + name: 'Test Policy', + id: 'policy1', + } as Policy; + + const mockContext: FormulaContext = { + report: mockReport, + policy: mockPolicy, + }; + + describe('Formula Parsing Performance', () => { + test('[CustomFormula] extract() with simple formula', async () => { + const formula = '{report:type} - {report:total}'; + await measureFunction(() => extract(formula)); + }); + + test('[CustomFormula] extract() with complex formula', async () => { + const formula = '{report:type} - {report:startdate} - {report:total} - {report:currency} - {report:policyname} - {user:email|frontPart}'; + await measureFunction(() => extract(formula)); + }); + + test('[CustomFormula] extract() with nested braces', async () => { + const formula = '{report:{report:submit:from:firstName|substr:2}}'; + await measureFunction(() => extract(formula)); + }); + + test('[CustomFormula] parse() with simple formula', async () => { + const formula = '{report:type} - {report:total}'; + await measureFunction(() => parse(formula)); + }); + + test('[CustomFormula] parse() with complex formula', async () => { + const formula = '{report:type} - {report:startdate} - {report:total} - {report:currency} - {report:policyname} - {field:department} - {user:email|frontPart}'; + await measureFunction(() => parse(formula)); + }); + + test('[CustomFormula] parse() with mixed content', async () => { + const formula = 'Expense Report for {report:policyname} on {report:startdate} totaling {report:total} {report:currency} submitted by {user:email|frontPart}'; + await measureFunction(() => parse(formula)); + }); + }); + + describe('Formula Computation Performance', () => { + test('[CustomFormula] compute() with simple formula', async () => { + const formula = '{report:type} - {report:total}'; + await measureFunction(() => compute(formula, mockContext)); + }); + + test('[CustomFormula] compute() with complex formula', async () => { + const formula = '{report:type} - {report:startdate} - {report:total} - {report:currency} - {report:policyname}'; + await measureFunction(() => compute(formula, mockContext)); + }); + + test('[CustomFormula] compute() with mixed content', async () => { + const formula = 'Expense Report for {report:policyname} on {report:startdate} totaling {report:total} {report:currency}'; + await measureFunction(() => compute(formula, mockContext)); + }); + + test('[CustomFormula] compute() with missing data context', async () => { + const formula = '{report:type} - {report:total} - {report:unknown} - {report:policyname}'; + const contextWithMissingData: FormulaContext = { + report: {} as Report, + policy: null as unknown as Policy, + }; + await measureFunction(() => compute(formula, contextWithMissingData)); + }); + }); + + describe('Batch Processing Performance', () => { + test('[CustomFormula] parse() batch processing 100 formulas', async () => { + const formulas = Array.from({length: 100}, (_, i) => `{report:type} ${i} - {report:startdate} - {report:total} - {report:currency}`); + + await measureFunction(() => { + formulas.forEach((formula) => parse(formula)); + }); + }); + + test('[CustomFormula] compute() batch processing 100 computations', async () => { + const formulas = Array.from({length: 100}, (_, i) => `{report:type} ${i} - {report:total} - {report:policyname}`); + + await measureFunction(() => { + formulas.forEach((formula) => compute(formula, mockContext)); + }); + }); + + test('[CustomFormula] end-to-end batch processing (parse + compute)', async () => { + const formulas = Array.from({length: 50}, (_, i) => `Expense ${i}: {report:type} - {report:startdate} - {report:total} {report:currency} for {report:policyname}`); + + await measureFunction(() => { + formulas.forEach((formula) => { + const parts = parse(formula); + const result = compute(formula, mockContext); + return {parts, result}; + }); + }); + }); + }); + + describe('Edge Cases Performance', () => { + test('[CustomFormula] large formula with many parts', async () => { + const largeParts = Array.from({length: 20}, (_, i) => `{report:field${i}}`).join(' - '); + const formula = `Large Formula: ${largeParts}`; + + await measureFunction(() => { + parse(formula); + compute(formula, mockContext); + }); + }); + + test('[CustomFormula] deeply nested functions', async () => { + const formula = '{user:email|frontPart} - {field:description|substr:0:20} - {report:created:yyyy-MM-dd}'; + + await measureFunction(() => { + parse(formula); + compute(formula, mockContext); + }); + }); + + test('[CustomFormula] formula with escaped braces', async () => { + const formula = '\\{not-formula} {report:type} \\{escaped} {report:total} \\{more-escapes}'; + + await measureFunction(() => { + parse(formula); + compute(formula, mockContext); + }); + }); + }); +}); diff --git a/tests/perf-test/OptimisticReportNames.perf-test.ts b/tests/perf-test/OptimisticReportNames.perf-test.ts new file mode 100644 index 0000000000000..4c6516ca19b73 --- /dev/null +++ b/tests/perf-test/OptimisticReportNames.perf-test.ts @@ -0,0 +1,274 @@ +import Onyx from 'react-native-onyx'; +import {measureFunction} from 'reassure'; +import type {UpdateContext} from '@libs/OptimisticReportNames'; +import {computeReportNameIfNeeded, updateOptimisticReportNamesFromUpdates} from '@libs/OptimisticReportNames'; +// eslint-disable-next-line no-restricted-syntax -- disabled because we need ReportUtils to mock +import * as ReportUtils from '@libs/ReportUtils'; +import type {OnyxKey} from '@src/ONYXKEYS'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Policy, Report} from '@src/types/onyx'; +import createCollection from '../utils/collections/createCollection'; +import {createRandomReport} from '../utils/collections/reports'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +// Mock dependencies +jest.mock('@libs/ReportUtils'); +jest.mock('@libs/Performance', () => ({ + markStart: jest.fn(), + markEnd: jest.fn(), +})); +jest.mock('@libs/actions/Timing', () => ({ + start: jest.fn(), + end: jest.fn(), +})); +jest.mock('@libs/Log', () => ({ + info: jest.fn(), +})); + +const mockReportUtils = ReportUtils as jest.Mocked; + +describe('[OptimisticReportNames] Performance Tests', () => { + const REPORTS_COUNT = 1000; + const POLICIES_COUNT = 100; + + const mockPolicy = { + id: 'policy1', + name: 'Test Policy', + fieldList: { + // eslint-disable-next-line @typescript-eslint/naming-convention + text_title: { + defaultValue: '{report:type} - {report:startdate} - {report:total} {report:currency}', + }, + }, + } as unknown as Policy; + + const mockPolicies = createCollection( + (item) => `policy_${item.id}`, + (index) => ({ + ...mockPolicy, + id: `policy${index}`, + name: `Policy ${index}`, + }), + POLICIES_COUNT, + ); + + const mockReports = createCollection( + (item) => `${ONYXKEYS.COLLECTION.REPORT}${item.reportID}`, + (index) => ({ + ...createRandomReport(index), + policyID: `policy${index % POLICIES_COUNT}`, + total: -(Math.random() * 100000), // Random negative amount + currency: 'USD', + lastVisibleActionCreated: new Date().toISOString(), + }), + REPORTS_COUNT, + ); + + const mockContext: UpdateContext = { + betas: ['authAutoReportTitle'], + allReports: mockReports, + allPolicies: mockPolicies, + allReportNameValuePairs: {}, + }; + + beforeAll(async () => { + Onyx.init({keys: ONYXKEYS}); + mockReportUtils.isExpenseReport.mockReturnValue(true); + mockReportUtils.getTitleReportField.mockReturnValue(mockPolicy.fieldList?.text_title); + await waitForBatchedUpdates(); + }); + + afterAll(() => { + Onyx.clear(); + }); + + describe('Single Report Name Computation', () => { + test('[OptimisticReportNames] computeReportNameIfNeeded() single report', async () => { + const report = Object.values(mockReports).at(0); + const update = { + key: `report_${report?.reportID}` as OnyxKey, + onyxMethod: Onyx.METHOD.MERGE, + value: {total: -20000}, + }; + + await measureFunction(() => computeReportNameIfNeeded(report, update, mockContext)); + }); + }); + + describe('Batch Processing Performance', () => { + test('[OptimisticReportNames] updateOptimisticReportNamesFromUpdates() with 10 new reports', async () => { + const updates = Array.from({length: 10}, (_, i) => ({ + key: `report_new${i}` as OnyxKey, + onyxMethod: Onyx.METHOD.SET, + value: { + reportID: `new${i}`, + policyID: `policy${i % 10}`, + total: -(Math.random() * 50000), + currency: 'USD', + lastVisibleActionCreated: new Date().toISOString(), + }, + })); + + await measureFunction(() => updateOptimisticReportNamesFromUpdates(updates, mockContext)); + }); + + test('[OptimisticReportNames] updateOptimisticReportNamesFromUpdates() with 50 existing report updates', async () => { + const reportKeys = Object.keys(mockReports).slice(0, 50) as OnyxKey[]; + const updates = reportKeys.map((key) => ({ + key, + onyxMethod: Onyx.METHOD.MERGE, + value: {total: -(Math.random() * 100000)}, + })); + + await measureFunction(() => updateOptimisticReportNamesFromUpdates(updates, mockContext)); + }); + + test('[OptimisticReportNames] updateOptimisticReportNamesFromUpdates() with 100 mixed updates', async () => { + const newReportUpdates = Array.from({length: 50}, (_, i) => ({ + key: `report_batch${i}` as OnyxKey, + onyxMethod: Onyx.METHOD.SET, + value: { + reportID: `batch${i}`, + policyID: `policy${i % 20}`, + total: -(Math.random() * 75000), + currency: 'USD', + lastVisibleActionCreated: new Date().toISOString(), + }, + })); + + const existingReportUpdates = Object.keys(mockReports) + .slice(0, 50) + .map((key) => ({ + key: key as OnyxKey, + onyxMethod: Onyx.METHOD.MERGE, + value: {total: -(Math.random() * 125000)}, + })); + + const allUpdates = [...newReportUpdates, ...existingReportUpdates]; + + await measureFunction(() => updateOptimisticReportNamesFromUpdates(allUpdates, mockContext)); + }); + }); + + describe('Policy Update Impact Performance', () => { + test('[OptimisticReportNames] policy update affecting multiple reports', async () => { + const policyUpdate = { + key: 'policy_policy1' as OnyxKey, + onyxMethod: Onyx.METHOD.MERGE, + value: {name: 'Updated Policy Name'}, + }; + + // This should trigger name computation for all reports using policy1 + await measureFunction(() => updateOptimisticReportNamesFromUpdates([policyUpdate], mockContext)); + }); + + test('[OptimisticReportNames] multiple policy updates', async () => { + const policyUpdates = Array.from({length: 10}, (_, i) => ({ + key: `policy_policy${i}` as OnyxKey, + onyxMethod: Onyx.METHOD.MERGE, + value: {name: `Bulk Updated Policy ${i}`}, + })); + + await measureFunction(() => updateOptimisticReportNamesFromUpdates(policyUpdates, mockContext)); + }); + }); + + describe('Large Dataset Performance', () => { + test('[OptimisticReportNames] processing with large context (1000 reports)', async () => { + const updates = Array.from({length: 1000}, (_, i) => ({ + key: `report_large${i}` as OnyxKey, + onyxMethod: Onyx.METHOD.SET, + value: { + reportID: `large${i}`, + policyID: `policy${i % 50}`, + total: -(Math.random() * 200000), + currency: 'USD', + lastVisibleActionCreated: new Date().toISOString(), + }, + })); + + await measureFunction(() => updateOptimisticReportNamesFromUpdates(updates, mockContext)); + }); + + test('[OptimisticReportNames] worst case: many irrelevant updates', async () => { + // Create updates that won't trigger name computation to test filtering performance + const irrelevantUpdates = Array.from({length: 100}, (_, i) => ({ + key: `transaction_${i}` as OnyxKey, + onyxMethod: Onyx.METHOD.MERGE, + value: {description: `Updated transaction ${i}`}, + })); + + await measureFunction(() => updateOptimisticReportNamesFromUpdates(irrelevantUpdates, mockContext)); + }); + }); + + describe('Edge Cases Performance', () => { + test('[OptimisticReportNames] reports without formulas', async () => { + // Mock reports with policies that don't have formulas + const contextWithoutFormulas: UpdateContext = { + ...mockContext, + allPolicies: createCollection( + (item) => `policy_${item.id}`, + (index) => + ({ + id: `policy${index}`, + name: `Policy ${index}`, + fieldList: { + // eslint-disable-next-line @typescript-eslint/naming-convention + text_title: { + name: 'Title', + defaultValue: 'Static Title', + fieldID: 'text_title', + orderWeight: 0, + type: 'text' as const, + deletable: true, + values: [], + keys: [], + externalIDs: [], + disabledOptions: [], + isTax: false, + }, + }, + }) as unknown as Policy, + 50, + ), + allReportNameValuePairs: {}, + }; + + const updates = Array.from({length: 20}, (_, i) => ({ + key: `report_static${i}` as OnyxKey, + onyxMethod: Onyx.METHOD.SET, + value: { + reportID: `static${i}`, + policyID: `policy${i % 10}`, + total: -10000, + currency: 'USD', + }, + })); + + await measureFunction(() => updateOptimisticReportNamesFromUpdates(updates, contextWithoutFormulas)); + }); + + test('[OptimisticReportNames] missing policies and reports', async () => { + const contextWithMissingData: UpdateContext = { + betas: ['authAutoReportTitle'], + allReports: {}, + allPolicies: {}, + allReportNameValuePairs: {}, + }; + + const updates = Array.from({length: 10}, (_, i) => ({ + key: `report_missing${i}` as OnyxKey, + onyxMethod: Onyx.METHOD.SET, + value: { + reportID: `missing${i}`, + policyID: 'nonexistent', + total: -10000, + currency: 'USD', + }, + })); + + await measureFunction(() => updateOptimisticReportNamesFromUpdates(updates, contextWithMissingData)); + }); + }); +}); diff --git a/tests/unit/FormulaTest.ts b/tests/unit/FormulaTest.ts new file mode 100644 index 0000000000000..1298f0414b35d --- /dev/null +++ b/tests/unit/FormulaTest.ts @@ -0,0 +1,360 @@ +// eslint-disable-next-line no-restricted-syntax -- disabled because we need CurrencyUtils to mock +import * as CurrencyUtils from '@libs/CurrencyUtils'; +import type {FormulaContext} from '@libs/Formula'; +import {compute, extract, parse} from '@libs/Formula'; +// eslint-disable-next-line no-restricted-syntax -- disabled because we need ReportActionsUtils to mock +import * as ReportActionsUtils from '@libs/ReportActionsUtils'; +// eslint-disable-next-line no-restricted-syntax -- disabled because we need ReportUtils to mock +import * as ReportUtils from '@libs/ReportUtils'; +import CONST from '@src/CONST'; +import type {Policy, Report, ReportActions, Transaction} from '@src/types/onyx'; + +jest.mock('@libs/ReportActionsUtils', () => ({ + getAllReportActions: jest.fn(), +})); + +jest.mock('@libs/ReportUtils', () => ({ + ...jest.requireActual('@libs/ReportUtils'), + getReportTransactions: jest.fn(), +})); + +jest.mock('@libs/CurrencyUtils', () => ({ + getCurrencySymbol: jest.fn(), +})); + +const mockReportActionsUtils = ReportActionsUtils as jest.Mocked; +const mockReportUtils = ReportUtils as jest.Mocked; +const mockCurrencyUtils = CurrencyUtils as jest.Mocked; + +describe('CustomFormula', () => { + describe('extract()', () => { + test('should extract formula parts with default braces', () => { + expect(extract('{report:type} - {report:total}')).toEqual(['{report:type}', '{report:total}']); + }); + + test('should handle nested braces', () => { + expect(extract('{report:{report:submit:from:firstName|substr:2}}')).toEqual(['{report:{report:submit:from:firstName|substr:2}}']); + }); + + test('should handle escaped braces', () => { + expect(extract('\\{not-formula} {report:type}')).toEqual(['{report:type}']); + }); + + test('should handle empty formula', () => { + expect(extract('')).toEqual([]); + }); + + test('should handle formula without braces', () => { + expect(extract('no braces here')).toEqual([]); + }); + }); + + describe('parse()', () => { + test('should parse report formula parts', () => { + const parts = parse('{report:type} {report:startdate}'); + expect(parts).toHaveLength(3); + expect(parts.at(0)).toEqual({ + definition: '{report:type}', + type: 'report', + fieldPath: ['type'], + functions: [], + }); + expect(parts.at(2)).toEqual({ + definition: '{report:startdate}', + type: 'report', + fieldPath: ['startdate'], + functions: [], + }); + }); + + test('should parse field formula parts', () => { + const parts = parse('{field:custom_field}'); + expect(parts.at(0)).toEqual({ + definition: '{field:custom_field}', + type: 'field', + fieldPath: ['custom_field'], + functions: [], + }); + }); + + test('should parse user formula parts with functions', () => { + const parts = parse('{user:email|frontPart}'); + expect(parts.at(0)).toEqual({ + definition: '{user:email|frontPart}', + type: 'user', + fieldPath: ['email'], + functions: ['frontPart'], + }); + }); + + test('should handle empty formula', () => { + expect(parse('')).toEqual([]); + }); + + test('should treat formula without braces as free text', () => { + const parts = parse('no braces here'); + expect(parts).toHaveLength(1); + expect(parts.at(0)?.type).toBe('freetext'); + }); + }); + + describe('compute()', () => { + const mockContext: FormulaContext = { + report: { + reportID: '123', + reportName: '', + type: 'expense', + total: -10000, // -$100.00 + currency: 'USD', + lastVisibleActionCreated: '2025-01-15T10:30:00Z', + policyID: 'policy1', + } as Report, + policy: { + name: 'Test Policy', + } as Policy, + }; + + beforeEach(() => { + jest.clearAllMocks(); + + mockCurrencyUtils.getCurrencySymbol.mockImplementation((currency: string) => { + if (currency === 'USD') { + return '$'; + } + return currency; + }); + + const mockReportActions = { + // eslint-disable-next-line @typescript-eslint/naming-convention + '1': { + reportActionID: '1', + created: '2025-01-10T08:00:00Z', // Oldest action + actionName: 'CREATED', + }, + // eslint-disable-next-line @typescript-eslint/naming-convention + '2': { + reportActionID: '2', + created: '2025-01-15T10:30:00Z', // Later action + actionName: 'IOU', + }, + // eslint-disable-next-line @typescript-eslint/naming-convention + '3': { + reportActionID: '3', + created: '2025-01-12T14:20:00Z', // Middle action + actionName: 'COMMENT', + }, + } as unknown as ReportActions; + + const mockTransactions = [ + { + transactionID: 'trans1', + created: '2025-01-08T12:00:00Z', // Oldest transaction + amount: 5000, + merchant: 'ACME Ltd.', + }, + { + transactionID: 'trans2', + created: '2025-01-14T16:45:00Z', // Later transaction + amount: 3000, + merchant: 'ACME Ltd.', + }, + { + transactionID: 'trans3', + created: '2025-01-11T09:15:00Z', // Middle transaction + amount: 2000, + merchant: 'ACME Ltd.', + }, + ] as Transaction[]; + + mockReportActionsUtils.getAllReportActions.mockReturnValue(mockReportActions); + mockReportUtils.getReportTransactions.mockReturnValue(mockTransactions); + }); + + test('should compute basic report formula', () => { + const result = compute('{report:type} {report:total}', mockContext); + expect(result).toBe('Expense Report $100.00'); // No space between parts + }); + + test('should compute startdate formula using transactions', () => { + const result = compute('{report:startdate}', mockContext); + expect(result).toBe('2025-01-08'); // Should use oldest transaction date (2025-01-08) + }); + + test('should compute created formula using report actions', () => { + const result = compute('{report:created}', mockContext); + expect(result).toBe('2025-01-10'); // Should use oldest report action date (2025-01-10) + }); + + test('should compute startdate with custom format', () => { + const result = compute('{report:startdate:MM/dd/yyyy}', mockContext); + expect(result).toBe('01/08/2025'); // Should use oldest transaction date with yyyy-MM-dd format + }); + + test('should compute created with custom format', () => { + const result = compute('{report:created:MMMM dd, yyyy}', mockContext); + expect(result).toBe('January 10, 2025'); // Should use oldest report action date with MMMM dd, yyyy format + }); + + test('should compute startdate with short month format', () => { + const result = compute('{report:startdate:dd MMM yyyy}', mockContext); + expect(result).toBe('08 Jan 2025'); // Should use oldest transaction date with dd MMM yyyy format + }); + + test('should compute policy name', () => { + const result = compute('{report:policyname}', mockContext); + expect(result).toBe('Test Policy'); + }); + + test('should handle empty formula', () => { + expect(compute('', mockContext)).toBe(''); + }); + + test('should handle unknown formula parts', () => { + const result = compute('{report:unknown}', mockContext); + expect(result).toBe('{report:unknown}'); + }); + + test('should handle missing report data gracefully', () => { + const contextWithMissingData: FormulaContext = { + report: {} as unknown as Report, + policy: null as unknown as Policy, + }; + const result = compute('{report:total} {report:policyname}', contextWithMissingData); + expect(result).toBe('{report:total} {report:policyname}'); // Empty data is replaced with definition + }); + + test('should preserve free text', () => { + const result = compute('Expense Report - {report:total}', mockContext); + expect(result).toBe('Expense Report - $100.00'); + }); + + test('should preserve exact spacing around formula parts', () => { + const result = compute('Report with type after 4 spaces {report:type}-and no space after computed part', mockContext); + expect(result).toBe('Report with type after 4 spaces Expense Report-and no space after computed part'); + }); + }); + + describe('Edge Cases', () => { + test('should handle malformed braces', () => { + const parts = parse('{incomplete'); + expect(parts.at(0)?.type).toBe('freetext'); + }); + + test('should handle undefined amounts', () => { + const context: FormulaContext = { + report: {total: undefined} as Report, + policy: null as unknown as Policy, + }; + const result = compute('{report:total}', context); + expect(result).toBe('{report:total}'); + }); + + test('should handle missing report actions for created', () => { + mockReportActionsUtils.getAllReportActions.mockReturnValue({}); + const context: FormulaContext = { + report: {reportID: '123'} as Report, + policy: null as unknown as Policy, + }; + + const result = compute('{report:created}', context); + expect(result).toBe('{report:created}'); + }); + + test('should handle missing transactions for startdate', () => { + mockReportUtils.getReportTransactions.mockReturnValue([]); + const context: FormulaContext = { + report: {reportID: '123'} as Report, + policy: null as unknown as Policy, + }; + const today = new Date(); + const expected = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`; + const result = compute('{report:startdate}', context); + expect(result).toBe(expected); + }); + + test('should call getReportTransactions with correct reportID for startdate', () => { + const context: FormulaContext = { + report: {reportID: 'test-report-123'} as Report, + policy: null as unknown as Policy, + }; + + compute('{report:startdate}', context); + expect(mockReportUtils.getReportTransactions).toHaveBeenCalledWith('test-report-123'); + }); + + test('should call getAllReportActions with correct reportID for created', () => { + const context: FormulaContext = { + report: {reportID: 'test-report-456'} as Report, + policy: null as unknown as Policy, + }; + + compute('{report:created}', context); + expect(mockReportActionsUtils.getAllReportActions).toHaveBeenCalledWith('test-report-456'); + }); + + test('should skip partial transactions (empty merchant)', () => { + const mockTransactions = [ + { + transactionID: 'trans1', + created: '2025-01-15T12:00:00Z', + amount: 5000, + merchant: 'ACME Ltd.', + }, + { + transactionID: 'trans2', + created: '2025-01-08T16:45:00Z', // Older but partial + amount: 3000, + merchant: '', // Empty merchant = partial + }, + { + transactionID: 'trans3', + created: '2025-01-12T09:15:00Z', // Should be oldest valid + amount: 2000, + merchant: 'Gamma Inc.', + }, + ] as Transaction[]; + + mockReportUtils.getReportTransactions.mockReturnValue(mockTransactions); + const context: FormulaContext = { + report: {reportID: 'test-report-123'} as Report, + policy: null as unknown as Policy, + }; + + const result = compute('{report:startdate}', context); + expect(result).toBe('2025-01-12'); + }); + + test('should skip partial transactions (zero amount)', () => { + const mockTransactions = [ + { + transactionID: 'trans1', + created: '2025-01-15T12:00:00Z', + amount: 5000, + merchant: 'ACME Ltd.', + }, + { + transactionID: 'trans2', + created: '2025-01-08T16:45:00Z', // Older but partial + amount: 0, // Zero amount = partial + merchant: 'Beta Corp.', + iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, + }, + { + transactionID: 'trans3', + created: '2025-01-12T09:15:00Z', // Should be oldest valid + amount: 2000, + merchant: 'Gamma Inc.', + }, + ] as Transaction[]; + + mockReportUtils.getReportTransactions.mockReturnValue(mockTransactions); + const context: FormulaContext = { + report: {reportID: 'test-report-123'} as Report, + policy: null as unknown as Policy, + }; + + const result = compute('{report:startdate}', context); + expect(result).toBe('2025-01-12'); + }); + }); +}); diff --git a/tests/unit/OptimisticReportNamesTest.ts b/tests/unit/OptimisticReportNamesTest.ts new file mode 100644 index 0000000000000..29ccaa2d2155a --- /dev/null +++ b/tests/unit/OptimisticReportNamesTest.ts @@ -0,0 +1,268 @@ +import Onyx from 'react-native-onyx'; +import type {UpdateContext} from '@libs/OptimisticReportNames'; +import {computeReportNameIfNeeded, shouldComputeReportName, updateOptimisticReportNamesFromUpdates} from '@libs/OptimisticReportNames'; +// eslint-disable-next-line no-restricted-syntax -- disabled because we need ReportUtils to mock +import * as ReportUtils from '@libs/ReportUtils'; +import type {OnyxKey} from '@src/ONYXKEYS'; +import type {Policy, PolicyReportField, Report} from '@src/types/onyx'; + +// Mock dependencies +jest.mock('@libs/ReportUtils', () => ({ + ...jest.requireActual('@libs/ReportUtils'), + isExpenseReport: jest.fn(), + getTitleReportField: jest.fn(), +})); + +jest.mock('@libs/CurrencyUtils', () => ({ + getCurrencySymbol: jest.fn().mockReturnValue('$'), +})); + +const mockReportUtils = ReportUtils as jest.Mocked; + +describe('OptimisticReportNames', () => { + const mockPolicy = { + id: 'policy1', + fieldList: { + // eslint-disable-next-line @typescript-eslint/naming-convention + text_title: { + defaultValue: '{report:type} - {report:total}', + }, + }, + } as unknown as Policy; + + const mockReport = { + reportID: '123', + reportName: 'Old Name', + policyID: 'policy1', + total: -10000, + currency: 'USD', + lastVisibleActionCreated: '2025-01-15T10:30:00Z', + type: 'expense', + } as Report; + + const mockContext: UpdateContext = { + betas: ['authAutoReportTitle'], + allReports: { + // eslint-disable-next-line @typescript-eslint/naming-convention + report_123: mockReport, + }, + allPolicies: { + // eslint-disable-next-line @typescript-eslint/naming-convention + policy_policy1: mockPolicy, + }, + allReportNameValuePairs: { + // eslint-disable-next-line @typescript-eslint/naming-convention + reportNameValuePairs_123: { + private_isArchived: '', + }, + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockReportUtils.isExpenseReport.mockReturnValue(true); + mockReportUtils.getTitleReportField.mockReturnValue(mockPolicy.fieldList?.text_title); + }); + + describe('shouldComputeReportName()', () => { + test('should return true for expense report with title field formula', () => { + const result = shouldComputeReportName(mockReport, mockPolicy); + expect(result).toBe(true); + }); + + test('should return false for reports with unsupported type', () => { + mockReportUtils.isExpenseReport.mockReturnValue(false); + + const result = shouldComputeReportName( + { + ...mockReport, + type: 'iou', + } as Report, + mockPolicy, + ); + expect(result).toBe(false); + }); + + test('should return false when no policy', () => { + const result = shouldComputeReportName(mockReport, undefined); + expect(result).toBe(false); + }); + + test('should return false when no title field', () => { + mockReportUtils.getTitleReportField.mockReturnValue(undefined); + const result = shouldComputeReportName(mockReport, mockPolicy); + expect(result).toBe(false); + }); + + test('should return true when title field has no formula', () => { + const policyWithoutFormula = { + ...mockPolicy, + fieldList: { + // eslint-disable-next-line @typescript-eslint/naming-convention + text_title: {defaultValue: 'Static Title'}, + }, + } as unknown as Policy; + mockReportUtils.getTitleReportField.mockReturnValue(policyWithoutFormula.fieldList?.text_title); + const result = shouldComputeReportName(mockReport, policyWithoutFormula); + expect(result).toBe(true); + }); + }); + + describe('computeReportNameIfNeeded()', () => { + test('should compute name when report data changes', () => { + const update = { + key: 'report_123' as OnyxKey, + onyxMethod: Onyx.METHOD.MERGE, + value: {total: -20000}, + }; + + const result = computeReportNameIfNeeded(mockReport, update, mockContext); + expect(result).toEqual('Expense Report - $200.00'); + }); + + test('should return null when name would not change', () => { + const update = { + key: 'report_456' as OnyxKey, + onyxMethod: Onyx.METHOD.MERGE, + value: {description: 'Updated description'}, + }; + + const result = computeReportNameIfNeeded( + { + ...mockReport, + reportName: 'Expense Report - $100.00', + }, + update, + mockContext, + ); + expect(result).toBeNull(); + }); + }); + + describe('updateOptimisticReportNamesFromUpdates()', () => { + test('should detect new report creation and add name update', () => { + const updates = [ + { + key: 'report_456' as OnyxKey, + onyxMethod: Onyx.METHOD.SET, + value: { + reportID: '456', + policyID: 'policy1', + total: -15000, + currency: 'USD', + type: 'expense', + }, + }, + ]; + + const result = updateOptimisticReportNamesFromUpdates(updates, mockContext); + expect(result).toHaveLength(2); // Original + name update + expect(result.at(1)).toEqual({ + key: 'report_456', + onyxMethod: Onyx.METHOD.MERGE, + value: {reportName: 'Expense Report - $150.00'}, + }); + }); + + test('should handle existing report updates', () => { + const updates = [ + { + key: 'report_123' as OnyxKey, + onyxMethod: Onyx.METHOD.MERGE, + value: {total: -25000}, + }, + ]; + + const result = updateOptimisticReportNamesFromUpdates(updates, mockContext); + expect(result).toHaveLength(2); // Original + name update + expect(result.at(1)?.value).toEqual({reportName: 'Expense Report - $250.00'}); + }); + + test('should handle policy updates affecting multiple reports', () => { + const contextWithMultipleReports = { + ...mockContext, + allReports: { + // eslint-disable-next-line @typescript-eslint/naming-convention + report_123: {...mockReport, reportID: '123'}, + // eslint-disable-next-line @typescript-eslint/naming-convention + report_456: {...mockReport, reportID: '456'}, + // eslint-disable-next-line @typescript-eslint/naming-convention + report_789: {...mockReport, reportID: '789'}, + }, + allReportNameValuePairs: { + // eslint-disable-next-line @typescript-eslint/naming-convention + reportNameValuePairs_123: {private_isArchived: ''}, + // eslint-disable-next-line @typescript-eslint/naming-convention + reportNameValuePairs_456: {private_isArchived: ''}, + // eslint-disable-next-line @typescript-eslint/naming-convention + reportNameValuePairs_789: {private_isArchived: ''}, + }, + }; + mockReportUtils.getTitleReportField.mockReturnValue({defaultValue: 'Policy: {report:policyname}'} as unknown as PolicyReportField); + + const updates = [ + { + key: 'policy_policy1' as OnyxKey, + onyxMethod: Onyx.METHOD.MERGE, + value: {name: 'Updated Policy Name'}, + }, + ]; + + const result = updateOptimisticReportNamesFromUpdates(updates, contextWithMultipleReports); + + expect(result).toHaveLength(4); + + // Assert the original policy update + expect(result.at(0)).toEqual({ + key: 'policy_policy1', + onyxMethod: Onyx.METHOD.MERGE, + value: {name: 'Updated Policy Name'}, + }); + + // Assert individual report name updates + expect(result.at(1)).toEqual({ + key: 'report_123', + onyxMethod: Onyx.METHOD.MERGE, + value: {reportName: 'Policy: Updated Policy Name'}, + }); + + expect(result.at(2)).toEqual({ + key: 'report_456', + onyxMethod: Onyx.METHOD.MERGE, + value: {reportName: 'Policy: Updated Policy Name'}, + }); + + expect(result.at(3)).toEqual({ + key: 'report_789', + onyxMethod: Onyx.METHOD.MERGE, + value: {reportName: 'Policy: Updated Policy Name'}, + }); + }); + + test('should handle unknown object types gracefully', () => { + const updates = [ + { + key: 'unknown_123' as OnyxKey, + onyxMethod: Onyx.METHOD.MERGE, + value: {someData: 'value'}, + }, + ]; + + const result = updateOptimisticReportNamesFromUpdates(updates, mockContext); + expect(result).toEqual(updates); // Unchanged + }); + }); + + describe('Edge Cases', () => { + test('should handle missing report gracefully', () => { + const update = { + key: 'report_999' as OnyxKey, + onyxMethod: Onyx.METHOD.MERGE, + value: {total: -10000}, + }; + + const result = computeReportNameIfNeeded(undefined, update, mockContext); + expect(result).toBeNull(); + }); + }); +});