-
Notifications
You must be signed in to change notification settings - Fork 3.9k
Core logic for ReportTitleUtils #70817
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
Merged
neil-marcellini
merged 10 commits into
Expensify:main
from
callstack-internal:feat/2-optimistic-report-title-utils
Sep 23, 2025
Merged
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
81146ab
Core logic for ReportTitleUtils
sosek108 efd63d8
fix imports
sosek108 ed803fd
Add support for updateTitleFieldToMatchPolicy to report creation
sosek108 1d6dcb4
useOnyx -> canBeMissing
sosek108 2a77e9a
Additional comment
sosek108 4f37d8c
Move betas to ReportTitleUtils
sosek108 0cfebe6
Clean up code
sosek108 f06fd44
fixes according to comments
sosek108 6372aac
Merge branch 'main' into feat/2-optimistic-report-title-utils
sosek108 c4baf54
comment nab fixes
sosek108 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string, ReportNameValuePairs> = {}; | ||
| 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<string, ReportNameValuePairs>) ?? {}; | ||
| }, | ||
| }); | ||
| 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}; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<typeof ReportUtils>; | ||
|
|
||
| describe('ReportTitleUtils', () => { | ||
| beforeAll(async () => { | ||
| const mergedCollection: CollectionDataSet<typeof ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS> = {}; | ||
| 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); | ||
| }); | ||
| }); | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
NAB: Where will we be calling the report title utils from? Will we be calling them from various action functions throughout the app, such as when a report is created or moved? Or will we always be calling them from within the optimistic report names module, and identifying these same actions based on the Onyx updates? If it's always from the module, I would rather pass in a
contextparam and set up these connections in the main context. If it's spread throughout the app then it's ok to have separate connect. NAB as we can always change the approach as we continue forward. I think my preference is to call these utils from the action functions directly, so this looks good. I want to make sure we both have the same plan in mind. Please send a link of your answer via DM so I don't miss it.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We are on the same side. We'll be calling these functions directly from action functions like
updateReportNamefrom@src/actions/Report.ts