Skip to content

Commit 3546b70

Browse files
authored
Merge pull request Expensify#76768 from Expensify/cmartins-todoHook
[No QA] Create useTodos hook
2 parents d3bf94b + 70be8ca commit 3546b70

3 files changed

Lines changed: 292 additions & 0 deletions

File tree

src/hooks/useTodos.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import {useMemo} from 'react';
2+
import {isApproveAction, isExportAction, isPrimaryPayAction, isSubmitAction} from '@libs/ReportPrimaryActionUtils';
3+
import CONST from '@src/CONST';
4+
import ONYXKEYS from '@src/ONYXKEYS';
5+
import type {Report, Transaction} from '@src/types/onyx';
6+
import useOnyx from './useOnyx';
7+
8+
export default function useTodos() {
9+
const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {canBeMissing: false});
10+
const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {canBeMissing: false});
11+
const [allReportNameValuePairs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, {canBeMissing: false});
12+
const [allTransactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION, {canBeMissing: false});
13+
const [allReportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS, {canBeMissing: false});
14+
15+
return useMemo(() => {
16+
const reportsToSubmit: Report[] = [];
17+
const reportsToApprove: Report[] = [];
18+
const reportsToPay: Report[] = [];
19+
const reportsToExport: Report[] = [];
20+
21+
const reports = allReports ? Object.values(allReports) : [];
22+
if (reports.length === 0) {
23+
return {reportsToSubmit, reportsToApprove, reportsToPay, reportsToExport};
24+
}
25+
26+
const transactionsByReportID: Record<string, Transaction[]> = {};
27+
if (allTransactions) {
28+
for (const transaction of Object.values(allTransactions)) {
29+
if (!transaction?.reportID) {
30+
continue;
31+
}
32+
(transactionsByReportID[transaction.reportID] ??= []).push(transaction);
33+
}
34+
}
35+
36+
for (const report of reports) {
37+
if (!report?.reportID || report.type !== CONST.REPORT.TYPE.EXPENSE) {
38+
continue;
39+
}
40+
const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`];
41+
const reportNameValuePair = allReportNameValuePairs?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report.chatReportID}`];
42+
const reportActions = Object.values(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`] ?? []);
43+
const reportTransactions = transactionsByReportID[report.reportID] ?? [];
44+
45+
if (isSubmitAction(report, reportTransactions, policy, reportNameValuePair)) {
46+
reportsToSubmit.push(report);
47+
}
48+
if (isApproveAction(report, reportTransactions, policy)) {
49+
reportsToApprove.push(report);
50+
}
51+
if (isPrimaryPayAction(report, policy, reportNameValuePair)) {
52+
reportsToPay.push(report);
53+
}
54+
if (isExportAction(report, policy, reportActions)) {
55+
reportsToExport.push(report);
56+
}
57+
}
58+
59+
return {reportsToSubmit, reportsToApprove, reportsToPay, reportsToExport};
60+
}, [allReports, allPolicies, allReportNameValuePairs, allTransactions, allReportActions]);
61+
}

src/libs/ReportPrimaryActionUtils.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -485,6 +485,8 @@ export {
485485
isAddExpenseAction,
486486
isPrimaryPayAction,
487487
isExportAction,
488+
isApproveAction,
489+
isSubmitAction,
488490
isMarkAsResolvedAction,
489491
isPrimaryMarkAsResolvedAction,
490492
getAllExpensesToHoldIfApplicable,

tests/unit/useTodosTest.ts

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
import {act, renderHook} from '@testing-library/react-native';
2+
import type {OnyxMultiSetInput} from 'react-native-onyx';
3+
import Onyx from 'react-native-onyx';
4+
import useTodos from '@hooks/useTodos';
5+
import CONST from '@src/CONST';
6+
import ONYXKEYS from '@src/ONYXKEYS';
7+
import type {Policy, Report, Transaction} from '@src/types/onyx';
8+
import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct';
9+
10+
const CURRENT_USER_ACCOUNT_ID = 1;
11+
const CURRENT_USER_EMAIL = 'tester@mail.com';
12+
const OTHER_USER_ACCOUNT_ID = 2;
13+
14+
const POLICY_ID = 'policy123';
15+
const POLICY_WITH_CONNECTION_ID = 'policy_with_connection';
16+
17+
// This keeps the error "@rnmapbox/maps native code not available." from causing the tests to fail
18+
jest.mock('@components/ConfirmedRoute.tsx');
19+
20+
const createMockReport = (reportID: string, overrides: Partial<Report> = {}): Report =>
21+
({
22+
reportID,
23+
chatReportID: `chat_${reportID}`,
24+
policyID: POLICY_ID,
25+
type: CONST.REPORT.TYPE.EXPENSE,
26+
ownerAccountID: CURRENT_USER_ACCOUNT_ID,
27+
managerID: OTHER_USER_ACCOUNT_ID,
28+
stateNum: CONST.REPORT.STATE_NUM.OPEN,
29+
statusNum: CONST.REPORT.STATUS_NUM.OPEN,
30+
...overrides,
31+
}) as Report;
32+
33+
const createMockPolicy = (policyID: string, overrides: Partial<Policy> = {}): Policy =>
34+
({
35+
id: policyID,
36+
type: CONST.POLICY.TYPE.TEAM,
37+
approvalMode: CONST.POLICY.APPROVAL_MODE.BASIC,
38+
role: CONST.POLICY.ROLE.USER,
39+
...overrides,
40+
}) as Policy;
41+
42+
const createMockTransaction = (transactionID: string, reportID: string, overrides: Partial<Transaction> = {}): Transaction =>
43+
({
44+
transactionID,
45+
reportID,
46+
amount: 100,
47+
modifiedAmount: 0,
48+
reimbursable: true,
49+
...overrides,
50+
}) as Transaction;
51+
52+
describe('useTodos', () => {
53+
beforeAll(() => {
54+
Onyx.init({
55+
keys: ONYXKEYS,
56+
});
57+
});
58+
59+
beforeEach(async () => {
60+
await Onyx.clear();
61+
await waitForBatchedUpdatesWithAct();
62+
});
63+
64+
describe('categorizes reports correctly', () => {
65+
// Report IDs for each category
66+
const SUBMIT_REPORT_IDS = ['submit_1', 'submit_2', 'submit_3', 'submit_4'];
67+
const APPROVE_REPORT_IDS = ['approve_1', 'approve_2', 'approve_3'];
68+
const PAY_REPORT_IDS = ['pay_1', 'pay_2'];
69+
const EXPORT_REPORT_ID = 'export_1';
70+
const EXCLUDED_REPORT_IDS = ['excluded_1', 'excluded_2'];
71+
72+
beforeEach(async () => {
73+
// Create 4 reports that can be submitted (open, owned by current user, with transactions)
74+
const reportsToSubmit = SUBMIT_REPORT_IDS.map((id) =>
75+
createMockReport(id, {
76+
stateNum: CONST.REPORT.STATE_NUM.OPEN,
77+
statusNum: CONST.REPORT.STATUS_NUM.OPEN,
78+
ownerAccountID: CURRENT_USER_ACCOUNT_ID,
79+
}),
80+
);
81+
82+
// Create 3 reports that can be approved (submitted, current user is manager, with transactions)
83+
const reportsToApprove = APPROVE_REPORT_IDS.map((id) =>
84+
createMockReport(id, {
85+
stateNum: CONST.REPORT.STATE_NUM.SUBMITTED,
86+
statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED,
87+
ownerAccountID: OTHER_USER_ACCOUNT_ID,
88+
managerID: CURRENT_USER_ACCOUNT_ID,
89+
}),
90+
);
91+
92+
// Create 2 reports that can be paid (approved, current user is admin/payer, with reimbursable transactions)
93+
const reportsToPay = PAY_REPORT_IDS.map((id) =>
94+
createMockReport(id, {
95+
stateNum: CONST.REPORT.STATE_NUM.APPROVED,
96+
statusNum: CONST.REPORT.STATUS_NUM.APPROVED,
97+
ownerAccountID: OTHER_USER_ACCOUNT_ID,
98+
managerID: CURRENT_USER_ACCOUNT_ID,
99+
total: -100,
100+
}),
101+
);
102+
103+
// Create 1 report that can be exported:
104+
// - Approved status
105+
// - User is admin
106+
// - Policy has a valid accounting connection with auto-sync disabled
107+
// - Not waiting on bank account
108+
const reportToExport = createMockReport(EXPORT_REPORT_ID, {
109+
policyID: POLICY_WITH_CONNECTION_ID,
110+
stateNum: CONST.REPORT.STATE_NUM.APPROVED,
111+
statusNum: CONST.REPORT.STATUS_NUM.APPROVED,
112+
ownerAccountID: OTHER_USER_ACCOUNT_ID,
113+
isWaitingOnBankAccount: false,
114+
});
115+
116+
// Create 2 reports that don't fit any condition:
117+
// 1. A chat report (not expense type)
118+
// 2. An expense report owned by another user that's not submitted (can't submit, approve, pay, or export)
119+
const excludedReports = [
120+
createMockReport(EXCLUDED_REPORT_IDS.at(0) ?? '', {
121+
type: CONST.REPORT.TYPE.CHAT, // Not an expense report
122+
}),
123+
createMockReport(EXCLUDED_REPORT_IDS.at(1) ?? '', {
124+
stateNum: CONST.REPORT.STATE_NUM.OPEN,
125+
statusNum: CONST.REPORT.STATUS_NUM.OPEN,
126+
ownerAccountID: OTHER_USER_ACCOUNT_ID, // Not owned by current user, so can't submit
127+
managerID: OTHER_USER_ACCOUNT_ID, // Not managed by current user, so can't approve
128+
}),
129+
];
130+
131+
// Create main policy (for submit, approve, pay reports)
132+
const policy = createMockPolicy(POLICY_ID, {
133+
approvalMode: CONST.POLICY.APPROVAL_MODE.BASIC,
134+
role: CONST.POLICY.ROLE.ADMIN,
135+
ownerAccountID: CURRENT_USER_ACCOUNT_ID,
136+
reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES,
137+
});
138+
139+
// Create policy with accounting connection (for export report)
140+
// Cast to Policy to avoid strict type checking on connection config
141+
const policyWithConnection = {
142+
...createMockPolicy(POLICY_WITH_CONNECTION_ID, {
143+
role: CONST.POLICY.ROLE.ADMIN,
144+
}),
145+
connections: {
146+
// QuickBooks Online connection with auto-sync disabled
147+
[CONST.POLICY.CONNECTIONS.NAME.QBO]: {
148+
lastSync: {
149+
isConnected: true,
150+
isSuccessful: true,
151+
isAuthenticationError: false,
152+
source: 'DIRECT',
153+
},
154+
config: {
155+
autoSync: {
156+
jobID: 'job123',
157+
enabled: false, // Auto-sync disabled so manual export is available
158+
},
159+
},
160+
},
161+
},
162+
} as Policy;
163+
164+
// Create transactions for each report that needs them
165+
const transactions: Record<string, Transaction> = {};
166+
167+
// Transactions for submit reports
168+
for (const reportID of SUBMIT_REPORT_IDS) {
169+
const transactionID = `trans_submit_${reportID}`;
170+
transactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`] = createMockTransaction(transactionID, reportID);
171+
}
172+
173+
// Transactions for approve reports
174+
for (const reportID of APPROVE_REPORT_IDS) {
175+
const transactionID = `trans_approve_${reportID}`;
176+
transactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`] = createMockTransaction(transactionID, reportID);
177+
}
178+
179+
// Transactions for pay reports (must be reimbursable)
180+
for (const reportID of PAY_REPORT_IDS) {
181+
const transactionID = `trans_pay_${reportID}`;
182+
transactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`] = createMockTransaction(transactionID, reportID, {
183+
reimbursable: true,
184+
});
185+
}
186+
187+
// Build reports object
188+
const reports: Record<string, Report> = {};
189+
for (const report of [...reportsToSubmit, ...reportsToApprove, ...reportsToPay, reportToExport, ...excludedReports]) {
190+
reports[`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`] = report;
191+
}
192+
193+
await act(async () => {
194+
await Onyx.multiSet({
195+
[ONYXKEYS.SESSION]: {
196+
email: CURRENT_USER_EMAIL,
197+
accountID: CURRENT_USER_ACCOUNT_ID,
198+
},
199+
[`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`]: policy,
200+
[`${ONYXKEYS.COLLECTION.POLICY}${POLICY_WITH_CONNECTION_ID}`]: policyWithConnection,
201+
...reports,
202+
...transactions,
203+
} as unknown as OnyxMultiSetInput);
204+
await waitForBatchedUpdatesWithAct();
205+
});
206+
});
207+
208+
it('returns correct number of reports for each category', async () => {
209+
const {result} = renderHook(() => useTodos());
210+
await waitForBatchedUpdatesWithAct();
211+
212+
expect(result.current.reportsToSubmit.length).toBe(4);
213+
expect(result.current.reportsToApprove.length).toBe(3);
214+
expect(result.current.reportsToPay.length).toBe(2);
215+
expect(result.current.reportsToExport.length).toBe(1);
216+
217+
for (const id of SUBMIT_REPORT_IDS) {
218+
expect(result.current.reportsToSubmit.map((r) => r.reportID)).toContain(id);
219+
}
220+
for (const id of APPROVE_REPORT_IDS) {
221+
expect(result.current.reportsToApprove.map((r) => r.reportID)).toContain(id);
222+
}
223+
for (const id of PAY_REPORT_IDS) {
224+
expect(result.current.reportsToPay.map((r) => r.reportID)).toContain(id);
225+
}
226+
expect(result.current.reportsToExport.map((r) => r.reportID)).toContain(EXPORT_REPORT_ID);
227+
});
228+
});
229+
});

0 commit comments

Comments
 (0)