-
Notifications
You must be signed in to change notification settings - Fork 3.9k
Expand file tree
/
Copy pathuseFormErrorManagement.ts
More file actions
274 lines (227 loc) · 10.6 KB
/
Copy pathuseFormErrorManagement.ts
File metadata and controls
274 lines (227 loc) · 10.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
import useDebouncedState from '@hooks/useDebouncedState';
import useLocalize from '@hooks/useLocalize';
import {isAttendeeTrackingEnabled} from '@libs/PolicyUtils';
import {areRequiredFieldsEmpty, getTag, hasMissingSmartscanFields, isMerchantMissing} from '@libs/TransactionUtils';
import {isInvalidMerchantValue, isValidInputLength} from '@libs/ValidationUtils';
import {getIsViolationFixed} from '@libs/Violations/ViolationsUtils';
import CONST from '@src/CONST';
import type {TranslationPaths} from '@src/languages/types';
import type * as OnyxTypes from '@src/types/onyx';
import type {Attendee} from '@src/types/onyx/IOU';
import type {CurrentUserPersonalDetails} from '@src/types/onyx/PersonalDetails';
import type {OnyxEntry} from 'react-native-onyx';
import {useIsFocused} from '@react-navigation/native';
import {useEffect, useRef} from 'react';
type UseFormErrorManagementParams = {
/** Transaction being confirmed */
transaction: OnyxEntry<OnyxTypes.Transaction>;
/** Report the IOU is being created on */
transactionReport: OnyxEntry<OnyxTypes.Report>;
/** Current merchant value entered for the IOU */
iouMerchant: string | undefined;
/** Currently selected category */
iouCategory: string;
/** Currently selected attendees */
iouAttendees: Attendee[];
/** Policy the IOU belongs to */
policy: OnyxEntry<OnyxTypes.Policy>;
/** Policy tag lists, used for tag validation */
policyTags: OnyxEntry<OnyxTypes.PolicyTagLists>;
/** Policy categories, used for category validation */
policyCategories: OnyxEntry<OnyxTypes.PolicyCategories>;
/** Personal details of the current user */
currentUserPersonalDetails: CurrentUserPersonalDetails;
/** Whether we are editing an existing split bill */
isEditingSplitBill: boolean | undefined;
/** Whether this is a policy-expense chat (drives merchant requirement) */
isPolicyExpenseChat: boolean;
/** Whether the IOU was started from a SmartScan flow */
isScanRequest: boolean;
/** Whether the merchant field should be visible in the UI */
shouldShowMerchant: boolean;
/** Whether SmartScan failed to read the receipt */
hasSmartScanFailed: boolean | undefined;
/** Whether the user has already confirmed the split */
didConfirmSplit: boolean;
/** Truthy when the route to the confirmation page has a known error */
routeError: string | null | undefined;
/** Whether the current IOU type is split */
isTypeSplit: boolean;
/** Whether splits are rendered read-only (suppresses some field errors) */
shouldShowReadOnlySplits: boolean;
/** Whether the transaction is a distance request (its amount is read-only, so amount errors are not shown inline) */
isDistanceRequest: boolean;
};
type UseFormErrorManagementResult = {
/** Current form-level error key, or '' when no error is set */
formError: TranslationPaths | '';
/** Debounced version of `formError`, used for the visible message */
debouncedFormError: TranslationPaths | '';
/** Setter for the form-level error key */
setFormError: (value: TranslationPaths | '') => void;
/** Clears the current form error if it matches one of the provided keys */
clearFormErrors: (errors: string[]) => void;
/** Whether per-field errors should be shown (only true when editing a split bill) */
shouldDisplayFieldError: boolean;
/** Whether the merchant field is currently empty / partial */
isMerchantEmpty: boolean;
/** Whether the merchant field is required for this flow */
isMerchantRequired: boolean;
/** Whether the current merchant value passes validation */
isMerchantFieldValid: boolean;
/** Whether a previously surfaced violation has been resolved */
isViolationFixed: boolean;
/** User-visible error message derived from `routeError` and `debouncedFormError` */
errorMessage: string | undefined;
};
/**
* Owns the form-error state for the Money Request confirmation flow.
*
* Holds a debounced form-error string, exposes setters and clearing helpers used by the
* controllers, and derives merchant validity, the violation-fixed flag, and the user-
* visible error message. `shouldDisplayFieldError` is gated on edit-split-bill mode so
* field-level errors only render in that flow. `errorMessage` prefers `routeError`,
* then suppresses errors that are already surfaced inline (missingAttendees, the tax amount
* error, and the manual-flow amount/date/merchant required/invalid errors, except the distance-amount
* error which has no inline surface) so they don't show twice.
*/
function useFormErrorManagement({
transaction,
transactionReport,
iouMerchant,
iouCategory,
iouAttendees,
policy,
policyTags,
policyCategories,
currentUserPersonalDetails,
isEditingSplitBill,
isPolicyExpenseChat,
isScanRequest,
shouldShowMerchant,
hasSmartScanFailed,
didConfirmSplit,
routeError,
isTypeSplit,
shouldShowReadOnlySplits,
isDistanceRequest,
}: UseFormErrorManagementParams): UseFormErrorManagementResult {
const isFocused = useIsFocused();
const {translate} = useLocalize();
const [formError, debouncedFormError, setFormError] = useDebouncedState<TranslationPaths | ''>('');
// Clear the form error if it's set to one among the list passed as an argument
const clearFormErrors = (errors: string[]) => {
if (!errors.includes(formError)) {
return;
}
setFormError('');
};
const shouldDisplayFieldError: boolean =
!!isEditingSplitBill &&
((!!hasSmartScanFailed && hasMissingSmartscanFields(transaction, transactionReport)) || (didConfirmSplit && areRequiredFieldsEmpty(transaction, transactionReport)));
const isMerchantEmpty = !iouMerchant || isMerchantMissing(transaction);
const isMerchantRequired = isPolicyExpenseChat && (!isScanRequest || !!isEditingSplitBill) && shouldShowMerchant;
const isMerchantFieldValid = (() => {
const merchantValue = iouMerchant ?? '';
const trimmedMerchant = merchantValue.trim();
const {isValid} = isValidInputLength(merchantValue, CONST.MERCHANT_NAME_MAX_BYTES);
if (!isValid) {
return false;
}
if (!trimmedMerchant) {
return !isMerchantRequired;
}
return !isInvalidMerchantValue(trimmedMerchant);
})();
const isViolationFixed = getIsViolationFixed(formError, {
category: iouCategory,
tag: getTag(transaction),
taxCode: transaction?.taxCode,
taxValue: transaction?.taxValue,
policyCategories,
policyTagLists: policyTags,
policyTaxRates: policy?.taxRates?.taxes,
iouAttendees,
currentUserPersonalDetails,
isAttendeeTrackingEnabled: isAttendeeTrackingEnabled(policy),
isControlPolicy: policy?.type === CONST.POLICY.TYPE.CORPORATE,
});
// Mirror formError into a ref so the effect below can read the current value without listing
// formError as a dependency. We don't want this effect to re-run just because formError changed —
// it should only react to focus / validation-state changes. (setFormError is stable across
// renders because useDebouncedState memoizes its setter.)
const formErrorRef = useRef(formError);
useEffect(() => {
formErrorRef.current = formError;
}, [formError]);
useEffect(() => {
const currentFormError = formErrorRef.current;
if (shouldDisplayFieldError && didConfirmSplit) {
setFormError('iou.error.genericSmartscanFailureMessage');
return;
}
if (shouldDisplayFieldError && hasSmartScanFailed) {
setFormError('iou.receiptScanningFailed');
return;
}
if (currentFormError === 'iou.error.invalidMerchant' && isMerchantFieldValid) {
setFormError('');
return;
}
// Check 1: If formError does NOT start with "violations.", clear it and return
// Reset the form error whenever the screen gains or loses focus
// but preserve violation-related errors since those represent real validation issues
// that can only be resolved by fixing the underlying issue
if (currentFormError && !currentFormError.startsWith(CONST.VIOLATIONS_PREFIX)) {
setFormError('');
return;
}
// Check 2: Only reached if formError STARTS with "violations."
// Clear any violation error if the user has fixed the underlying issue
if (isViolationFixed) {
setFormError('');
}
}, [isFocused, shouldDisplayFieldError, hasSmartScanFailed, didConfirmSplit, isViolationFixed, isMerchantFieldValid, setFormError]);
const computeErrorMessage = (): string | undefined => {
if (routeError) {
return routeError;
}
if (isTypeSplit && !shouldShowReadOnlySplits) {
return debouncedFormError ? translate(debouncedFormError) : undefined;
}
// Don't show error at the bottom of the form for missing attendees — the field surfaces it inline.
if (formError === 'violations.missingAttendees') {
return undefined;
}
// The tax amount error is a parameterized message surfaced inline on the tax amount field, so skip it here.
if (formError === 'iou.error.invalidTaxAmount') {
return undefined;
}
// The amount/date/merchant fields surface these required/invalid errors inline, so
// don't repeat them at the bottom of the form (which would show "This field is required" twice).
if (formError === 'common.error.fieldRequired' || formError === 'iou.error.invalidMerchant') {
return undefined;
}
// `common.error.invalidAmount` is only surfaced inline when the editable amount input is rendered. Distance requests
// disable that input (the amount falls back to a read-only menu row that doesn't show this error), so keep the
// distance-amount validation error in the footer — otherwise an invalid distance expense would fail silently.
if (!isDistanceRequest && formError === 'common.error.invalidAmount') {
return undefined;
}
return formError ? translate(formError) : undefined;
};
const errorMessage = computeErrorMessage();
return {
formError,
debouncedFormError,
setFormError,
clearFormErrors,
shouldDisplayFieldError,
isMerchantEmpty,
isMerchantRequired,
isMerchantFieldValid,
isViolationFixed,
errorMessage,
};
}
export default useFormErrorManagement;