Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions src/components/ReportActionItem/MoneyRequestReceiptView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import useOriginalReportID from '@hooks/useOriginalReportID';
import usePermissions from '@hooks/usePermissions';
import {usePersonalDetail} from '@hooks/usePersonalDetails';
import usePrevious from '@hooks/usePrevious';
import useRelatedReportActionsErrorFields from '@hooks/useRelatedReportActionsErrorFields';
import useReportIsArchived from '@hooks/useReportIsArchived';
import useReportsParentHierarchy from '@hooks/useReportsParentHierarchy';
import useReportTransactionsCollection from '@hooks/useReportTransactionsCollection';
Expand Down Expand Up @@ -174,6 +175,7 @@ function MoneyRequestReceiptView({

const originalReportID = useOriginalReportID(report?.reportID, parentReportAction);
const reportsParentHierarchy = useReportsParentHierarchy(report?.reportID);
const relatedReportActions = useRelatedReportActionsErrorFields(reportsParentHierarchy, originalReportID, parentReportAction?.childReportID);
const {iouReport, chatReport: chatIOUReport, isChatIOUReportArchived} = useGetIOUReportFromReportAction(parentReportAction);
const iouReportTransactionsCollection = useReportTransactionsCollection(iouReport?.reportID);
const iouReportTransactions = Object.values(iouReportTransactionsCollection);
Expand Down Expand Up @@ -583,15 +585,29 @@ 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)) {
revert(transaction, getLastModifiedExpense(report?.reportID));
}
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) {
Expand Down
30 changes: 30 additions & 0 deletions src/hooks/useRelatedReportActionsErrorFields.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type {ReportHierarchyInfo} from '@userActions/ClearReportActionErrors';

import ONYXKEYS from '@src/ONYXKEYS';
import {relatedReportActionsErrorFieldsSelector} 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, narrowed to
* the fields it reads: 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 instead of mapping the whole collection, so a write to an
* unrelated report action costs a few lookups rather than a deep comparison over every action in the account.
*/
function useRelatedReportActionsErrorFields(
reportsParentHierarchy: OnyxCollection<ReportHierarchyInfo>,
originalReportID: string | undefined,
childRootReportID: string | undefined,
): OnyxCollection<ReportActions> {
const reportIDs = Object.keys(reportsParentHierarchy ?? {}).map((key) => key.slice(ONYXKEYS.COLLECTION.REPORT.length));
const [relatedReportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS, {
selector: relatedReportActionsErrorFieldsSelector([...reportIDs, originalReportID], childRootReportID),
});
return relatedReportActions;
}

export default useRelatedReportActionsErrorFields;
19 changes: 11 additions & 8 deletions src/libs/actions/ClearReportActionErrors.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {getLinkedTransactionID, getReportAction, getReportActionMessage, isCreatedTaskReportAction, isRejectedAction} from '@libs/ReportActionsUtils';
import {getLinkedTransactionID, getReportActionMessage, isCreatedTaskReportAction, isRejectedAction} from '@libs/ReportActionsUtils';
import {getOriginalReportID} from '@libs/ReportUtils';
import {buildOptimisticSnapshotData} from '@libs/SearchQueryUtils';

Expand Down Expand Up @@ -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<OnyxTypes.ReportActions>) {
if (!reportAction?.reportActionID) {
return;
}
Expand All @@ -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 (
Expand Down Expand Up @@ -125,21 +125,22 @@ type ClearAllRelatedReportActionErrorsParams = {
ignore?: IgnoreDirection;
keys?: string[];
reports?: OnyxCollection<ReportHierarchyInfo>;
reportActions?: OnyxCollection<OnyxTypes.ReportActions>;
};

function clearAllRelatedReportActionErrors({reportID, reportAction, originalReportID, isOffline, ignore, keys, reports}: ClearAllRelatedReportActionErrorsParams) {
function clearAllRelatedReportActionErrors({reportID, reportAction, originalReportID, isOffline, ignore, keys, reports, reportActions}: ClearAllRelatedReportActionErrorsParams) {
const errorKeys = keys ?? Object.keys(reportAction?.errors ?? {});
if (!reportAction || errorKeys.length === 0 || !reportID) {
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({
Expand All @@ -150,11 +151,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);
Expand All @@ -166,6 +168,7 @@ function clearAllRelatedReportActionErrors({reportID, reportAction, originalRepo
ignore: 'parent',
keys: childErrorKeys,
reports,
reportActions,
});
}
}
Expand Down
78 changes: 78 additions & 0 deletions src/selectors/ReportAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,83 @@ function getReceiptScanFailedIOUActionDataSelector(
const transactionThreadReportIDSelector = (transactionID: string | undefined) => (reportActions: OnyxEntry<ReportActions>) =>
transactionID ? getIOUActionForTransactionID(Object.values(reportActions ?? {}), transactionID)?.childReportID : undefined;

/**
* Keeps only the fields `clearAllRelatedReportActionErrors` reads when walking related report actions.
* Nested fields (`errors`, `message`, `originalMessage`) are copied by reference so equality checks on the
* projected result short-circuit and unrelated action updates (reactions, read receipts, person data, ...)
* neither re-render the subscriber nor pay a deep comparison.
*/
function reportActionErrorFieldsSelector(reportAction: OnyxEntry<ReportAction>): OnyxEntry<ReportAction> {
Comment thread
cretadn22 marked this conversation as resolved.
Resolved
return (
reportAction && {
reportActionID: reportAction.reportActionID,
actionName: reportAction.actionName,
created: reportAction.created,
pendingAction: reportAction.pendingAction,
isOptimisticAction: reportAction.isOptimisticAction,
childReportID: reportAction.childReportID,
errors: reportAction.errors,
message: reportAction.message,
// Copying the deprecated originalMessage field is intentional. The projection must preserve the raw
// data so downstream helpers can resolve it through getOriginalMessage.
// eslint-disable-next-line @typescript-eslint/no-deprecated
originalMessage: reportAction.originalMessage,
}
);
}

function projectReportActionsErrorFields(actions: ReportActions): ReportActions {
const projectedActions: ReportActions = {};
for (const [reportActionID, reportAction] of Object.entries(actions)) {
const projectedAction = reportActionErrorFieldsSelector(reportAction);
if (projectedAction) {
projectedActions[reportActionID] = projectedAction;
}
}
return projectedActions;
}

/**
* Creates a selector returning the report actions `clearAllRelatedReportActionErrors` walks, narrowed to the fields it
* reads: 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, so a write to an unrelated report action neither maps the whole collection nor pays
* a deep comparison over it.
*/
function relatedReportActionsErrorFieldsSelector(reportIDs: Array<string | undefined>, childRootReportID: string | undefined) {
return function selectRelatedReportActionsErrorFields(reportActions: OnyxCollection<ReportActions>): OnyxCollection<ReportActions> {
const result: OnyxCollection<ReportActions> = {};
const visitedReportIDs = new Set<string>();
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}`] = projectReportActionsErrorFields(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 the ID of the author's newest comment in the report that can show the feedback prompt. */
const getLatestFeedbackActionIDByAuthorSelector = (authorAccountID: number | undefined) => (reportActions: OnyxEntry<ReportActions>) =>
authorAccountID ? getLatestConciergeFeedbackActionIDFromReportActions(reportActions, authorAccountID) : undefined;
Expand Down Expand Up @@ -195,6 +272,7 @@ export {
getReportActionByIDSelector,
getReceiptScanFailedIOUActionDataSelector,
reportVisibleActionsSelector,
relatedReportActionsErrorFieldsSelector,
forwardedSinceLastSubmitReportIDsSelector,
transactionThreadReportIDSelector,
getParentReportActionWithoutThreadRepliesSelector,
Expand Down
Loading
Loading