diff --git a/src/libs/Formula.ts b/src/libs/Formula.ts index 96627bb284ec..5a809f07ddb7 100644 --- a/src/libs/Formula.ts +++ b/src/libs/Formula.ts @@ -38,7 +38,7 @@ const FORMULA_PART_TYPES = { * 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[] { +function extract(formula?: string, opener = '{', closer = '}'): string[] { if (!formula || typeof formula !== 'string') { return []; } @@ -79,7 +79,7 @@ function extract(formula: string, opener = '{', closer = '}'): string[] { * Parse a formula string into an array of formula parts * Based on OldDot Formula.parse method */ -function parse(formula: string): FormulaPart[] { +function parse(formula?: string): FormulaPart[] { if (!formula || typeof formula !== 'string') { return []; } @@ -191,10 +191,13 @@ function parsePart(definition: string): FormulaPart { /** * Compute the value of a formula given a context */ -function compute(formula: string, context: FormulaContext): string { +function compute(formula?: string, context?: FormulaContext): string { if (!formula || typeof formula !== 'string') { return ''; } + if (!context) { + return ''; + } const parts = parse(formula); let result = ''; diff --git a/src/libs/OptimisticReportNames.ts b/src/libs/OptimisticReportNames.ts index 6437a8eabfe8..6e83bedd0a78 100644 --- a/src/libs/OptimisticReportNames.ts +++ b/src/libs/OptimisticReportNames.ts @@ -154,9 +154,9 @@ function shouldComputeReportName(report: Report, context: UpdateContext): boolea } // Only compute names for expense reports with policies that have title fields - // Check if the policy has a title field with a formula - const titleField = getTitleFieldFromRNVP(report.reportID, context); - if (!titleField?.defaultValue) { + // Check if the report has a title field with a formula in rNVP + const reportTitleField = getTitleFieldFromRNVP(report.reportID, context); + if (!reportTitleField?.defaultValue) { return false; } return true; @@ -195,13 +195,10 @@ function computeReportNameIfNeeded(report: Report | undefined, incomingUpdate: O } const titleField = getTitleFieldFromRNVP(targetReport.reportID, context); - if (!titleField?.defaultValue) { - return null; - } // Quick check: see if the update might affect the report name const updateType = determineObjectTypeByKey(incomingUpdate.key); - const formula = titleField.defaultValue; + const formula = titleField?.defaultValue; const formulaParts = parse(formula); let transaction: Transaction | undefined; diff --git a/src/libs/ReportTitleUtils.ts b/src/libs/ReportTitleUtils.ts new file mode 100644 index 000000000000..5776c0326108 --- /dev/null +++ b/src/libs/ReportTitleUtils.ts @@ -0,0 +1,135 @@ +/** + * This file should only be used in context of optimistic report name updates. + * We're using direct Onyx connection and this can lead to stale component's state if used in wrong context. + */ +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 {Beta, BetaConfiguration, Policy, Report, ReportNameValuePairs} from '@src/types/onyx'; +import Permissions from './Permissions'; +import {getTitleReportField, isChatReport} from './ReportUtils'; + +let allReportNameValuePairs: Record = {}; +let betas: Beta[] = []; +let betaConfiguration: BetaConfiguration = {}; + +/** + * We use Onyx.connectWithoutView because we do not use this in React components and this logic is not tied directly to the UI. + * We need up to date report name value pairs of reports to correctly determine if further updates to report's titles should be made. + * It wouldn't be possible without connection directly to Onyx. + */ +Onyx.connectWithoutView({ + key: ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, + waitForCollectionCallback: true, + callback: (val) => { + allReportNameValuePairs = (val as Record) ?? {}; + }, +}); +Onyx.connectWithoutView({ + key: ONYXKEYS.BETAS, + callback: (val) => { + betas = val ?? []; + }, +}); +Onyx.connectWithoutView({ + key: ONYXKEYS.BETA_CONFIGURATION, + callback: (val) => { + betaConfiguration = val ?? {}; + }, +}); + +/** + * Get the title field from report name value pairs + */ +function getTitleFieldFromRNVP(reportID: string) { + const reportNameValuePairs = allReportNameValuePairs[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`]; + // eslint-disable-next-line @typescript-eslint/naming-convention + return reportNameValuePairs?.expensify_text_title; +} + +/** + * Update title field in report's rNVP to match the policy's title field configuration + * This is the JavaScript equivalent of the backend updateTitleFieldToMatchPolicy function + */ +function updateTitleFieldToMatchPolicy(reportID: string, policy?: Policy): OnyxUpdate[] { + if (!Permissions.isBetaEnabled(CONST.BETAS.AUTH_AUTO_REPORT_TITLE, betas, betaConfiguration)) { + return []; + } + if (!reportID || !policy) { + return []; + } + + // Get the policy's title field configuration + const reportTitleField = getTitleReportField(policy.fieldList ?? {}); + + // Early return if policy doesn't have a title field + if (!reportTitleField) { + return []; + } + + // Create the update to set/update the title field in rNVP + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`, + value: { + // eslint-disable-next-line @typescript-eslint/naming-convention + expensify_text_title: reportTitleField, + }, + }, + ]; + + return optimisticData; +} + +/** + * Remove title field from report's rNVP when report is manually renamed to indicate that the manual name should be preserved, and the custom report name formula should no longer update the name. + */ +function removeTitleFieldFromReport(reportID: string): OnyxUpdate[] { + if (!Permissions.isBetaEnabled(CONST.BETAS.AUTH_AUTO_REPORT_TITLE, betas, betaConfiguration)) { + return []; + } + if (!reportID) { + return []; + } + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`, + value: { + // eslint-disable-next-line @typescript-eslint/naming-convention + expensify_text_title: null, + }, + }, + ]; + + return optimisticData; +} + +/** + * Check if a report should have its title field updated based on conditions + */ +function shouldUpdateTitleField(report: Report): boolean { + // todo: this should be more sophisticated function. check for iou etc + if (!report) { + return false; + } + // Skip chat reports + if (isChatReport(report)) { + return false; + } + + // Skip if report has statement card ID (backend check: !getValue(db, reportID, NVP_STATEMENT_CARD_ID).empty()) + // This would need to be implemented based on how statement card IDs are stored in the frontend + + const reportTitleField = getTitleFieldFromRNVP(report.reportID); + if (!reportTitleField) { + return false; + } + + return true; +} + +export {getTitleFieldFromRNVP, removeTitleFieldFromReport, shouldUpdateTitleField, updateTitleFieldToMatchPolicy}; diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 540ac8e6d14c..b1ecd8532a5b 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -98,6 +98,7 @@ import processReportIDDeeplink from '@libs/processReportIDDeeplink'; import Pusher from '@libs/Pusher'; import type {UserIsLeavingRoomEvent, UserIsTypingEvent} from '@libs/Pusher/types'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; +import {updateTitleFieldToMatchPolicy} from '@libs/ReportTitleUtils'; import type {OptimisticAddCommentReportAction, OptimisticChatReport, SelfDMParameters} from '@libs/ReportUtils'; import { buildOptimisticAddCommentReportAction, @@ -2790,6 +2791,8 @@ function buildNewReportOptimisticData(policy: OnyxEntry, reportID: strin }, ]; + optimisticData.push(...updateTitleFieldToMatchPolicy(reportID, policy)); + const failureData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, diff --git a/tests/unit/ReportTitleUtilsTest.ts b/tests/unit/ReportTitleUtilsTest.ts new file mode 100644 index 000000000000..a1e4af5e9513 --- /dev/null +++ b/tests/unit/ReportTitleUtilsTest.ts @@ -0,0 +1,156 @@ +import Onyx from 'react-native-onyx'; +import {getTitleFieldFromRNVP, removeTitleFieldFromReport, shouldUpdateTitleField, updateTitleFieldToMatchPolicy} from '@libs/ReportTitleUtils'; +// 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 ONYXKEYS from '@src/ONYXKEYS'; +import type {Policy, PolicyReportField, Report, ReportNameValuePairs} from '@src/types/onyx'; +import type CollectionDataSet from '@src/types/utils/CollectionDataSet'; + +// Mock dependencies +jest.mock('@libs/ReportUtils', () => ({ + getTitleReportField: jest.fn(), + isChatReport: jest.fn(), +})); +jest.mock('@libs/Permissions', () => ({ + isBetaEnabled: jest.fn().mockReturnValue(true), +})); + +const mockedReportUtils = ReportUtils as jest.Mocked; + +describe('ReportTitleUtils', () => { + beforeAll(async () => { + const mergedCollection: CollectionDataSet = {}; + mergedCollection[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}12345`] = { + // eslint-disable-next-line @typescript-eslint/naming-convention + expensify_text_title: { + defaultValue: 'Report {report:total}', + }, + } as unknown as ReportNameValuePairs; + await Onyx.merge(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, mergedCollection); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('getTitleFieldFromRNVP', () => { + const mockReportID = '12345'; + const mockTitleField = { + defaultValue: 'Report {report:total}', + }; + + it('should return title field when RNVP exists with title field', () => { + const result = getTitleFieldFromRNVP(mockReportID); + + expect(result).toEqual(mockTitleField); + }); + + it('should return undefined when RNVP is undefined', () => { + const result = getTitleFieldFromRNVP('55555'); + + expect(result).toBeUndefined(); + }); + }); + + describe('updateTitleFieldToMatchPolicy', () => { + const mockReportID = '12345'; + const mockTitleField: PolicyReportField = { + defaultValue: 'Test report Title', + } as unknown as PolicyReportField; + + it('should return optimistic update when valid inputs provided', () => { + const mockPolicy: Policy = { + id: 'policy123', + fieldList: { + // eslint-disable-next-line @typescript-eslint/naming-convention + text_title: mockTitleField, + }, + } as unknown as Policy; + + mockedReportUtils.getTitleReportField.mockReturnValue(mockTitleField); + + const result = updateTitleFieldToMatchPolicy(mockReportID, mockPolicy); + + expect(mockedReportUtils.getTitleReportField).toHaveBeenCalledWith(mockPolicy.fieldList); + expect(result).toHaveLength(1); + expect(result.at(0)).toEqual({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${mockReportID}`, + value: { + // eslint-disable-next-line @typescript-eslint/naming-convention + expensify_text_title: mockTitleField, + }, + }); + }); + + it('should return empty array when policy is undefined', () => { + const result = updateTitleFieldToMatchPolicy(mockReportID, undefined); + + expect(result).toEqual([]); + expect(mockedReportUtils.getTitleReportField).not.toHaveBeenCalled(); + }); + }); + + describe('removeTitleFieldFromReport', () => { + const mockReportID = '12345'; + + it('should return optimistic update with null value for valid reportID', () => { + const result = removeTitleFieldFromReport(mockReportID); + + expect(result).toHaveLength(1); + expect(result.at(0)).toEqual({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${mockReportID}`, + value: { + // eslint-disable-next-line @typescript-eslint/naming-convention + expensify_text_title: null, + }, + }); + }); + }); + + describe('shouldUpdateTitleField', () => { + const mockReportID = '12345'; + + it('should return false when report is chat report', () => { + const mockReport: Report = { + reportID: mockReportID, + type: CONST.REPORT.TYPE.CHAT, + } as Report; + + mockedReportUtils.isChatReport.mockReturnValue(true); + + const result = shouldUpdateTitleField(mockReport); + + expect(mockedReportUtils.isChatReport).toHaveBeenCalledWith(mockReport); + expect(result).toBe(false); + }); + + it('should return false when report has no title field in RNVP', () => { + const mockReport: Report = { + reportID: '5555', + type: CONST.REPORT.TYPE.EXPENSE, + } as Report; + + mockedReportUtils.isChatReport.mockReturnValue(false); + + const result = shouldUpdateTitleField(mockReport); + + expect(result).toBe(false); + }); + + it('should return true when non-chat report has title field', () => { + const mockReport: Report = { + reportID: mockReportID, + type: CONST.REPORT.TYPE.EXPENSE, + } as Report; + + mockedReportUtils.isChatReport.mockReturnValue(false); + + const result = shouldUpdateTitleField(mockReport); + + expect(result).toBe(true); + }); + }); +});