Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
cb080ac
Exclude all-held reports from Submit to-do count
allgandalf Jun 3, 2026
fe670be
Exclude all-held reports from Approve to-do count
allgandalf Jun 3, 2026
d2fa40e
Exclude all-held reports from Pay to-do count
allgandalf Jun 3, 2026
b5f4b70
Surface Remove Hold over Approve/Submit for all-held reports on repor…
allgandalf Jun 3, 2026
e1160e2
Add test: all-held report excluded from Submit to-do
allgandalf Jun 3, 2026
23f86b0
Add test: all-held report excluded from Approve to-do
allgandalf Jun 3, 2026
b7cfffd
Add test: all-held report excluded from Pay to-do
allgandalf Jun 3, 2026
1692b25
Guard approve and submit header actions when all expenses are held
allgandalf Jun 3, 2026
b8c7d91
Add test: report header returns Remove Hold for all-held approvable r…
allgandalf Jun 3, 2026
90bf0e3
Merge branch 'Expensify:main' into fix-allheld-todos
allgandalf Jun 7, 2026
08f65d3
Merge branch 'Expensify:main' into fix-allheld-todos
allgandalf Jun 9, 2026
60a5b8f
Merge branch 'Expensify:main' into fix-allheld-todos
allgandalf Jun 10, 2026
d62e5d6
Compute hasOnlyHeldExpenses once per report in the todos derivation
allgandalf Jun 11, 2026
3682312
Suppress Pay on the report header when every expense is held and reus…
allgandalf Jun 11, 2026
fd94d9e
Update report header tests for the all-held Pay suppression
allgandalf Jun 11, 2026
f1c9429
Bump eslint-seatbelt baseline for the added report header tests
allgandalf Jun 11, 2026
4c6fffc
Merge remote-tracking branch 'upstream/main' into fix-allheld-todos
allgandalf Jun 11, 2026
58229c6
Merge remote-tracking branch 'upstream/main' into fix-allheld-todos
allgandalf Jun 16, 2026
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
4 changes: 2 additions & 2 deletions config/eslint/eslint.seatbelt.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -1846,7 +1846,7 @@
"../../tests/unit/NextStepUtilsTest.ts" "@typescript-eslint/no-deprecated/buildNextStepNew" 23
"../../tests/unit/NextStepUtilsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 2
"../../tests/unit/OnboardingSelectorsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 10
"../../tests/unit/OnyxDerivedTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 7
"../../tests/unit/OnyxDerivedTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 8
"../../tests/unit/OnyxUpdateManagerTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 4
"../../tests/unit/OptionListContextProviderTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 10
"../../tests/unit/OptionsListUtilsTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 50
Expand All @@ -1870,7 +1870,7 @@
"../../tests/unit/ReportActionsUtilsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 35
"../../tests/unit/ReportLayoutUtilsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../tests/unit/ReportNameUtilsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 23
"../../tests/unit/ReportPrimaryActionUtilsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 168
"../../tests/unit/ReportPrimaryActionUtilsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 175
"../../tests/unit/ReportPrimaryActionUtilsTest.ts" "no-restricted-imports" 1
"../../tests/unit/ReportSecondaryActionUtilsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 466
"../../tests/unit/ReportSecondaryActionUtilsTest.ts" "no-restricted-imports" 2
Expand Down
10 changes: 6 additions & 4 deletions src/libs/ReportPrimaryActionUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,7 @@ function getReportPrimaryAction(params: GetReportPrimaryActionParams): ValueOf<t
return '';
}

const allExpensesHeld = hasOnlyHeldExpenses(reportTransactions);
const isPayActionWithAllExpensesHeld =
isPrimaryPayAction({
report,
Expand All @@ -481,7 +482,7 @@ function getReportPrimaryAction(params: GetReportPrimaryActionParams): ValueOf<t
isChatReportArchived,
invoiceReceiverPolicy,
reportActions,
}) && hasOnlyHeldExpenses(reportTransactions);
}) && allExpensesHeld;
const expensesToHold = getAllExpensesToHoldIfApplicable(report, reportActions, reportTransactions, policy, currentUserAccountID);

