Skip to content

Commit b46dee1

Browse files
authored
Merge pull request Expensify#80254 from Expensify/neil-fix-uneditable-title
[No QA] Allow editing report titles if title field is missing
2 parents e0727c9 + b8f787c commit b46dee1

12 files changed

Lines changed: 404 additions & 75 deletions

File tree

src/CONST/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1551,6 +1551,8 @@ const CONST = {
15511551
OWNER_EMAIL_FAKE: '__FAKE__',
15521552
OWNER_ACCOUNT_ID_FAKE: 0,
15531553
DEFAULT_REPORT_NAME: 'Chat Report',
1554+
// Not translated because default report names are not translated on the backend (matches Expensify Classic behavior)
1555+
DEFAULT_EXPENSE_REPORT_NAME: 'New Report',
15541556
PERMISSIONS: {
15551557
READ: 'read',
15561558
WRITE: 'write',

src/libs/PolicyUtils.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,13 @@ Onyx.connect({
7979
callback: (value) => (allPolicies = value),
8080
});
8181

82+
/**
83+
* Returns true if the policy has no fieldList or its fieldList is empty.
84+
*/
85+
function isPolicyFieldListEmpty(policy: OnyxEntry<Policy>): boolean {
86+
return !policy?.fieldList || Object.keys(policy.fieldList).length === 0;
87+
}
88+
8289
/**
8390
* Filter out the active policies, which will exclude policies with pending deletion
8491
* and policies the current user doesn't belong to.
@@ -2005,6 +2012,7 @@ export {
20052012
hasEligibleActiveAdminFromWorkspaces,
20062013
isPolicyEmployee,
20072014
isPolicyFeatureEnabled,
2015+
isPolicyFieldListEmpty,
20082016
getUberConnectionErrorDirectlyFromPolicy,
20092017
isPolicyOwner,
20102018
isPolicyMember,

src/libs/ReportNameUtils.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ import {translateLocal} from './Localize';
2929
import {getForReportAction, getMovedReportID} from './ModifiedExpenseMessage';
3030
import Parser from './Parser';
3131
import {getDisplayNameOrDefault} from './PersonalDetailsUtils';
32-
import {getCleanedTagName, isPolicyAdmin} from './PolicyUtils';
32+
import {getCleanedTagName, isPolicyAdmin, isPolicyFieldListEmpty} from './PolicyUtils';
3333
import {
3434
getActionableCardFraudAlertResolutionMessage,
3535
getAutoPayApprovedReportsEnabledMessage,
@@ -321,6 +321,13 @@ function getInvoicePayerName(report: OnyxEntry<Report>, invoiceReceiverPolicy?:
321321
* Get the title for an IOU or expense chat which will be showing the payer and the amount
322322
*/
323323
function getMoneyRequestReportName({report, policy, invoiceReceiverPolicy}: {report: OnyxEntry<Report>; policy?: OnyxEntry<Policy>; invoiceReceiverPolicy?: OnyxEntry<Policy>}): string {
324+
// For expense reports with empty fieldList and empty reportName, return "New Report" (matches OldDot behavior)
325+
if (isExpenseReport(report)) {
326+
if (report?.reportName === '' && isPolicyFieldListEmpty(policy)) {
327+
return CONST.REPORT.DEFAULT_EXPENSE_REPORT_NAME;
328+
}
329+
}
330+
324331
if (report?.reportName && isExpenseReport(report)) {
325332
return report.reportName;
326333
}

src/libs/ReportUtils.ts

Lines changed: 71 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ import {
166166
isPendingDeletePolicy,
167167
isPolicyAdmin as isPolicyAdminPolicyUtils,
168168
isPolicyAuditor,
169+
isPolicyFieldListEmpty,
169170
isPolicyMember,
170171
isPolicyMemberWithoutPendingDelete,
171172
isPolicyOwner,
@@ -993,6 +994,24 @@ Onyx.connectWithoutView({
993994
// Example case: when we need to get a report name of a thread which is dependent on a report action message.
994995
const parsedReportActionMessageCache: Record<string, string> = {};
995996

997+
/**
998+
* Fallback title field used when a policy has an empty fieldList (matches OldDot behavior).
999+
*/
1000+
const FALLBACK_TITLE_FIELD: PolicyReportField = {
1001+
fieldID: CONST.REPORT_FIELD_TITLE_FIELD_ID,
1002+
name: CONST.POLICY.DEFAULT_FIELD_LIST_NAME,
1003+
type: CONST.REPORT_FIELD_TYPES.TEXT,
1004+
defaultValue: CONST.REPORT.DEFAULT_EXPENSE_REPORT_NAME,
1005+
deletable: true,
1006+
target: CONST.POLICY.DEFAULT_FIELD_LIST_TARGET,
1007+
values: [],
1008+
keys: [],
1009+
externalIDs: [],
1010+
disabledOptions: [],
1011+
orderWeight: 0,
1012+
isTax: false,
1013+
};
1014+
9961015
let conciergeReportIDOnyxConnect: OnyxEntry<string>;
9971016
Onyx.connect({
9981017
key: ONYXKEYS.CONCIERGE_REPORT_ID,
@@ -4427,6 +4446,22 @@ function getTitleReportField(reportFields: Record<string, PolicyReportField>) {
44274446
return Object.values(reportFields).find((field) => isReportFieldOfTypeTitle(field));
44284447
}
44294448

4449+
/**
4450+
* Gets the title field from a policy, with a fallback when the policy fieldList is empty (matches OldDot behavior).
4451+
*/
4452+
function getTitleFieldWithFallback(policy: OnyxEntry<Policy>): PolicyReportField | undefined {
4453+
const policyTitleField = policy?.fieldList?.[CONST.POLICY.FIELDS.FIELD_LIST_TITLE];
4454+
if (policyTitleField) {
4455+
return policyTitleField;
4456+
}
4457+
4458+
if (isPolicyFieldListEmpty(policy)) {
4459+
return FALLBACK_TITLE_FIELD;
4460+
}
4461+
4462+
return undefined;
4463+
}
4464+
44304465
/**
44314466
* Get the key for a report field
44324467
*/
@@ -6579,6 +6614,30 @@ function buildOptimisticInvoiceReport(
65796614
return invoiceReport;
65806615
}
65816616

6617+
/**
6618+
* Computes the optimistic report name using the policy's title field formula, with a fallback to the default expense report name.
6619+
*/
6620+
function computeOptimisticReportName(report: Report, policy: OnyxEntry<Policy>, policyID: string | undefined, reportTransactions: Record<string, Transaction>): string | null {
6621+
if (!isGroupPolicy(policy?.type ?? '')) {
6622+
return null;
6623+
}
6624+
6625+
const titleReportField = getTitleReportField(getReportFieldsByPolicyID(policyID) ?? {});
6626+
const formulaContext: FormulaContext = {
6627+
report,
6628+
policy,
6629+
allTransactions: reportTransactions,
6630+
};
6631+
6632+
// We use dynamic require here to avoid a circular dependency between ReportUtils and Formula
6633+
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires
6634+
const Formula = require('./Formula') as {compute: (formula?: string, context?: FormulaContext) => string};
6635+
6636+
// If there is no title field, use "New Report" as default (matches OldDot behavior)
6637+
const defaultValue = titleReportField?.defaultValue ?? CONST.REPORT.DEFAULT_EXPENSE_REPORT_NAME;
6638+
return Formula.compute(defaultValue, formulaContext);
6639+
}
6640+
65826641
/**
65836642
* Returns the stateNum and statusNum for an expense report based on the policy settings
65846643
* @param policy
@@ -6688,20 +6747,12 @@ function buildOptimisticExpenseReport({
66886747
expenseReport.managerID = submitToAccountID;
66896748
}
66906749

6691-
const titleReportField = getTitleReportField(getReportFieldsByPolicyID(policyID) ?? {});
6692-
if (!!titleReportField && isGroupPolicy(policy?.type ?? '')) {
6693-
const formulaContext: FormulaContext = {
6694-
report: expenseReport,
6695-
policy,
6696-
allTransactions: reportTransactions ?? {},
6697-
};
6698-
6699-
// We use dynamic require here to avoid a circular dependency between ReportUtils and Formula
6700-
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires
6701-
const Formula = require('./Formula') as {compute: (formula?: string, context?: FormulaContext) => string};
6702-
const computedName = Formula.compute(titleReportField.defaultValue, formulaContext);
6703-
expenseReport.reportName = computedName ?? expenseReport.reportName;
6750+
// Compute optimistic report name if applicable
6751+
const computedName = computeOptimisticReportName(expenseReport, policy, policyID, reportTransactions ?? {});
6752+
if (computedName !== null) {
6753+
expenseReport.reportName = computedName;
67046754
}
6755+
// Otherwise, keep the default format: `${policyName} owes ${formattedTotal}`
67056756

67066757
expenseReport.fieldList = policy?.fieldList;
67076758

@@ -6718,7 +6769,6 @@ function buildOptimisticEmptyReport(
67186769
betas: OnyxEntry<Beta[]>,
67196770
) {
67206771
const {stateNum, statusNum} = getExpenseReportStateAndStatus(policy, betas, true);
6721-
const titleReportField = getTitleReportField(getReportFieldsByPolicyID(policy?.id) ?? {});
67226772
const optimisticEmptyReport: OptimisticNewReport = {
67236773
reportName: '',
67246774
reportID,
@@ -6740,17 +6790,11 @@ function buildOptimisticEmptyReport(
67406790
managerID: getManagerAccountID(policy, {ownerAccountID: accountID}),
67416791
};
67426792

6743-
const formulaContext: FormulaContext = {
6744-
report: optimisticEmptyReport as Report,
6745-
policy,
6746-
allTransactions: {},
6747-
};
6748-
6749-
// We use dynamic require here to avoid a circular dependency between ReportUtils and Formula
6750-
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires
6751-
const Formula = require('./Formula') as {compute: (formula?: string, context?: FormulaContext) => string};
6752-
const optimisticReportName = Formula.compute(titleReportField?.defaultValue ?? CONST.POLICY.DEFAULT_REPORT_NAME_PATTERN, formulaContext);
6753-
optimisticEmptyReport.reportName = optimisticReportName ?? '';
6793+
// Compute optimistic report name if applicable
6794+
const optimisticReportName = computeOptimisticReportName(optimisticEmptyReport as Report, policy, policy?.id, {});
6795+
if (optimisticReportName !== null) {
6796+
optimisticEmptyReport.reportName = optimisticReportName;
6797+
}
67546798

67556799
optimisticEmptyReport.participants = accountID
67566800
? {
@@ -13119,6 +13163,7 @@ export {
1311913163
isSelectedManagerMcTest,
1312013164
isTestTransactionReport,
1312113165
getReportSubtitlePrefix,
13166+
computeOptimisticReportName,
1312213167
getPolicyChangeMessage,
1312313168
getMovedTransactionMessage,
1312413169
getUnreportedTransactionMessage,
@@ -13128,6 +13173,7 @@ export {
1312813173
isBusinessInvoiceRoom,
1312913174
buildOptimisticResolvedDuplicatesReportAction,
1313013175
getTitleReportField,
13176+
getTitleFieldWithFallback,
1313113177
getReportFieldsByPolicyID,
1313213178
getChatListItemReportName,
1313313179
buildOptimisticMovedTransactionAction,

src/libs/actions/Report/index.ts

Lines changed: 13 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -90,15 +90,7 @@ import Parser from '@libs/Parser';
9090
import {getParsedMessageWithShortMentions} from '@libs/ParsingUtils';
9191
import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils';
9292
import * as PhoneNumber from '@libs/PhoneNumber';
93-
import {
94-
getDefaultApprover,
95-
getMemberAccountIDsForWorkspace,
96-
isInstantSubmitEnabled,
97-
isPaidGroupPolicy,
98-
isPolicyAdmin as isPolicyAdminPolicyUtils,
99-
isPolicyMember,
100-
isSubmitAndClose,
101-
} from '@libs/PolicyUtils';
93+
import {getDefaultApprover, getMemberAccountIDsForWorkspace, isInstantSubmitEnabled, isPolicyAdmin as isPolicyAdminPolicyUtils, isPolicyMember, isSubmitAndClose} from '@libs/PolicyUtils';
10294
import processReportIDDeeplink from '@libs/processReportIDDeeplink';
10395
import Pusher from '@libs/Pusher';
10496
import type {UserIsLeavingRoomEvent, UserIsTypingEvent} from '@libs/Pusher/types';
@@ -126,6 +118,7 @@ import {
126118
buildOptimisticUnreportedTransactionAction,
127119
buildTransactionThread,
128120
canUserPerformWriteAction as canUserPerformWriteActionReportUtils,
121+
computeOptimisticReportName,
129122
findLastAccessedReport,
130123
findSelfDMReportID,
131124
formatReportLastMessageText,
@@ -143,7 +136,6 @@ import {
143136
getPendingChatMembers,
144137
getPolicyExpenseChat,
145138
getReportFieldKey,
146-
getReportFieldsByPolicyID,
147139
getReportLastMessage,
148140
getReportLastVisibleActionCreated,
149141
getReportMetadata,
@@ -152,7 +144,6 @@ import {
152144
getReportPreviewMessage,
153145
getReportTransactions,
154146
getReportViolations,
155-
getTitleReportField,
156147
hasOutstandingChildRequest,
157148
isAdminRoom,
158149
isChatThread as isChatThreadReportUtils,
@@ -5740,24 +5731,18 @@ function convertIOUReportToExpenseReport(iouReport: Report, policy: Policy, poli
57405731
expenseReport.managerID = nextApproverAccountID;
57415732
}
57425733

5743-
const titleReportField = getTitleReportField(getReportFieldsByPolicyID(policyID) ?? {});
5744-
if (!!titleReportField && isPaidGroupPolicy(policy)) {
5745-
// Convert transactions array to Record<string, Transaction> for FormulaContext
5746-
const transactionsRecord: Record<string, Transaction> = {};
5747-
for (const transaction of reportTransactions) {
5748-
if (transaction?.transactionID) {
5749-
transactionsRecord[transaction.transactionID] = transaction;
5750-
}
5734+
// Convert transactions array to Record<string, Transaction> for computeOptimisticReportName
5735+
const transactionsRecord: Record<string, Transaction> = {};
5736+
for (const transaction of reportTransactions) {
5737+
if (transaction?.transactionID) {
5738+
transactionsRecord[transaction.transactionID] = transaction;
57515739
}
5740+
}
57525741

5753-
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires
5754-
const Formula = require('@libs/Formula') as {compute: (formula?: string, context?: {report: Report; policy: Policy; allTransactions?: Record<string, Transaction>}) => string};
5755-
const computedName = Formula.compute(titleReportField.defaultValue, {
5756-
report: expenseReport,
5757-
policy,
5758-
allTransactions: transactionsRecord,
5759-
});
5760-
expenseReport.reportName = computedName ?? expenseReport.reportName;
5742+
// Compute optimistic report name if applicable
5743+
const computedName = computeOptimisticReportName(expenseReport, policy, policyID, transactionsRecord);
5744+
if (computedName !== null) {
5745+
expenseReport.reportName = computedName;
57615746
}
57625747

57635748
const reportID = iouReport.reportID;
@@ -6780,6 +6765,7 @@ export {
67806765
saveReportDraft,
67816766
moveIOUReportToPolicy,
67826767
moveIOUReportToPolicyAndInviteSubmitter,
6768+
convertIOUReportToExpenseReport,
67836769
dismissChangePolicyModal,
67846770
changeReportPolicy,
67856771
changeReportPolicyAndInviteSubmitter,

src/pages/EditReportFieldPage.tsx

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,10 @@ import {deleteReportField, updateReportField, updateReportName} from '@libs/acti
1616
import Navigation from '@libs/Navigation/Navigation';
1717
import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types';
1818
import type {EditRequestNavigatorParamList} from '@libs/Navigation/types';
19+
import {isPolicyFieldListEmpty} from '@libs/PolicyUtils';
1920
import {
2021
getReportFieldKey,
22+
getTitleFieldWithFallback,
2123
hasViolations as hasViolationsReportUtils,
2224
isInvoiceReport,
2325
isPaidGroupPolicyExpenseReport,
@@ -42,8 +44,18 @@ function EditReportFieldPage({route}: EditReportFieldPageProps) {
4244
const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {canBeMissing: false});
4345
const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {canBeMissing: false});
4446
const [recentlyUsedReportFields] = useOnyx(ONYXKEYS.RECENTLY_USED_REPORT_FIELDS, {canBeMissing: true});
45-
const reportField = report?.fieldList?.[fieldKey] ?? policy?.fieldList?.[fieldKey];
46-
const policyField = policy?.fieldList?.[fieldKey] ?? reportField;
47+
48+
const isTitleField = route.params.fieldID === CONST.REPORT_FIELD_TITLE_FIELD_ID;
49+
let reportField = report?.fieldList?.[fieldKey] ?? policy?.fieldList?.[fieldKey];
50+
let policyField = policy?.fieldList?.[fieldKey] ?? reportField;
51+
52+
// If the title field is missing, use fallback so that it can still be edited and matches the OldDot behavior.
53+
if (isTitleField && !reportField && !policyField) {
54+
const fallbackTitleField = getTitleFieldWithFallback(policy);
55+
reportField = fallbackTitleField;
56+
policyField = fallbackTitleField;
57+
}
58+
4759
const isDisabled = isReportFieldDisabledForUser(report, reportField, policy) && reportField?.type !== CONST.REPORT_FIELD_TYPES.FORMULA;
4860
const {isBetaEnabled} = usePermissions();
4961
const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT);
@@ -98,7 +110,12 @@ function EditReportFieldPage({route}: EditReportFieldPageProps) {
98110
});
99111
};
100112

101-
const fieldValue = isReportFieldTitle ? (report.reportName ?? '') : (reportField.value ?? reportField.defaultValue);
113+
// Provide a default when the report name and the policy field list are empty
114+
/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */
115+
const fieldValue = isReportFieldTitle
116+
? report.reportName || (isPolicyFieldListEmpty(policy) ? CONST.REPORT.DEFAULT_EXPENSE_REPORT_NAME : '')
117+
: (reportField.value ?? reportField.defaultValue);
118+
/* eslint-enable @typescript-eslint/prefer-nullish-coalescing */
102119

103120
const handleReportFieldChange = (form: FormOnyxValues<typeof ONYXKEYS.FORMS.REPORT_FIELDS_EDIT_FORM>) => {
104121
const value = form[fieldKey];

0 commit comments

Comments
 (0)