Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
getFieldViolationTranslation,
getReportFieldKey,
getReportFieldMaps,
getReportFieldsWithoutFailedAdds,
isReportFieldDisabledForUser,
isReportFieldTargetMatchingReport,
shouldDisplayReportFields as shouldDisplayReportFieldsUtils,
Expand Down Expand Up @@ -97,7 +98,7 @@ function ReportFieldView(
value={reportField.fieldValue}
isDisabled={reportField.isFieldDisabled}
errorText={reportField.violationTranslation}
fieldList={policy?.fieldList}
fieldList={getReportFieldsWithoutFailedAdds(policy?.fieldList, policy?.errorFields)}
onSaveValue={(value) => onSaveValue(reportField, value)}
onDelete={
reportField.isFieldDeletable
Expand Down Expand Up @@ -140,7 +141,7 @@ function MoneyRequestViewReportFields({report, policy, pendingAction, style, sho
deleteReportField(report.reportID, report.fieldList?.[reportField.fieldKey] ?? reportField);
};

const {fieldValues, fieldsByName} = getReportFieldMaps(report, policy?.fieldList ?? {}, reportNameValuePairs);
const {fieldValues, fieldsByName} = getReportFieldMaps(report, policy?.fieldList ?? {}, reportNameValuePairs, policy?.errorFields);
const fields = Object.values(fieldsByName);
const sortedPolicyReportFields: EnrichedPolicyReportField[] = fields
.filter((field) => isReportFieldTargetMatchingReport(report, field))
Expand Down
2 changes: 1 addition & 1 deletion src/components/ReportActionItem/MoneyReportView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ function MoneyReportView({
StyleUtils.getColorStyle(theme.textSupporting),
];

const {fieldsByName} = getReportFieldMaps(report, policy?.fieldList ?? {}, reportNameValuePairs);
const {fieldsByName} = getReportFieldMaps(report, policy?.fieldList ?? {}, reportNameValuePairs, policy?.errorFields);
const policyReportFields = Object.values(fieldsByName).filter((field) => isReportFieldTargetMatchingReport(report, field));

const isOnlyTitleFieldEnabled = policyReportFields.every(shouldHideSingleReportField);
Expand Down
3 changes: 2 additions & 1 deletion src/hooks/useAdvancedSearchFilters.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {isFilterableBankAccount} from '@libs/BankAccountUtils';
import {hasVendorFeatureOnAnyPolicy, isPolicyFeatureEnabled} from '@libs/PolicyUtils';
import {getReportFieldsWithoutFailedAdds} from '@libs/ReportUtils';

import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
Expand Down Expand Up @@ -246,7 +247,7 @@ const policyDerivedSelector = (policies: OnyxCollection<Policy>) => {
isAttendeeTrackingEnabled = isPolicyFeatureEnabled(policy, CONST.POLICY.MORE_FEATURES.IS_ATTENDEE_TRACKING_ENABLED);
}
if (!hasReportFields) {
hasReportFields = Object.values(policy.fieldList ?? {}).some((val) => val.type !== CONST.POLICY.DEFAULT_FIELD_LIST_TYPE);
hasReportFields = Object.values(getReportFieldsWithoutFailedAdds(policy.fieldList, policy.errorFields)).some((val) => val.type !== CONST.POLICY.DEFAULT_FIELD_LIST_TYPE);
}
if (!hasAnyTaxRates && policy.taxRates?.taxes && Object.keys(policy.taxRates.taxes).length > 0) {
hasAnyTaxRates = true;
Expand Down
43 changes: 36 additions & 7 deletions src/libs/ReportUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5624,7 +5624,8 @@ function isReportFieldDisabledForUser(
*/
function canEditReportTitle(report: OnyxEntry<Report>, policy: OnyxEntry<Policy>, currentUserAccountID: number | undefined, rules: OnyxCollection<Rule>): boolean {
const titleField =
getAvailableReportFields(report, Object.values(policy?.fieldList ?? {})).find((reportField) => isReportFieldOfTypeTitle(reportField)) ?? getTitleFieldWithFallback(policy);
getAvailableReportFields(report, Object.values(policy?.fieldList ?? {}), policy?.errorFields).find((reportField) => isReportFieldOfTypeTitle(reportField)) ??
getTitleFieldWithFallback(policy);
const isFieldDisabled = isReportFieldDisabled(report, titleField, policy, rules);

return !isFieldDisabled && isAdminOwnerApproverOrReportOwner(report, policy, currentUserAccountID) && isExpenseReport(report) && isGroupPolicyPolicyUtils(policy);
Expand Down Expand Up @@ -5666,6 +5667,24 @@ function getReportFieldKey(reportFieldId: string | undefined) {
return `expensify_${reportFieldId}`;
}

/**
* Whether a policy report field failed to be created. It stays on the policy, pending ADD with an error, until the error
* is dismissed. Fields still pending ADD without an error (for example, created offline) haven't failed.
*/
function isFailedReportFieldAdd(reportField: (PolicyReportField & {pendingAction?: PendingAction | null}) | undefined, policyErrorFields: ErrorFields | undefined): boolean {
return reportField?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD && !!policyErrorFields?.fieldList?.[getReportFieldKey(reportField.fieldID)];
}

/**
* Get the policy report fields without the ones that failed to be created
*/
function getReportFieldsWithoutFailedAdds<TReportField extends PolicyReportField & {pendingAction?: PendingAction | null}>(
fieldList: Record<string, TReportField> | undefined,
policyErrorFields: ErrorFields | undefined,
): Record<string, TReportField> {
return Object.fromEntries(Object.entries(fieldList ?? {}).filter(([, reportField]) => !isFailedReportFieldAdd(reportField, policyErrorFields)));
}

/**
* Get the report fields attached to the policy given policyID
*/
Expand All @@ -5689,7 +5708,11 @@ function getReportFieldsByPolicyID(policy: OnyxEntry<Policy>): Policy['fieldList
* Get the report fields that we should display a MoneyReportView gets opened
*/

function getAvailableReportFields(report: OnyxEntry<Report>, policyReportFields: PolicyReportField[]): PolicyReportField[] {
function getAvailableReportFields(
report: OnyxEntry<Report>,
policyReportFields: Array<PolicyReportField & {pendingAction?: PendingAction | null}>,
policyErrorFields?: ErrorFields,
): PolicyReportField[] {
// Get the report fields that are attached to a report. These will persist even if a field is deleted from the policy.
const reportFields = Object.values(report?.fieldList ?? {});
const reportIsSettled = isSettled(report?.reportID);
Expand All @@ -5701,7 +5724,10 @@ function getAvailableReportFields(report: OnyxEntry<Report>, policyReportFields:

// If the report is unsettled, we want to merge the new fields that get added to the policy with the fields that
// are attached to the report.
const mergedFieldIds = Array.from(new Set([...policyReportFields.map(({fieldID}) => fieldID), ...reportFields.map(({fieldID}) => fieldID)]));
// Policy fields that failed to be created are skipped.
const reportFieldIDs = new Set(reportFields.map(({fieldID}) => fieldID));
const availablePolicyReportFields = policyReportFields.filter((policyReportField) => !isFailedReportFieldAdd(policyReportField, policyErrorFields));
const mergedFieldIds = Array.from(new Set([...availablePolicyReportFields.map(({fieldID}) => fieldID), ...reportFieldIDs]));

const fields = mergedFieldIds.map((id) => {
const field = report?.fieldList?.[getReportFieldKey(id)] ?? report?.fieldList?.[id];
Expand Down Expand Up @@ -8002,7 +8028,7 @@ function buildOptimisticIOUReport(
parentReportID: chatReportID,
created,
lastVisibleActionCreated: created,
fieldList: policy?.fieldList,
fieldList: policy?.fieldList ? getReportFieldsWithoutFailedAdds(policy.fieldList, policy.errorFields) : undefined,
parentReportActionID,
};
}
Expand Down Expand Up @@ -8227,7 +8253,7 @@ function buildOptimisticExpenseReport({
}
// Otherwise, keep the default format: `${policyName} owes ${formattedTotal}`

expenseReport.fieldList = policy?.fieldList;
expenseReport.fieldList = policy?.fieldList ? getReportFieldsWithoutFailedAdds(policy.fieldList, policy.errorFields) : undefined;

return expenseReport;
}
Expand Down Expand Up @@ -10869,7 +10895,7 @@ function hasVisibleReportFieldViolations(
return false;
}

const {fieldsByName} = getReportFieldMaps(report, policy.fieldList, reportNameValuePairs);
const {fieldsByName} = getReportFieldMaps(report, policy.fieldList, reportNameValuePairs, policy.errorFields);

return Object.values(fieldsByName).some((field) => {
if (!isReportFieldTargetMatchingReport(report, field)) {
Expand Down Expand Up @@ -15116,8 +15142,9 @@ function getReportFieldMaps(
report: OnyxEntry<Report>,
fieldList: Record<string, PolicyReportField>,
reportNameValuePairs: OnyxEntry<ReportNameValuePairs>,
policyErrorFields?: ErrorFields,
): {fieldValues: Record<string, string>; fieldsByName: Record<string, PolicyReportField>} {
const availableFields = getAvailableReportFields(report, Object.values(fieldList ?? {}));
const availableFields = getAvailableReportFields(report, Object.values(fieldList ?? {}), policyErrorFields);
const invoiceReportFields = report?.type === CONST.REPORT.TYPE.INVOICE ? getInvoiceReportFieldsFromReportNameValuePairs(reportNameValuePairs) : [];
const fieldsMatchingReport = [...availableFields, ...invoiceReportFields].filter((field) => isReportFieldTargetMatchingReport(report, field));
const fields = [...new Map(fieldsMatchingReport.map((field) => [field.fieldID, field])).values()];
Expand Down Expand Up @@ -15518,6 +15545,8 @@ export {
getReimbursementQueuedActionMessage,
getReportDescription,
getReportFieldKey,
getReportFieldsWithoutFailedAdds,
isFailedReportFieldAdd,
getReportFieldMaps,
getReportIDFromLink,
getReportTransactions,
Expand Down
83 changes: 67 additions & 16 deletions src/libs/actions/Policy/ReportField.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import ONYXKEYS from '@src/ONYXKEYS';
import type {WorkspaceReportFieldForm} from '@src/types/form/WorkspaceReportFieldForm';
import INPUT_IDS from '@src/types/form/WorkspaceReportFieldForm';
import type {Policy, PolicyReportField} from '@src/types/onyx';
import type {OnyxValueWithOfflineFeedback} from '@src/types/onyx/OnyxCommon';
import type {ErrorFields, OnyxValueWithOfflineFeedback} from '@src/types/onyx/OnyxCommon';
import type {OnyxData} from '@src/types/onyx/Request';

import type {OnyxEntry, OnyxUpdate} from 'react-native-onyx';
Expand Down Expand Up @@ -95,6 +95,31 @@ type UpdateReportFieldInitialValueParams = {
policy: OnyxEntry<Policy>;
};

/**
* Returns the errorFields update that clears a single report field's errors without wiping other policy errors (such as the report title error).
*/
function getReportFieldErrorFieldsToClear(fieldKey: string) {
return {
fieldList: {
[fieldKey]: null,
},
};
}

/**
* Returns the errorFields update that sets a report field's generic failure error at errorFields.fieldList.<fieldKey>.defaultValue, where the server also returns its errors.
*/
function getReportFieldFailureErrorFields(fieldKey: string) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- ErrorFields doesn't model the nested fieldList.<fieldKey>.defaultValue shape the server uses, same as the report title error

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@MelvinBot Is there any way to fix this? If not, point me to similar functions.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No. The as unknown as ErrorFields cast can't be removed with a local change. Policy.errorFields is typed as ErrorFields, which is one level too shallow for fieldList.<fieldKey>.defaultValue.<microtime>. The App already writes the report title error the same way, with the same cast.

Similar functions:

Why a type change is out of scope
  • ErrorFields is Record<string, Errors>, and Errors is Record<string, string | null> (OnyxCommon.ts:27-30). So errorFields.fieldList.<fieldKey> has to be a string, not {defaultValue: {...}}.
  • Adding & {fieldList?: Record<string, ErrorFields>} doesn't work. The intersection with the string index collapses the values to null.
  • A separate PolicyErrorFields type with a union index would change what every reader of policy.errorFields gets back. There are about 320 readers in src, including getLatestErrorField(policy, ...). That belongs in its own PR.

The cast in the title code has no disable comment, so I assume it's covered by the lint baseline. The new helper uses an eslint-disable with a reason instead, so the cast stays in one documented place.


view run

return {
fieldList: {
[fieldKey]: {
defaultValue: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('workspace.reportFields.genericFailureMessage'),
},
},
} as unknown as ErrorFields;
}

function openPolicyReportFieldsPage(policyID: string) {
if (!policyID) {
Log.warn('openPolicyReportFieldsPage invalid params', {policyID});
Expand Down Expand Up @@ -237,7 +262,7 @@ function createReportField({name, type, initialValue, listValues, disabledListVa
fieldList: {
[fieldKey]: {...optimisticReportFieldDataForPolicy, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD},
},
errorFields: null,
errorFields: getReportFieldErrorFieldsToClear(fieldKey),
},
},
...(policyReportIDs ?? []).map(
Expand All @@ -258,12 +283,7 @@ function createReportField({name, type, initialValue, listValues, disabledListVa
key: `${ONYXKEYS.COLLECTION.POLICY}${policy?.id}`,
onyxMethod: Onyx.METHOD.MERGE,
value: {
fieldList: {
[fieldKey]: null,
},
errorFields: {
[fieldKey]: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('workspace.reportFields.genericFailureMessage'),
},
errorFields: getReportFieldFailureErrorFields(fieldKey),
},
},
...(policyReportIDs ?? []).map(
Expand All @@ -289,7 +309,7 @@ function createReportField({name, type, initialValue, listValues, disabledListVa
fieldList: {
[fieldKey]: {pendingAction: null},
},
errorFields: null,
errorFields: getReportFieldErrorFieldsToClear(fieldKey),
},
},
],
Expand Down Expand Up @@ -323,7 +343,10 @@ function deleteReportFields({policy, reportFieldsToUpdate}: DeleteReportFieldsPa
return;
}

const updatedReportFields = Object.fromEntries(Object.entries(allReportFields).filter(([key]) => !resolvedReportFieldKeys.includes(key)));
// Fields that failed to be created don't exist on the server, so they're left out of the remaining fields we send
const updatedReportFields = Object.fromEntries(
Object.entries(ReportUtils.getReportFieldsWithoutFailedAdds(allReportFields, policy.errorFields)).filter(([key]) => !resolvedReportFieldKeys.includes(key)),
);
const optimisticReportFields = resolvedReportFieldKeys.reduce<Record<string, Partial<OnyxValueWithOfflineFeedback<PolicyReportField>>>>((acc, reportFieldKey) => {
acc[reportFieldKey] = {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE};
return acc;
Expand All @@ -334,6 +357,9 @@ function deleteReportFields({policy, reportFieldsToUpdate}: DeleteReportFieldsPa
return acc;
}, {});

// Only the deleted fields' errors are cleared, so other fields keep theirs (for example, a field that failed to be created)
const successErrorFields = {fieldList: successReportFields};

const failureReportFields = resolvedReportFieldKeys.reduce<Record<string, Partial<OnyxValueWithOfflineFeedback<PolicyReportField>>>>((acc, reportFieldKey) => {
acc[reportFieldKey] = {pendingAction: null};
return acc;
Expand All @@ -355,7 +381,7 @@ function deleteReportFields({policy, reportFieldsToUpdate}: DeleteReportFieldsPa
key: `${ONYXKEYS.COLLECTION.POLICY}${policy?.id}`,
value: {
fieldList: successReportFields,
errorFields: null,
errorFields: successErrorFields,
},
},
],
Expand Down Expand Up @@ -427,7 +453,7 @@ function updateReportFieldInitialValue({policy, reportFieldID, newInitialValue}:
fieldList: {
[fieldKey]: {...updatedReportField, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE},
},
errorFields: null,
errorFields: getReportFieldErrorFieldsToClear(fieldKey),
},
},
],
Expand All @@ -439,7 +465,7 @@ function updateReportFieldInitialValue({policy, reportFieldID, newInitialValue}:
fieldList: {
[fieldKey]: {pendingAction: null},
},
errorFields: null,
errorFields: getReportFieldErrorFieldsToClear(fieldKey),
},
},
],
Expand All @@ -451,9 +477,7 @@ function updateReportFieldInitialValue({policy, reportFieldID, newInitialValue}:
fieldList: {
[fieldKey]: {...previousFieldList[fieldKey], pendingAction: null},
},
errorFields: {
[fieldKey]: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('workspace.reportFields.genericFailureMessage'),
},
errorFields: getReportFieldFailureErrorFields(fieldKey),
},
},
],
Expand Down Expand Up @@ -626,9 +650,36 @@ function removeReportFieldListValue({policy, reportFieldID, valueIndexes}: Remov
API.write(removeCommand, parameters, onyxData);
}

/**
* Clears a report field's errors. A field that failed to be created is removed from the list.
*/
function clearReportFieldErrors(policy: OnyxEntry<Policy>, reportFieldID: string) {
if (!policy?.id) {
return;
}

const fieldKey = ReportUtils.getReportFieldKey(reportFieldID);
const reportField = policy.fieldList?.[fieldKey];

if (reportField?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD) {
Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, {
fieldList: {
[fieldKey]: null,
},
errorFields: getReportFieldErrorFieldsToClear(fieldKey),
});
return;
}

Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, {
errorFields: getReportFieldErrorFieldsToClear(fieldKey),
});
}

export type {CreateReportFieldParams};

export {
clearReportFieldErrors,
setInitialCreateReportFieldsForm,
createReportFieldsListValue,
renameReportFieldsListValue,
Expand Down
6 changes: 4 additions & 2 deletions src/pages/DynamicEditReportFieldPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {getReportName} from '@libs/ReportNameUtils';
import {
getReportFieldFromReportNameValuePairs,
getReportFieldKey,
getReportFieldsWithoutFailedAdds,
getTitleFieldWithFallback,
isGroupPolicyExpenseReport,
isInvoiceReport,
Expand Down Expand Up @@ -134,6 +135,7 @@ function DynamicEditReportFieldPage({route}: DynamicEditReportFieldPageProps) {
const menuItems: PopoverMenuItem[] = [];

const isReportFieldDeletable = reportField.deletable && reportField?.fieldID !== CONST.REPORT_FIELD_TITLE_FIELD_ID;
const policyFieldList = getReportFieldsWithoutFailedAdds(policy?.fieldList, policy?.errorFields);

if (isReportFieldDeletable) {
menuItems.push({
Expand Down Expand Up @@ -168,7 +170,7 @@ function DynamicEditReportFieldPage({route}: DynamicEditReportFieldPageProps) {
fieldValue={fieldValue}
isRequired={!isReportFieldDeletable}
onSubmit={handleReportFieldChange}
fieldList={policy?.fieldList}
fieldList={policyFieldList}
/>
)}

Expand Down Expand Up @@ -198,7 +200,7 @@ function DynamicEditReportFieldPage({route}: DynamicEditReportFieldPageProps) {
fieldValue={fieldValue}
isRequired={!isReportFieldDeletable}
onSubmit={handleReportFieldChange}
fieldList={policy?.fieldList}
fieldList={policyFieldList}
disabled
/>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ function ReportDetailsTitleSectionContent({report}: ReportDetailsTitleSectionCon
const parentNavigationSubtitleData = getParentNavigationSubtitle(report, policy, conciergeReportID, translate, formatPhoneNumber, derivedParentReportName, isParentReportArchived);

const titleField: OnyxTypes.PolicyReportField | undefined = (() => {
const fields = getAvailableReportFields(report, Object.values(policy?.fieldList ?? {}));
const fields = getAvailableReportFields(report, Object.values(policy?.fieldList ?? {}), policy?.errorFields);
return fields.find((reportField) => isReportFieldOfTypeTitle(reportField));
})();
const fieldKey = getReportFieldKey(titleField?.fieldID);
Expand Down
Loading
Loading