if (isMarkAsCashAction(currentUserLogin, currentUserAccountID, report, reportTransactions, violations, policy)) {
Expand All @@ -492,7 +493,7 @@ function getReportPrimaryAction(params: GetReportPrimaryActionParams): ValueOf<t
return CONST.REPORT.PRIMARY_ACTIONS.REVIEW_DUPLICATES;
}

if (isApproveAction(report, reportTransactions, currentUserAccountID, reportMetadata, policy)) {
if (isApproveAction(report, reportTransactions, currentUserAccountID, reportMetadata, policy) && !allExpensesHeld) {
return CONST.REPORT.PRIMARY_ACTIONS.APPROVE;
}

Expand All @@ -504,7 +505,7 @@ function getReportPrimaryAction(params: GetReportPrimaryActionParams): ValueOf<t
return CONST.REPORT.PRIMARY_ACTIONS.MARK_AS_RESOLVED;
}

if (isSubmitAction(report, reportTransactions, reportMetadata, policy, reportNameValuePairs, violations, currentUserLogin, currentUserAccountID)) {
if (isSubmitAction(report, reportTransactions, reportMetadata, policy, reportNameValuePairs, violations, currentUserLogin, currentUserAccountID) && !allExpensesHeld) {
return CONST.REPORT.PRIMARY_ACTIONS.SUBMIT;
}

Expand All @@ -520,7 +521,8 @@ function getReportPrimaryAction(params: GetReportPrimaryActionParams): ValueOf<t
isChatReportArchived,
invoiceReceiverPolicy,
reportActions,
})
}) &&
!allExpensesHeld
) {
return CONST.REPORT.PRIMARY_ACTIONS.PAY;
}
Expand Down
10 changes: 6 additions & 4 deletions src/libs/actions/OnyxDerived/configs/todos.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
import {isApproveAction, isExportAction, isPrimaryPayAction, isSubmitAction} from '@libs/ReportPrimaryActionUtils';
import {hasOnlyNonReimbursableTransactions} from '@libs/ReportUtils';
import {hasOnlyHeldExpenses, hasOnlyNonReimbursableTransactions} from '@libs/ReportUtils';
import createOnyxDerivedValueConfig from '@userActions/OnyxDerived/createOnyxDerivedValueConfig';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
Expand Down Expand Up @@ -58,10 +58,11 @@ const createTodosReportsAndTransactions = ({
const reportActions = Object.values(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`] ?? []);
const reportTransactions = transactionsByReportID[report.reportID] ?? [];
const reportMetadata = allReportMetadata?.[`${ONYXKEYS.COLLECTION.REPORT_METADATA}${report.reportID}`];
if (isSubmitAction(report, reportTransactions, reportMetadata, policy, reportNameValuePair, undefined, login, currentUserAccountID)) {
const allExpensesHeld = hasOnlyHeldExpenses(reportTransactions);
if (isSubmitAction(report, reportTransactions, reportMetadata, policy, reportNameValuePair, undefined, login, currentUserAccountID) && !allExpensesHeld) {
reportsToSubmit.push(report);
}
if (isApproveAction(report, reportTransactions, currentUserAccountID, reportMetadata, policy)) {
if (isApproveAction(report, reportTransactions, currentUserAccountID, reportMetadata, policy) && !allExpensesHeld) {
reportsToApprove.push(report);
}
if (
Expand All @@ -74,7 +75,8 @@ const createTodosReportsAndTransactions = ({
policy,
reportNameValuePairs: reportNameValuePair,
}) &&
!hasOnlyNonReimbursableTransactions(report.reportID, reportTransactions)
!hasOnlyNonReimbursableTransactions(report.reportID, reportTransactions) &&
!allExpensesHeld
) {
reportsToPay.push(report);
}
Expand Down
67 changes: 67 additions & 0 deletions tests/unit/OnyxDerivedTest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -660,6 +660,73 @@ describe('OnyxDerived', () => {
});
});

describe('excludes reports with all expenses on hold', () => {
const HELD_SUBMIT_REPORT_ID = 'held_submit_1';
const HELD_APPROVE_REPORT_ID = 'held_approve_1';
const HELD_PAY_REPORT_ID = 'held_pay_1';

beforeEach(async () => {
const submitReport = createMockReport(HELD_SUBMIT_REPORT_ID, {
stateNum: CONST.REPORT.STATE_NUM.OPEN,
statusNum: CONST.REPORT.STATUS_NUM.OPEN,
ownerAccountID: CURRENT_USER_ACCOUNT_ID,
});
const approveReport = createMockReport(HELD_APPROVE_REPORT_ID, {
stateNum: CONST.REPORT.STATE_NUM.SUBMITTED,
statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED,
ownerAccountID: OTHER_USER_ACCOUNT_ID,
managerID: CURRENT_USER_ACCOUNT_ID,
});
const payReport = createMockReport(HELD_PAY_REPORT_ID, {
stateNum: CONST.REPORT.STATE_NUM.APPROVED,
statusNum: CONST.REPORT.STATUS_NUM.APPROVED,
ownerAccountID: OTHER_USER_ACCOUNT_ID,
managerID: CURRENT_USER_ACCOUNT_ID,
total: -100,
});

const policy = createMockPolicy(POLICY_ID, {
approvalMode: CONST.POLICY.APPROVAL_MODE.BASIC,
role: CONST.POLICY.ROLE.ADMIN,
ownerAccountID: CURRENT_USER_ACCOUNT_ID,
reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES,
});

const heldOverride: Partial<Transaction> = {comment: {hold: 'HOLD_ACTION_ID'}};

await Onyx.multiSet({
[ONYXKEYS.SESSION]: {
email: CURRENT_USER_EMAIL,
accountID: CURRENT_USER_ACCOUNT_ID,
},
[`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`]: policy,
[`${ONYXKEYS.COLLECTION.REPORT}${HELD_SUBMIT_REPORT_ID}`]: submitReport,
[`${ONYXKEYS.COLLECTION.REPORT}${HELD_APPROVE_REPORT_ID}`]: approveReport,
[`${ONYXKEYS.COLLECTION.REPORT}${HELD_PAY_REPORT_ID}`]: payReport,
[`${ONYXKEYS.COLLECTION.TRANSACTION}trans_${HELD_SUBMIT_REPORT_ID}`]: createMockTransaction(`trans_${HELD_SUBMIT_REPORT_ID}`, HELD_SUBMIT_REPORT_ID, heldOverride),
[`${ONYXKEYS.COLLECTION.TRANSACTION}trans_${HELD_APPROVE_REPORT_ID}`]: createMockTransaction(`trans_${HELD_APPROVE_REPORT_ID}`, HELD_APPROVE_REPORT_ID, heldOverride),
[`${ONYXKEYS.COLLECTION.TRANSACTION}trans_${HELD_PAY_REPORT_ID}`]: createMockTransaction(`trans_${HELD_PAY_REPORT_ID}`, HELD_PAY_REPORT_ID, heldOverride),
} as OnyxMultiSetInput);

await waitForBatchedUpdates();
});

it('excludes an all-held report from reportsToSubmit', async () => {
const todos = await OnyxUtils.get(ONYXKEYS.DERIVED.TODOS);
expect(todos?.reportsToSubmit.map((r) => r.reportID)).not.toContain(HELD_SUBMIT_REPORT_ID);
});

it('excludes an all-held report from reportsToApprove', async () => {
const todos = await OnyxUtils.get(ONYXKEYS.DERIVED.TODOS);
expect(todos?.reportsToApprove.map((r) => r.reportID)).not.toContain(HELD_APPROVE_REPORT_ID);
});

it('excludes an all-held report from reportsToPay', async () => {
const todos = await OnyxUtils.get(ONYXKEYS.DERIVED.TODOS);
expect(todos?.reportsToPay.map((r) => r.reportID)).not.toContain(HELD_PAY_REPORT_ID);
});
});

describe('categorizes reports correctly', () => {
const SUBMIT_REPORT_IDS = ['submit_1', 'submit_2', 'submit_3', 'submit_4'];
const APPROVE_REPORT_IDS = ['approve_1', 'approve_2', 'approve_3'];
Expand Down
111 changes: 108 additions & 3 deletions tests/unit/ReportPrimaryActionUtilsTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,9 +306,6 @@ describe('getPrimaryAction', () => {
};
const transaction = {
reportID: `${REPORT_ID}`,
comment: {
hold: 'Hold',
},
} as unknown as Transaction;

expect(
Expand Down Expand Up @@ -573,6 +570,40 @@ describe('getPrimaryAction', () => {
).toBe(CONST.REPORT.PRIMARY_ACTIONS.PAY);
});

it('should not return PAY for an expense report when every expense is held', async () => {
const report = {
reportID: REPORT_ID,
type: CONST.REPORT.TYPE.EXPENSE,
ownerAccountID: CURRENT_USER_ACCOUNT_ID,
statusNum: CONST.REPORT.STATUS_NUM.CLOSED,
total: -300,
} as unknown as Report;
await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report);
const policy = {
role: CONST.POLICY.ROLE.ADMIN,
};
const transaction = {
reportID: `${REPORT_ID}`,
comment: {
hold: 'Hold',
},
} as unknown as Transaction;

expect(
getReportPrimaryAction({
currentUserLogin: CURRENT_USER_EMAIL,
currentUserAccountID: CURRENT_USER_ACCOUNT_ID,
report,
chatReport,
reportTransactions: [transaction],
violations: {},
bankAccountList: {},
policy: policy as Policy,
isChatReportArchived: false,
}),
).not.toBe(CONST.REPORT.PRIMARY_ACTIONS.PAY);
});

it('should not return PAY for expense report with only non-reimbursable transactions when total is 0', async () => {
const report = {
reportID: REPORT_ID,
Expand Down Expand Up @@ -888,6 +919,80 @@ describe('getPrimaryAction', () => {
).toBe(CONST.REPORT.PRIMARY_ACTIONS.REMOVE_HOLD);
});

it('should return REMOVE HOLD over APPROVE when all expenses are held and the manager can unhold', async () => {
const MEMBER_ACCOUNT_ID = 2;
const HOLD_ACTION_ID = 'HOLD_ACTION_ID';
const REPORT_ACTION_ID = 'REPORT_ACTION_ID';
const TRANSACTION_ID = 'TRANSACTION_ID';
const CHILD_REPORT_ID = 'CHILD_REPORT_ID';

const report = {
reportID: REPORT_ID,
type: CONST.REPORT.TYPE.EXPENSE,
ownerAccountID: MEMBER_ACCOUNT_ID,
managerID: CURRENT_USER_ACCOUNT_ID,
stateNum: CONST.REPORT.STATE_NUM.SUBMITTED,
statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED,
} as unknown as Report;
await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report);

const policy = {
approver: CURRENT_USER_EMAIL,
approvalMode: CONST.POLICY.APPROVAL_MODE.BASIC,
} as unknown as Policy;

const transaction = {
transactionID: TRANSACTION_ID,
reportID: `${REPORT_ID}`,
comment: {
hold: HOLD_ACTION_ID,
},
} as unknown as Transaction;

const reportAction = {
actionName: CONST.REPORT.ACTIONS.TYPE.IOU,
type: CONST.REPORT.ACTIONS.TYPE.IOU,
reportActionID: REPORT_ACTION_ID,
actorAccountID: MEMBER_ACCOUNT_ID,
childReportID: CHILD_REPORT_ID,
childType: CONST.REPORT.TYPE.CHAT,
message: [
{
html: 'html',
},
],
originalMessage: {
type: CONST.IOU.REPORT_ACTION_TYPE.CREATE,
IOUTransactionID: TRANSACTION_ID,
},
} as unknown as ReportAction;

// The member created the hold, not the approver, so isRemoveHoldAction is false and the result depends on the all-held redirect
const holdAction = {
reportActionID: HOLD_ACTION_ID,
reportID: CHILD_REPORT_ID,
actorAccountID: MEMBER_ACCOUNT_ID,
};

await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, {[REPORT_ACTION_ID]: reportAction});
await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${CHILD_REPORT_ID}`, {[HOLD_ACTION_ID]: holdAction});

expect(
getReportPrimaryAction({
currentUserLogin: CURRENT_USER_EMAIL,
currentUserAccountID: CURRENT_USER_ACCOUNT_ID,
report,
chatReport,
reportTransactions: [transaction],
violations: {},
bankAccountList: {},
policy,
reportActions: [reportAction],
isChatReportArchived: false,
}),
).toBe(CONST.REPORT.PRIMARY_ACTIONS.REMOVE_HOLD);
});

it('should not return REMOVE HOLD for closed reports with transactions on hold', async () => {
const report = {
reportID: REPORT_ID,
Expand Down
Loading