Skip to content

Commit 94e1e10

Browse files
authored
Merge pull request Expensify#87979 from callstack-internal/perf/extract-useParticipantSubmission-hook
extract useParticipantSubmission hook
2 parents 7bc98b6 + 2081d56 commit 94e1e10

3 files changed

Lines changed: 419 additions & 368 deletions

File tree

Lines changed: 378 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,378 @@
1+
import {useEffect, useRef} from 'react';
2+
import type {OnyxEntry} from 'react-native-onyx';
3+
import {setTransactionReport} from '@libs/actions/Transaction';
4+
import {READ_COMMANDS} from '@libs/API/types';
5+
import DistanceRequestUtils from '@libs/DistanceRequestUtils';
6+
import HttpUtils from '@libs/HttpUtils';
7+
import Navigation from '@libs/Navigation/Navigation';
8+
import {isPaidGroupPolicy} from '@libs/PolicyUtils';
9+
import {findSelfDMReportID, generateReportID, isInvoiceRoomWithID} from '@libs/ReportUtils';
10+
import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils';
11+
import {isDistanceRequest} from '@libs/TransactionUtils';
12+
import {
13+
resetDraftTransactionsCustomUnit,
14+
setCustomUnitRateID,
15+
setMoneyRequestCategory,
16+
setMoneyRequestParticipants,
17+
setMoneyRequestParticipantsFromReport,
18+
setMoneyRequestTag,
19+
} from '@userActions/IOU';
20+
import {setSplitShares} from '@userActions/IOU/Split';
21+
import {createDraftWorkspace, generateDefaultWorkspaceName} from '@userActions/Policy/Policy';
22+
import CONST from '@src/CONST';
23+
import type {IOUAction, IOUType} from '@src/CONST';
24+
import ONYXKEYS from '@src/ONYXKEYS';
25+
import ROUTES from '@src/ROUTES';
26+
import {lastWorkspaceNumberSelector} from '@src/selectors/Policy';
27+
import type {Policy, Transaction} from '@src/types/onyx';
28+
import type {Participant} from '@src/types/onyx/IOU';
29+
import KeyboardUtils from '@src/utils/keyboard';
30+
import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails';
31+
import useLocalize from './useLocalize';
32+
import useMappedPolicies from './useMappedPolicies';
33+
import useOnyx from './useOnyx';
34+
import useOptimisticDraftTransactions from './useOptimisticDraftTransactions';
35+
import usePolicyForMovingExpenses from './usePolicyForMovingExpenses';
36+
import useTransactionsByID from './useTransactionsByID';
37+
38+
const policyMapper = (policy: OnyxEntry<Policy>): OnyxEntry<Policy> =>
39+
policy && {
40+
id: policy.id,
41+
name: policy.name,
42+
type: policy.type,
43+
role: policy.role,
44+
owner: policy.owner,
45+
outputCurrency: policy.outputCurrency,
46+
isPolicyExpenseChatEnabled: policy.isPolicyExpenseChatEnabled,
47+
customUnits: policy.customUnits,
48+
};
49+
50+
type UseParticipantSubmissionParams = {
51+
reportID: string;
52+
initialTransactionID: string;
53+
initialTransaction: OnyxEntry<Transaction>;
54+
participants: Participant[] | undefined;
55+
iouType: IOUType;
56+
action: IOUAction;
57+
backTo: string | undefined;
58+
isSplitRequest: boolean;
59+
isMovingTransactionFromTrackExpense: boolean;
60+
isFocused: boolean;
61+
};
62+
63+
function useParticipantSubmission({
64+
reportID,
65+
initialTransactionID,
66+
initialTransaction,
67+
participants,
68+
iouType,
69+
action,
70+
backTo,
71+
isSplitRequest,
72+
isMovingTransactionFromTrackExpense,
73+
isFocused,
74+
}: UseParticipantSubmissionParams) {
75+
const {translate} = useLocalize();
76+
77+
const [allPolicies] = useMappedPolicies(policyMapper);
78+
const [lastSelectedDistanceRates] = useOnyx(ONYXKEYS.NVP_LAST_SELECTED_DISTANCE_RATES);
79+
const selfDMReportID = findSelfDMReportID();
80+
const [selfDMReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReportID}`);
81+
const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID);
82+
const [activePolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${activePolicyID}`);
83+
const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED);
84+
const [userBillingGracePeriodEnds] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END);
85+
const [ownerBillingGracePeriodEnd] = useOnyx(ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END);
86+
const [amountOwed] = useOnyx(ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED);
87+
const currentUserPersonalDetails = useCurrentUserPersonalDetails();
88+
const {policyForMovingExpenses} = usePolicyForMovingExpenses();
89+
const [draftTransactions] = useOptimisticDraftTransactions(initialTransaction);
90+
// React Compiler memoizes `transactionIDs` — it only gets a new reference when `draftTransactions`
91+
// changes (i.e. on actual Onyx writes to COLLECTION.TRANSACTION_DRAFT). The search hot-path updates
92+
// RAM_ONLY_IS_SEARCHING_FOR_REPORTS instead, so `transactionIDs` stays stable during search and no
93+
// explicit useMemo is needed here.
94+
const transactionIDs = draftTransactions?.map((transaction) => transaction.transactionID);
95+
const [transactions] = useTransactionsByID(transactionIDs);
96+
97+
const isActivePolicyRequest =
98+
iouType === CONST.IOU.TYPE.CREATE &&
99+
isPaidGroupPolicy(activePolicy) &&
100+
activePolicy?.isPolicyExpenseChatEnabled &&
101+
!shouldRestrictUserBillableActions(activePolicy.id, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed);
102+
103+
const dataRef = useRef({
104+
allPolicies,
105+
lastSelectedDistanceRates,
106+
selfDMReportID,
107+
selfDMReport,
108+
introSelected,
109+
currentUserPersonalDetails,
110+
policyForMovingExpenses,
111+
draftTransactions,
112+
isActivePolicyRequest,
113+
participants,
114+
initialTransaction,
115+
translate,
116+
});
117+
118+
useEffect(() => {
119+
dataRef.current = {
120+
allPolicies,
121+
lastSelectedDistanceRates,
122+
selfDMReportID,
123+
selfDMReport,
124+
introSelected,
125+
currentUserPersonalDetails,
126+
policyForMovingExpenses,
127+
draftTransactions,
128+
isActivePolicyRequest,
129+
participants,
130+
initialTransaction,
131+
translate,
132+
};
133+
});
134+
135+
// We need to set selectedReportID if user has navigated back from confirmation page and navigates to confirmation page with already selected participant
136+
const selectedReportID = useRef<string>(participants?.length === 1 ? (participants.at(0)?.reportID ?? reportID) : reportID);
137+
const numberOfParticipants = useRef(participants?.length ?? 0);
138+
139+
// When the step opens, reset the draft transaction's custom unit if moved from Track Expense.
140+
// This resets the custom unit to the p2p rate when the destination workspace changes,
141+
// because we want to first check if the p2p rate exists on the workspace.
142+
// If it doesn't exist - we'll show an error message to force the user to choose a valid rate from the workspace.
143+
useEffect(() => {
144+
if (!isMovingTransactionFromTrackExpense || !isFocused || !transactions || transactions?.length === 0) {
145+
return;
146+
}
147+
148+
for (const transaction of transactions) {
149+
resetDraftTransactionsCustomUnit(transaction);
150+
}
151+
}, [isFocused, isMovingTransactionFromTrackExpense, transactions]);
152+
153+
useEffect(() => {
154+
const isCategorizing = action === CONST.IOU.ACTION.CATEGORIZE;
155+
const isShareAction = action === CONST.IOU.ACTION.SHARE;
156+
if (isFocused && (isCategorizing || isShareAction)) {
157+
for (const transaction of dataRef.current.draftTransactions) {
158+
setMoneyRequestParticipants(transaction.transactionID, []);
159+
}
160+
numberOfParticipants.current = 0;
161+
}
162+
}, [isFocused, action]);
163+
164+
const trackExpense = () => {
165+
const {
166+
selfDMReportID: dmReportID,
167+
selfDMReport: dmReport,
168+
draftTransactions: drafts,
169+
policyForMovingExpenses: movingPolicy,
170+
currentUserPersonalDetails: userDetails,
171+
isActivePolicyRequest: isActiveRequest,
172+
lastSelectedDistanceRates: distanceRates,
173+
} = dataRef.current;
174+
175+
// If coming from the combined submit/track flow and the user proceeds to just track the expense,
176+
// we will use the track IOU type in the confirmation flow.
177+
if (!dmReportID) {
178+
return;
179+
}
180+
181+
for (const transaction of drafts) {
182+
const rateID = DistanceRequestUtils.getCustomUnitRateID({
183+
reportID: dmReportID,
184+
isTrackDistanceExpense: isDistanceRequest(transaction),
185+
policy: movingPolicy,
186+
isPolicyExpenseChat: false,
187+
lastSelectedDistanceRates: distanceRates,
188+
});
189+
setCustomUnitRateID(transaction.transactionID, rateID, transaction, movingPolicy);
190+
const shouldSetParticipantAutoAssignment = iouType === CONST.IOU.TYPE.CREATE;
191+
setMoneyRequestParticipantsFromReport(transaction.transactionID, dmReport, userDetails.accountID, shouldSetParticipantAutoAssignment ? isActiveRequest : false);
192+
setTransactionReport(transaction.transactionID, {reportID: CONST.REPORT.UNREPORTED_REPORT_ID}, true);
193+
}
194+
const iouConfirmationPageRoute = ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(action, CONST.IOU.TYPE.TRACK, initialTransactionID, dmReportID);
195+
KeyboardUtils.dismissKeyboardAndExecute(() => {
196+
// If the backTo parameter is set, we should navigate back to the confirmation screen that is already on the stack.
197+
Navigation.setNavigationActionToMicrotaskQueue(() => {
198+
if (backTo) {
199+
// We don't want to compare params because we just changed the participants.
200+
Navigation.goBack(iouConfirmationPageRoute, {compareParams: false});
201+
} else {
202+
// We wrap navigation in setNavigationActionToMicrotaskQueue so that data loading in Onyx and navigation do not occur simultaneously, which resets the amount to 0.
203+
// More information can be found here: https://github.com/Expensify/App/issues/73728
204+
Navigation.navigate(iouConfirmationPageRoute);
205+
}
206+
});
207+
});
208+
};
209+
210+
const addParticipant = (val: Participant[]) => {
211+
HttpUtils.cancelPendingRequests(READ_COMMANDS.SEARCH_FOR_REPORTS);
212+
213+
const firstParticipant = val.at(0);
214+
215+
if (firstParticipant?.isSelfDM && !isSplitRequest) {
216+
trackExpense();
217+
return;
218+
}
219+
220+
const {allPolicies: policies, lastSelectedDistanceRates: distanceRates, draftTransactions: drafts} = dataRef.current;
221+
const firstParticipantReportID = val.at(0)?.reportID;
222+
const isPolicyExpenseChat = !!firstParticipant?.isPolicyExpenseChat;
223+
const policy = isPolicyExpenseChat && firstParticipant?.policyID ? policies?.[`${ONYXKEYS.COLLECTION.POLICY}${firstParticipant.policyID}`] : undefined;
224+
const isInvoice = iouType === CONST.IOU.TYPE.INVOICE;
225+
numberOfParticipants.current = val.length;
226+
227+
// Use transactions array if available, otherwise use initialTransactionID directly
228+
// This handles the case where initialTransaction hasn't loaded yet but we still need to set participants
229+
if (drafts.length > 0) {
230+
for (const transaction of drafts) {
231+
setMoneyRequestParticipants(transaction.transactionID, val);
232+
}
233+
} else {
234+
// Fallback to using initialTransactionID directly when transaction object isn't loaded yet
235+
setMoneyRequestParticipants(initialTransactionID, val);
236+
}
237+
238+
if (!isMovingTransactionFromTrackExpense || !isPolicyExpenseChat) {
239+
// If not moving the transaction from track expense, select the default rate automatically.
240+
// Otherwise, keep the original p2p rate and let the user manually change it to the one they want from the workspace.
241+
const rateID = DistanceRequestUtils.getCustomUnitRateID({reportID: firstParticipantReportID, isPolicyExpenseChat, policy, lastSelectedDistanceRates: distanceRates});
242+
243+
if (drafts.length > 0) {
244+
for (const transaction of drafts) {
245+
setCustomUnitRateID(transaction.transactionID, rateID, transaction, policy);
246+
}
247+
} else {
248+
// Fallback to using initialTransactionID directly
249+
setCustomUnitRateID(initialTransactionID, rateID, undefined, policy);
250+
}
251+
}
252+
253+
// When multiple valid participants are selected, the reportID is generated at the end of the confirmation step.
254+
// So we are resetting selectedReportID ref to the reportID coming from params.
255+
// For invoices, a valid participant must have a login.
256+
257+
const hasOneValidParticipant = iouType === CONST.IOU.TYPE.INVOICE && selectedReportID.current !== reportID ? val.filter((item) => !!item.login).length !== 1 : val.length !== 1;
258+
259+
if (hasOneValidParticipant && !isInvoice) {
260+
selectedReportID.current = reportID;
261+
return;
262+
}
263+
264+
// When a participant is selected, the reportID needs to be saved because that's the reportID that will be used in the confirmation step.
265+
// We use || to be sure that if the first participant doesn't have a reportID, we generate a new one.
266+
if (isInvoice) {
267+
selectedReportID.current = firstParticipantReportID && isInvoiceRoomWithID(firstParticipantReportID) ? firstParticipantReportID : generateReportID();
268+
} else {
269+
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
270+
selectedReportID.current = firstParticipantReportID || generateReportID();
271+
}
272+
};
273+
274+
const goToNextStep = (_value?: string, nextParticipants?: Participant[]) => {
275+
const {
276+
allPolicies: policies,
277+
draftTransactions: drafts,
278+
currentUserPersonalDetails: userDetails,
279+
introSelected: intro,
280+
participants: currentParticipants,
281+
initialTransaction: splitTransaction,
282+
policyForMovingExpenses: movingPolicy,
283+
} = dataRef.current;
284+
285+
const isCategorizing = action === CONST.IOU.ACTION.CATEGORIZE;
286+
const isShareAction = action === CONST.IOU.ACTION.SHARE;
287+
288+
// Prefer nextParticipants (passed directly from the selector callback) over currentParticipants
289+
// (last-rendered value from dataRef) because the Onyx write from addParticipant may not have
290+
// caused a re-render yet by the time goToNextStep is called.
291+
const effectiveParticipants = nextParticipants ?? currentParticipants;
292+
const isPolicyExpenseChat = effectiveParticipants?.some((participant) => participant.isPolicyExpenseChat);
293+
if (iouType === CONST.IOU.TYPE.SPLIT && !isPolicyExpenseChat && splitTransaction?.amount && splitTransaction?.currency) {
294+
const participantAccountIDs = effectiveParticipants?.map((participant) => participant.accountID) as number[];
295+
setSplitShares(splitTransaction, splitTransaction.amount, splitTransaction.currency, participantAccountIDs);
296+
}
297+
298+
const newReportID = selectedReportID.current;
299+
const currentSelfDMReportID = dataRef.current.selfDMReportID;
300+
const shouldUpdateTransactionReportID = effectiveParticipants?.at(0)?.reportID !== newReportID;
301+
const transactionReportID = newReportID === currentSelfDMReportID ? CONST.REPORT.UNREPORTED_REPORT_ID : newReportID;
302+
const firstParticipant = effectiveParticipants?.at(0);
303+
for (const transaction of drafts) {
304+
const tag = isMovingTransactionFromTrackExpense && transaction?.tag ? transaction?.tag : '';
305+
setMoneyRequestTag(transaction.transactionID, tag);
306+
const policy = isPolicyExpenseChat && firstParticipant?.policyID ? policies?.[`${ONYXKEYS.COLLECTION.POLICY}${firstParticipant.policyID}`] : undefined;
307+
const policyDistance = Object.values(policy?.customUnits ?? {}).find((customUnit) => customUnit.name === CONST.CUSTOM_UNITS.NAME_DISTANCE);
308+
const defaultCategory = isDistanceRequest(transaction) && policyDistance?.defaultCategory ? policyDistance?.defaultCategory : '';
309+
const category = isMovingTransactionFromTrackExpense ? (transaction?.category ?? '') : defaultCategory;
310+
setMoneyRequestCategory(transaction.transactionID, category, isMovingTransactionFromTrackExpense ? movingPolicy : undefined, isMovingTransactionFromTrackExpense);
311+
if (shouldUpdateTransactionReportID) {
312+
setTransactionReport(transaction.transactionID, {reportID: transactionReportID}, true);
313+
}
314+
}
315+
if ((isCategorizing || isShareAction) && numberOfParticipants.current === 0) {
316+
const email = userDetails.email ?? '';
317+
const lastWorkspaceNumber = lastWorkspaceNumberSelector(policies, email);
318+
const {expenseChatReportID, policyID, policyName} = createDraftWorkspace(
319+
intro,
320+
generateDefaultWorkspaceName(email, lastWorkspaceNumber, translate),
321+
userDetails.accountID,
322+
email,
323+
);
324+
for (const transaction of drafts) {
325+
setMoneyRequestParticipants(transaction.transactionID, [
326+
{
327+
selected: true,
328+
accountID: 0,
329+
isPolicyExpenseChat: true,
330+
reportID: expenseChatReportID,
331+
policyID,
332+
searchText: policyName,
333+
},
334+
]);
335+
}
336+
Navigation.setNavigationActionToMicrotaskQueue(() => {
337+
if (isCategorizing) {
338+
Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute(action, CONST.IOU.TYPE.SUBMIT, initialTransactionID, expenseChatReportID));
339+
} else {
340+
Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(action, CONST.IOU.TYPE.SUBMIT, initialTransactionID, expenseChatReportID, undefined, true));
341+
}
342+
});
343+
return;
344+
}
345+
346+
const iouConfirmationPageRoute = ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(
347+
action,
348+
iouType === CONST.IOU.TYPE.CREATE || iouType === CONST.IOU.TYPE.TRACK ? CONST.IOU.TYPE.SUBMIT : iouType,
349+
initialTransactionID,
350+
newReportID,
351+
undefined,
352+
undefined,
353+
action === CONST.IOU.ACTION.SHARE ? Navigation.getActiveRoute() : undefined,
354+
);
355+
356+
const route = isCategorizing
357+
? ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute(action, iouType, initialTransactionID, selectedReportID.current || reportID, iouConfirmationPageRoute)
358+
: iouConfirmationPageRoute;
359+
360+
KeyboardUtils.dismissKeyboardAndExecute(() => {
361+
// If the backTo parameter is set, we should navigate back to the confirmation screen that is already on the stack.
362+
// We wrap navigation in setNavigationActionToMicrotaskQueue so that data loading in Onyx and navigation do not occur simultaneously, which resets the amount to 0.
363+
// More information can be found here: https://github.com/Expensify/App/issues/73728
364+
Navigation.setNavigationActionToMicrotaskQueue(() => {
365+
if (backTo) {
366+
// We don't want to compare params because we just changed the participants.
367+
Navigation.goBack(route, {compareParams: false});
368+
} else {
369+
Navigation.navigate(route);
370+
}
371+
});
372+
});
373+
};
374+
375+
return {addParticipant, goToNextStep};
376+
}
377+
378+
export default useParticipantSubmission;

0 commit comments

Comments
 (0)