diff --git a/src/components/ReportActionItem/MoneyRequestReceiptView.tsx b/src/components/ReportActionItem/MoneyRequestReceiptView.tsx index effb4a7f47dc..e92797b6047e 100644 --- a/src/components/ReportActionItem/MoneyRequestReceiptView.tsx +++ b/src/components/ReportActionItem/MoneyRequestReceiptView.tsx @@ -29,6 +29,7 @@ import usePermissions from '@hooks/usePermissions'; import {usePersonalDetail} from '@hooks/usePersonalDetails'; import usePrevious from '@hooks/usePrevious'; import useReceiptTranslation from '@hooks/useReceiptTranslation'; +import useRelatedReportActions from '@hooks/useRelatedReportActions'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useReportsParentHierarchy from '@hooks/useReportsParentHierarchy'; import useReportTransactionsCollection from '@hooks/useReportTransactionsCollection'; @@ -178,6 +179,7 @@ function MoneyRequestReceiptView({ const originalReportID = useOriginalReportID(report?.reportID, parentReportAction); const reportsParentHierarchy = useReportsParentHierarchy(report?.reportID); + const relatedReportActions = useRelatedReportActions(reportsParentHierarchy, originalReportID, parentReportAction?.childReportID); const {iouReport, chatReport: chatIOUReport, isChatIOUReportArchived} = useGetIOUReportFromReportAction(parentReportAction); const [chatIOUReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(iouReport?.chatReportID)}`); const iouReportTransactionsCollection = useReportTransactionsCollection(iouReport?.reportID); @@ -564,7 +566,14 @@ function MoneyRequestReceiptView({ return; } clearError(linkedTransactionID); - clearAllRelatedReportActionErrors({reportID: report.reportID, reportAction: parentReportAction, originalReportID, isOffline, reports: reportsParentHierarchy}); + clearAllRelatedReportActionErrors({ + reportID: report.reportID, + reportAction: parentReportAction, + originalReportID, + isOffline, + reports: reportsParentHierarchy, + reportActions: relatedReportActions, + }); return; } if (!isEmptyObject(transactionAndReportActionErrors)) { @@ -572,7 +581,14 @@ function MoneyRequestReceiptView({ } if (!isEmptyObject(errorsWithoutReportCreation)) { clearError(transaction.transactionID); - clearAllRelatedReportActionErrors({reportID: report.reportID, reportAction: parentReportAction, originalReportID, isOffline, reports: reportsParentHierarchy}); + clearAllRelatedReportActionErrors({ + reportID: report.reportID, + reportAction: parentReportAction, + originalReportID, + isOffline, + reports: reportsParentHierarchy, + reportActions: relatedReportActions, + }); } if (!isEmptyObject(reportCreationError)) { if (isInNarrowPaneModal) { diff --git a/src/hooks/useRelatedReportActions.ts b/src/hooks/useRelatedReportActions.ts new file mode 100644 index 000000000000..43bdf90bda97 --- /dev/null +++ b/src/hooks/useRelatedReportActions.ts @@ -0,0 +1,30 @@ +import type {ReportHierarchyInfo} from '@userActions/ClearReportActionErrors'; + +import ONYXKEYS from '@src/ONYXKEYS'; +import {relatedReportActionsSelector} from '@src/selectors/ReportAction'; +import type {ReportActions} from '@src/types/onyx'; + +import type {OnyxCollection} from 'react-native-onyx'; + +import useOnyx from './useOnyx'; + +/** + * Subscribes to the report actions `clearAllRelatedReportActionErrors` walks when an error is dismissed: the actions + * of every report in `reportsParentHierarchy` (see `useReportsParentHierarchy`), of the original report holding the + * dismissed action, and of the threads below `childRootReportID` that hang off errored actions. The selector only + * looks up those keys and returns each report's actions by reference, so a write to an unrelated report action costs + * a few lookups rather than a deep comparison over every action in the account. + */ +function useRelatedReportActions( + reportsParentHierarchy: OnyxCollection, + originalReportID: string | undefined, + childRootReportID: string | undefined, +): OnyxCollection { + const reportIDs = Object.keys(reportsParentHierarchy ?? {}).map((key) => key.slice(ONYXKEYS.COLLECTION.REPORT.length)); + const [relatedReportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS, { + selector: relatedReportActionsSelector([...reportIDs, originalReportID], childRootReportID), + }); + return relatedReportActions; +} + +export default useRelatedReportActions; diff --git a/src/libs/actions/ClearReportActionErrors.ts b/src/libs/actions/ClearReportActionErrors.ts index c428b2f1c27d..d30a00561c61 100644 --- a/src/libs/actions/ClearReportActionErrors.ts +++ b/src/libs/actions/ClearReportActionErrors.ts @@ -1,4 +1,4 @@ -import {getLinkedTransactionID, getReportAction, getReportActionMessage, getVisibleReportActionErrors, isCreatedTaskReportAction, isRejectedAction} from '@libs/ReportActionsUtils'; +import {getLinkedTransactionID, getReportActionMessage, getVisibleReportActionErrors, isCreatedTaskReportAction, isRejectedAction} from '@libs/ReportActionsUtils'; import {getOriginalReportID} from '@libs/ReportUtils'; import {buildOptimisticSnapshotData} from '@libs/SearchQueryUtils'; @@ -33,7 +33,7 @@ Onyx.connectWithoutView({ }, }); -function clearReportActionErrors(reportAction: ReportAction, originalReportID: string | undefined, keys?: string[]) { +function clearReportActionErrors(reportAction: ReportAction, originalReportID: string | undefined, keys?: string[], reportActions?: OnyxCollection) { if (!reportAction?.reportActionID) { return; } @@ -52,7 +52,7 @@ function clearReportActionErrors(reportAction: ReportAction, originalReportID: s // Also clean up any orphaned optimistic reject actions (REJECTED/REJECTED_TO_SUBMITTER) // from the same report. When a rejection fails, the comment action gets the error but the // corresponding reject action remains with pendingAction ADD and no errors. - const siblingActions = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${originalReportID}`]; + const siblingActions = (reportActions ?? allReportActions)?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${originalReportID}`]; if (siblingActions) { for (const [actionID, siblingAction] of Object.entries(siblingActions)) { if ( @@ -125,9 +125,10 @@ type ClearAllRelatedReportActionErrorsParams = { ignore?: IgnoreDirection; keys?: string[]; reports?: OnyxCollection; + reportActions?: OnyxCollection; }; -function clearAllRelatedReportActionErrors({reportID, reportAction, originalReportID, isOffline, ignore, keys, reports}: ClearAllRelatedReportActionErrorsParams) { +function clearAllRelatedReportActionErrors({reportID, reportAction, originalReportID, isOffline, ignore, keys, reports, reportActions}: ClearAllRelatedReportActionErrorsParams) { // Default to the errors the user can see. Propagating a hidden one, e.g. the mirrored pay failure, would clear // or delete the action it was mirrored from without the user ever seeing it. const errorKeys = keys ?? Object.keys(getVisibleReportActionErrors(reportAction ?? undefined) ?? {}); @@ -135,13 +136,13 @@ function clearAllRelatedReportActionErrors({reportID, reportAction, originalRepo return; } - clearReportActionErrors(reportAction, originalReportID, keys); + clearReportActionErrors(reportAction, originalReportID, keys, reportActions); const report = (reports ?? allReports)?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; if (report?.parentReportID && report?.parentReportActionID && ignore !== 'parent') { - const parentReportAction = getReportAction(report.parentReportID, report.parentReportActionID); + const parentReportActions = (reportActions ?? allReportActions)?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID}`] ?? {}; + const parentReportAction = parentReportActions[report.parentReportActionID]; const parentErrorKeys = Object.keys(parentReportAction?.errors ?? {}).filter((err) => errorKeys.includes(err)); - const parentReportActions = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID}`] ?? {}; const parentOriginalReportID = getOriginalReportID(report.parentReportID, parentReportAction, parentReportActions, isOffline); clearAllRelatedReportActionErrors({ @@ -152,11 +153,12 @@ function clearAllRelatedReportActionErrors({reportID, reportAction, originalRepo ignore: 'child', keys: parentErrorKeys, reports, + reportActions, }); } if (reportAction.childReportID && ignore !== 'child') { - const childActions = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportAction.childReportID}`] ?? {}; + const childActions = (reportActions ?? allReportActions)?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportAction.childReportID}`] ?? {}; for (const action of Object.values(childActions)) { const childErrorKeys = Object.keys(action.errors ?? {}).filter((err) => errorKeys.includes(err)); const childOriginalReportID = getOriginalReportID(reportAction.childReportID, action, childActions, isOffline); @@ -168,6 +170,7 @@ function clearAllRelatedReportActionErrors({reportID, reportAction, originalRepo ignore: 'parent', keys: childErrorKeys, reports, + reportActions, }); } } diff --git a/src/selectors/ReportAction.ts b/src/selectors/ReportAction.ts index 61648d4560dc..652c167f596d 100644 --- a/src/selectors/ReportAction.ts +++ b/src/selectors/ReportAction.ts @@ -183,6 +183,47 @@ function getReceiptScanFailedIOUActionDataSelector( const transactionThreadReportIDSelector = (transactionID: string | undefined) => (reportActions: OnyxEntry) => transactionID ? getIOUActionForTransactionID(Object.values(reportActions ?? {}), transactionID)?.childReportID : undefined; +/** + * Creates a selector returning the report actions `clearAllRelatedReportActionErrors` walks: the actions of every + * report in `reportIDs` (the report whose error is dismissed, its ancestors and the original report holding the + * dismissed action), plus the threads below `childRootReportID` that hang off actions with errors. + * The walk returns early from an action without errors, so following only errored actions downward is exact. + * Only those keys are looked up and each report's actions are returned by reference, so a write to an unrelated + * report action costs a few lookups, and a report whose actions are unchanged compares by reference. + */ +function relatedReportActionsSelector(reportIDs: Array, childRootReportID: string | undefined) { + return function selectRelatedReportActions(reportActions: OnyxCollection): OnyxCollection { + const result: OnyxCollection = {}; + const visitedReportIDs = new Set(); + const collect = (reportID: string | undefined, shouldFollowErroredThreads: boolean) => { + // The visited set also terminates the walk when thread links form a cycle + if (!reportID || visitedReportIDs.has(reportID)) { + return; + } + visitedReportIDs.add(reportID); + const actions = reportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`]; + if (!actions) { + return; + } + result[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`] = actions; + if (!shouldFollowErroredThreads) { + return; + } + for (const action of Object.values(actions)) { + if (action.childReportID && Object.keys(action.errors ?? {}).length > 0) { + collect(action.childReportID, true); + } + } + }; + // The child root is usually also one of `reportIDs`, so descend from it before the plain lookups mark it visited + collect(childRootReportID, true); + for (const reportID of reportIDs) { + collect(reportID, false); + } + return result; + }; +} + /** Returns whether the report is a "Pay someone" (sent money) report, which has a pay action with IOUDetails instead of an expense-creating one. */ const isSentMoneyReportSelector = (reportActions: OnyxEntry) => Object.values(reportActions ?? {}).some(isSentMoneyReportAction); @@ -245,6 +286,7 @@ export { getReportActionByIDSelector, getReceiptScanFailedIOUActionDataSelector, reportVisibleActionsSelector, + relatedReportActionsSelector, forwardedSinceLastSubmitReportIDsSelector, transactionThreadReportIDSelector, getParentReportActionWithoutThreadRepliesSelector, diff --git a/tests/unit/ClearReportActionErrorsTest.ts b/tests/unit/ClearReportActionErrorsTest.ts index f923a4bf0c55..9b5c46ae416f 100644 --- a/tests/unit/ClearReportActionErrorsTest.ts +++ b/tests/unit/ClearReportActionErrorsTest.ts @@ -1,8 +1,10 @@ import {clearAllRelatedReportActionErrors} from '@libs/actions/ClearReportActionErrors'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {ReportActions} from '@src/types/onyx'; +import {relatedReportActionsSelector} from '@selectors/ReportAction'; import Onyx from 'react-native-onyx'; import {createMockReport, getFakeReportAction} from '../utils/ReportTestUtils'; @@ -14,6 +16,7 @@ const PARENT_REPORT_ID = '2'; const CHILD_REPORT_ID = '3'; const GRANDPARENT_REPORT_ID = '4'; const REPORT_ACTION_ID = '100'; +const SIBLING_REPORT_ACTION_ID = '101'; const PARENT_REPORT_ACTION_ID = '200'; const CHILD_REPORT_ACTION_ID = '300'; const GRANDPARENT_REPORT_ACTION_ID = '400'; @@ -568,5 +571,260 @@ describe('ClearReportActionErrors', () => { const parentReportActions = await getReportActionsFromOnyx(PARENT_REPORT_ID); expect(parentReportActions?.[PARENT_REPORT_ACTION_ID]?.errors).toEqual({sharedError: 'Parent error message'}); }); + + it('should clean up orphaned reject actions based on the reportActions parameter', async () => { + // Given an optimistic action in Onyx next to a sibling that Onyx knows as a plain submitted action, + // while the reportActions parameter presents that same sibling as an orphaned optimistic reject + // action (proving the sibling cleanup reads the caller-supplied data, not a hidden cache) + const reportAction = getFakeReportAction(Number(REPORT_ACTION_ID), { + errors: {error1: 'Error message'}, + isOptimisticAction: true, + }); + const seededSiblingAction = getFakeReportAction(Number(SIBLING_REPORT_ACTION_ID), { + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + errors: undefined, + }); + const rejectSiblingAction = getFakeReportAction(Number(SIBLING_REPORT_ACTION_ID), { + actionName: CONST.REPORT.ACTIONS.TYPE.REJECTED, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + errors: undefined, + }); + + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, { + [REPORT_ACTION_ID]: reportAction, + [SIBLING_REPORT_ACTION_ID]: seededSiblingAction, + }); + await waitForBatchedUpdates(); + + // When clearAllRelatedReportActionErrors is called with the sibling presented as a reject action in the parameter + clearAllRelatedReportActionErrors({ + reportID: REPORT_ID, + reportAction, + originalReportID: REPORT_ID, + isOffline: false, + reportActions: { + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`]: { + [REPORT_ACTION_ID]: reportAction, + [SIBLING_REPORT_ACTION_ID]: rejectSiblingAction, + }, + }, + }); + await waitForBatchedUpdates(); + + // Then the sibling should be deleted because the parameter identified it as an orphaned reject action + const reportActions = await getReportActionsFromOnyx(REPORT_ID); + expect(reportActions?.[SIBLING_REPORT_ACTION_ID]).toBeUndefined(); + }); + + it('should thread the reportActions parameter into the parent walk recursion', async () => { + // Given a parent action that Onyx knows as a plain action next to a non-reject sibling, while the + // reportActions parameter presents the parent action as optimistic and its sibling as an orphaned + // reject action. The sibling can only be deleted if the recursive parent call forwards the parameter. + const reportAction = getFakeReportAction(Number(REPORT_ACTION_ID), { + errors: {sharedError: 'Error message'}, + }); + const seededParentReportAction = getFakeReportAction(Number(PARENT_REPORT_ACTION_ID), { + errors: {sharedError: 'Parent error message'}, + }); + const optimisticParentReportAction = getFakeReportAction(Number(PARENT_REPORT_ACTION_ID), { + errors: {sharedError: 'Parent error message'}, + isOptimisticAction: true, + }); + const seededSiblingAction = getFakeReportAction(Number(SIBLING_REPORT_ACTION_ID), { + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + errors: undefined, + }); + const rejectSiblingAction = getFakeReportAction(Number(SIBLING_REPORT_ACTION_ID), { + actionName: CONST.REPORT.ACTIONS.TYPE.REJECTED, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + errors: undefined, + }); + + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, { + [REPORT_ACTION_ID]: reportAction, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${PARENT_REPORT_ID}`, { + [PARENT_REPORT_ACTION_ID]: seededParentReportAction, + [SIBLING_REPORT_ACTION_ID]: seededSiblingAction, + }); + await waitForBatchedUpdates(); + + // When clearAllRelatedReportActionErrors is called with the full hierarchy and actions supplied via parameters + clearAllRelatedReportActionErrors({ + reportID: REPORT_ID, + reportAction, + originalReportID: REPORT_ID, + isOffline: false, + reports: { + [`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`]: {parentReportID: PARENT_REPORT_ID, parentReportActionID: PARENT_REPORT_ACTION_ID}, + }, + reportActions: { + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`]: { + [REPORT_ACTION_ID]: reportAction, + }, + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${PARENT_REPORT_ID}`]: { + [PARENT_REPORT_ACTION_ID]: optimisticParentReportAction, + [SIBLING_REPORT_ACTION_ID]: rejectSiblingAction, + }, + }, + }); + await waitForBatchedUpdates(); + + // Then the parent frame should have deleted the reject sibling, proving it read the forwarded parameter + const parentReportActions = await getReportActionsFromOnyx(PARENT_REPORT_ID); + expect(parentReportActions?.[SIBLING_REPORT_ACTION_ID]).toBeUndefined(); + }); + + it('should clear errors when the reportActions data comes from relatedReportActionsSelector', async () => { + // Given the same hierarchy as the recursion test, but with the reportActions parameter passed + // through the selector components use to scope the data to the dismissal hierarchy. This fails if + // the selector ever drops a report the walk looks up. + const reportAction = getFakeReportAction(Number(REPORT_ACTION_ID), { + errors: {sharedError: 'Error message'}, + }); + const seededParentReportAction = getFakeReportAction(Number(PARENT_REPORT_ACTION_ID), { + errors: {sharedError: 'Parent error message'}, + }); + const optimisticParentReportAction = getFakeReportAction(Number(PARENT_REPORT_ACTION_ID), { + errors: {sharedError: 'Parent error message'}, + isOptimisticAction: true, + }); + const seededSiblingAction = getFakeReportAction(Number(SIBLING_REPORT_ACTION_ID), { + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + errors: undefined, + }); + const rejectSiblingAction = getFakeReportAction(Number(SIBLING_REPORT_ACTION_ID), { + actionName: CONST.REPORT.ACTIONS.TYPE.REJECTED, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + errors: undefined, + }); + + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, { + [REPORT_ACTION_ID]: reportAction, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${PARENT_REPORT_ID}`, { + [PARENT_REPORT_ACTION_ID]: seededParentReportAction, + [SIBLING_REPORT_ACTION_ID]: seededSiblingAction, + }); + await waitForBatchedUpdates(); + + // When clearAllRelatedReportActionErrors is called with selector-scoped reportActions data + clearAllRelatedReportActionErrors({ + reportID: REPORT_ID, + reportAction, + originalReportID: REPORT_ID, + isOffline: false, + reports: { + [`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`]: {parentReportID: PARENT_REPORT_ID, parentReportActionID: PARENT_REPORT_ACTION_ID}, + }, + reportActions: relatedReportActionsSelector( + [REPORT_ID, PARENT_REPORT_ID], + reportAction.childReportID, + )({ + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`]: { + [REPORT_ACTION_ID]: reportAction, + }, + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${PARENT_REPORT_ID}`]: { + [PARENT_REPORT_ACTION_ID]: optimisticParentReportAction, + [SIBLING_REPORT_ACTION_ID]: rejectSiblingAction, + }, + }), + }); + await waitForBatchedUpdates(); + + // Then the scoped data should still drive the full walk and delete the reject sibling + const parentReportActions = await getReportActionsFromOnyx(PARENT_REPORT_ID); + expect(parentReportActions?.[SIBLING_REPORT_ACTION_ID]).toBeUndefined(); + }); + + it('should not clean up reject actions when the supplied reportActions collection is missing the data', async () => { + // Given an optimistic action and an orphaned optimistic reject sibling both seeded in Onyx, but a + // caller-supplied reportActions collection that is empty (the function must trust the caller's data + // and not fall back to the module-level cache per key) + const reportAction = getFakeReportAction(Number(REPORT_ACTION_ID), { + errors: {error1: 'Error message'}, + isOptimisticAction: true, + }); + const rejectSiblingAction = getFakeReportAction(Number(SIBLING_REPORT_ACTION_ID), { + actionName: CONST.REPORT.ACTIONS.TYPE.REJECTED, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + errors: undefined, + }); + + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, { + [REPORT_ACTION_ID]: reportAction, + [SIBLING_REPORT_ACTION_ID]: rejectSiblingAction, + }); + await waitForBatchedUpdates(); + + // When clearAllRelatedReportActionErrors is called with an empty reportActions collection + clearAllRelatedReportActionErrors({reportID: REPORT_ID, reportAction, originalReportID: REPORT_ID, isOffline: false, reportActions: {}}); + await waitForBatchedUpdates(); + + // Then the reject sibling should remain because the supplied collection has no data for the report + const reportActions = await getReportActionsFromOnyx(REPORT_ID); + expect(reportActions?.[SIBLING_REPORT_ACTION_ID]).toBeDefined(); + }); + + it('should not clear child errors when the supplied reportActions collection is missing the data', async () => { + // Given a parent action with matching child errors seeded in Onyx, but a caller-supplied + // reportActions collection that is empty, so the child walk has nothing to iterate + const reportAction = getFakeReportAction(Number(REPORT_ACTION_ID), { + childReportID: CHILD_REPORT_ID, + errors: {sharedError: 'Error message'}, + }); + const childReportAction = getFakeReportAction(Number(CHILD_REPORT_ACTION_ID), { + errors: {sharedError: 'Child error message'}, + }); + + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, createMockReport()); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, { + [REPORT_ACTION_ID]: reportAction, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${CHILD_REPORT_ID}`, { + [CHILD_REPORT_ACTION_ID]: childReportAction, + }); + await waitForBatchedUpdates(); + + // When clearAllRelatedReportActionErrors is called with an empty reportActions collection + clearAllRelatedReportActionErrors({reportID: REPORT_ID, reportAction, originalReportID: REPORT_ID, isOffline: false, reportActions: {}}); + await waitForBatchedUpdates(); + + // Then the child action's error should remain because the supplied collection has no child actions + const childReportActions = await getReportActionsFromOnyx(CHILD_REPORT_ID); + expect(childReportActions?.[CHILD_REPORT_ACTION_ID]?.errors).toEqual({sharedError: 'Child error message'}); + }); + + it('should not clear parent errors when the supplied reportActions collection is missing the data', async () => { + // Given a report whose parent action with matching errors exists in Onyx, but a caller-supplied + // reportActions collection that is empty, so the parent action cannot be resolved from the input + const report = createMockReport({ + parentReportID: PARENT_REPORT_ID, + parentReportActionID: PARENT_REPORT_ACTION_ID, + }); + const reportAction = getFakeReportAction(Number(REPORT_ACTION_ID), { + errors: {sharedError: 'Error message'}, + }); + const parentReportAction = getFakeReportAction(Number(PARENT_REPORT_ACTION_ID), { + errors: {sharedError: 'Parent error message'}, + }); + + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, { + [REPORT_ACTION_ID]: reportAction, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${PARENT_REPORT_ID}`, { + [PARENT_REPORT_ACTION_ID]: parentReportAction, + }); + await waitForBatchedUpdates(); + + // When clearAllRelatedReportActionErrors is called with an empty reportActions collection + clearAllRelatedReportActionErrors({reportID: REPORT_ID, reportAction, originalReportID: REPORT_ID, isOffline: false, reportActions: {}}); + await waitForBatchedUpdates(); + + // Then the parent action's error should remain because the parent action was not in the supplied collection + const parentReportActions = await getReportActionsFromOnyx(PARENT_REPORT_ID); + expect(parentReportActions?.[PARENT_REPORT_ACTION_ID]?.errors).toEqual({sharedError: 'Parent error message'}); + }); }); }); diff --git a/tests/unit/selectors/ReportActionTest.ts b/tests/unit/selectors/ReportActionTest.ts index 916076705eed..5a4943ada055 100644 --- a/tests/unit/selectors/ReportActionTest.ts +++ b/tests/unit/selectors/ReportActionTest.ts @@ -1,7 +1,10 @@ import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; import type {ReportAction, ReportActions} from '@src/types/onyx'; -import {getLatestFeedbackActionIDByAuthorSelector, getReceiptScanFailedIOUActionDataSelector} from '@selectors/ReportAction'; +import type {OnyxCollection} from 'react-native-onyx'; + +import {getLatestFeedbackActionIDByAuthorSelector, getReceiptScanFailedIOUActionDataSelector, relatedReportActionsSelector} from '@selectors/ReportAction'; const CURRENT_USER_ACCOUNT_ID = 123456789; const OTHER_USER_ACCOUNT_ID = 987654321; @@ -179,3 +182,67 @@ describe('getLatestFeedbackActionIDByAuthorSelector', () => { expect(getLatestFeedbackActionIDByAuthorSelector(undefined)(reportActions)).toBeUndefined(); }); }); + +describe('relatedReportActionsSelector', () => { + const THREAD_REPORT_ID = 'thread1'; + const PARENT_REPORT_ID = 'parent1'; + const CHAT_REPORT_ID = 'chat1'; + const UNRELATED_REPORT_ID = 'unrelated1'; + const ERRORED_THREAD_REPORT_ID = 'erroredThread1'; + const CLEAN_THREAD_REPORT_ID = 'cleanThread1'; + + const reportActionsKey = (reportID: string) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}` as const; + + it('should return the actions of the listed reports by reference, and nothing else', () => { + // Given actions for a thread, its parent and an unrelated report + const parentAction = createIOUAction('iou1', {childReportID: THREAD_REPORT_ID, errors: {error1: 'Error message'}}); + const reportActions: OnyxCollection = { + [reportActionsKey(THREAD_REPORT_ID)]: {comment1: createIOUAction('comment1')}, + [reportActionsKey(PARENT_REPORT_ID)]: {iou1: parentAction}, + [reportActionsKey(UNRELATED_REPORT_ID)]: {iou2: createIOUAction('iou2')}, + }; + + // When the selector is applied for the thread and its parent without a child root + const result = relatedReportActionsSelector([THREAD_REPORT_ID, PARENT_REPORT_ID], undefined)(reportActions); + + // Then only the listed reports are returned, so unrelated report action writes cannot change the result + expect(Object.keys(result ?? {})).toEqual([reportActionsKey(THREAD_REPORT_ID), reportActionsKey(PARENT_REPORT_ID)]); + + // Then each report's actions are the input objects by reference, so a report whose actions are unchanged compares cheaply + expect(result?.[reportActionsKey(THREAD_REPORT_ID)]).toBe(reportActions[reportActionsKey(THREAD_REPORT_ID)]); + expect(result?.[reportActionsKey(PARENT_REPORT_ID)]).toBe(reportActions[reportActionsKey(PARENT_REPORT_ID)]); + expect(result?.[reportActionsKey(PARENT_REPORT_ID)]?.iou1).toBe(parentAction); + }); + + it('should follow threads below the child root only through actions that have errors', () => { + // Given a child root that is also one of the listed reports, holding an errored action with a thread and a clean action with a thread + const reportActions: OnyxCollection = { + [reportActionsKey(THREAD_REPORT_ID)]: { + errored1: createIOUAction('errored1', {childReportID: ERRORED_THREAD_REPORT_ID, errors: {error1: 'Error message'}}), + clean1: createIOUAction('clean1', {childReportID: CLEAN_THREAD_REPORT_ID}), + }, + [reportActionsKey(ERRORED_THREAD_REPORT_ID)]: {reply1: createIOUAction('reply1')}, + [reportActionsKey(CLEAN_THREAD_REPORT_ID)]: {reply2: createIOUAction('reply2')}, + }; + + // When the selector is applied with the thread both listed and used as the child root + const result = relatedReportActionsSelector([THREAD_REPORT_ID], THREAD_REPORT_ID)(reportActions); + + // Then the thread hanging off the errored action is included, since the walk recurses into it, while the clean action's thread is not + expect(Object.keys(result ?? {}).sort()).toEqual([reportActionsKey(ERRORED_THREAD_REPORT_ID), reportActionsKey(THREAD_REPORT_ID)].sort()); + }); + + it('should skip missing and undefined report IDs and terminate when thread links form a cycle', () => { + // Given two errored actions whose threads point back at each other, a parent report that is not loaded and an undefined original report ID + const reportActions: OnyxCollection = { + [reportActionsKey(THREAD_REPORT_ID)]: {errored1: createIOUAction('errored1', {childReportID: CHAT_REPORT_ID, errors: {error1: 'Error message'}})}, + [reportActionsKey(CHAT_REPORT_ID)]: {errored2: createIOUAction('errored2', {childReportID: THREAD_REPORT_ID, errors: {error1: 'Error message'}})}, + }; + + // When the selector is applied with the cycle reachable from the child root + const result = relatedReportActionsSelector([THREAD_REPORT_ID, PARENT_REPORT_ID, undefined], THREAD_REPORT_ID)(reportActions); + + // Then both reports in the cycle are returned once, the unloaded report is left out and the selector returns instead of hanging + expect(Object.keys(result ?? {}).sort()).toEqual([reportActionsKey(CHAT_REPORT_ID), reportActionsKey(THREAD_REPORT_ID)].sort()); + }); +});