diff --git a/Mobile-Expensify b/Mobile-Expensify index b8844ac0f183..54414613a88a 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit b8844ac0f18388316cacfccd362040de07c34ac7 +Subproject commit 54414613a88af26ac2a52416a1feb253547b17a5 diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 10da4ca095a1..78039e9fe434 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -790,6 +790,7 @@ const ONYXKEYS = { }, DERIVED: { CONCIERGE_CHAT_REPORT_ID: 'conciergeChatReportID', + REPORT_ATTRIBUTES: 'reportAttributes', }, } as const; @@ -1124,6 +1125,7 @@ type OnyxValuesMapping = { type OnyxDerivedValuesMapping = { [ONYXKEYS.DERIVED.CONCIERGE_CHAT_REPORT_ID]: string | undefined; + [ONYXKEYS.DERIVED.REPORT_ATTRIBUTES]: Record; }; type OnyxValues = OnyxValuesMapping & OnyxCollectionValuesMapping & OnyxFormValuesMapping & OnyxFormDraftValuesMapping & OnyxDerivedValuesMapping; diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 34a3ecea7daf..cdf5499c7988 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -43,6 +43,7 @@ import type { PolicyReportField, Report, ReportAction, + ReportAttributes, ReportMetadata, ReportNameValuePairs, ReportViolationName, @@ -1035,6 +1036,17 @@ Onyx.connect({ callback: (value) => (activePolicyID = value), }); +let reportAttributes: OnyxEntry>; +Onyx.connect({ + key: ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, + callback: (value) => { + if (!value) { + return; + } + reportAttributes = value; + }, +}); + let newGroupChatDraft: OnyxEntry; Onyx.connect({ key: ONYXKEYS.NEW_GROUP_CHAT_DRAFT, @@ -4510,13 +4522,6 @@ function getInvoicesChatName({ return getPolicyName({report, policy: invoiceReceiverPolicy, policies}); } -const reportNameCache = new Map(); - -/** - * Get a cache key for the report name. - */ -const getCacheKey = (report: OnyxEntry): string => `${report?.reportID}-${report?.lastVisibleActionCreated}-${report?.reportName}`; - /** * Get the title for a report using only participant names. This may be used for 1:1 DMs and other non-categorized chats. */ @@ -4534,6 +4539,13 @@ function buildReportNameFromParticipantNames({report, personalDetails}: {report: .join(', '); } +function generateReportName(report: OnyxEntry): string { + if (!report) { + return ''; + } + return getReportNameInternal({report}); +} + /** * Get the title for a report. */ @@ -4544,6 +4556,12 @@ function getReportName( personalDetails?: Partial, invoiceReceiverPolicy?: OnyxEntry, ): string { + // Check if we can use report name in derived values - only when we have report but no other params + const canUseDerivedValue = report && policy === undefined && parentReportActionParam === undefined && personalDetails === undefined && invoiceReceiverPolicy === undefined; + + if (canUseDerivedValue && reportAttributes?.[report.reportID]) { + return reportAttributes[report.reportID].reportName; + } return getReportNameInternal({report, policy, parentReportActionParam, personalDetails, invoiceReceiverPolicy}); } @@ -4573,15 +4591,6 @@ function getReportNameInternal({ policies, }: GetReportNameParams): string { const reportID = report?.reportID; - const cacheKey = getCacheKey(report); - - if (reportID) { - const reportNameFromCache = reportNameCache.get(cacheKey); - - if (reportNameFromCache?.reportName && reportNameFromCache.reportName === report?.reportName && reportNameFromCache.reportName !== CONST.REPORT.DEFAULT_REPORT_NAME) { - return reportNameFromCache.reportName; - } - } let formattedName: string | undefined; let parentReportAction: OnyxEntry; @@ -4765,20 +4774,12 @@ function getReportNameInternal({ } if (formattedName) { - if (reportID) { - reportNameCache.set(cacheKey, {lastVisibleActionCreated: report?.lastVisibleActionCreated ?? '', reportName: formattedName}); - } - return formatReportLastMessageText(formattedName); } // Not a room or PolicyExpenseChat, generate title from first 5 other participants formattedName = buildReportNameFromParticipantNames({report, personalDetails}); - if (reportID) { - reportNameCache.set(cacheKey, {lastVisibleActionCreated: report?.lastVisibleActionCreated ?? '', reportName: formattedName}); - } - return formattedName; } @@ -10714,6 +10715,8 @@ export { getMovedTransactionMessage, getUnreportedTransactionMessage, getExpenseReportStateAndStatus, + generateReportName, + navigateToLinkedReportAction, buildOptimisticUnreportedTransactionAction, buildOptimisticResolvedDuplicatesReportAction, getTitleReportField, @@ -10722,7 +10725,6 @@ export { getInvoiceReportName, getChatListItemReportName, buildOptimisticMovedTransactionAction, - navigateToLinkedReportAction, populateOptimisticReportFormula, getOutstandingReports, isReportOutsanding, diff --git a/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts b/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts index de618e686ea1..7f5e84539b4e 100644 --- a/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts +++ b/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts @@ -1,6 +1,7 @@ import type {ValueOf} from 'type-fest'; import ONYXKEYS from '@src/ONYXKEYS'; import conciergeChatReportIDConfig from './configs/conciergeChatReportID'; +import reportAttributesConfig from './configs/reportAttributes'; import type {OnyxDerivedValueConfig} from './types'; /** @@ -9,6 +10,7 @@ import type {OnyxDerivedValueConfig} from './types'; */ const ONYX_DERIVED_VALUES = { [ONYXKEYS.DERIVED.CONCIERGE_CHAT_REPORT_ID]: conciergeChatReportIDConfig, + [ONYXKEYS.DERIVED.REPORT_ATTRIBUTES]: reportAttributesConfig, } as const satisfies { // eslint-disable-next-line @typescript-eslint/no-explicit-any [Key in ValueOf]: OnyxDerivedValueConfig; diff --git a/src/libs/actions/OnyxDerived/configs/reportAttributes.ts b/src/libs/actions/OnyxDerived/configs/reportAttributes.ts new file mode 100644 index 000000000000..a199dcc6b34a --- /dev/null +++ b/src/libs/actions/OnyxDerived/configs/reportAttributes.ts @@ -0,0 +1,36 @@ +import {generateReportName} from '@libs/ReportUtils'; +import createOnyxDerivedValueConfig from '@userActions/OnyxDerived/createOnyxDerivedValueConfig'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {ReportAttributes} from '@src/types/onyx'; + +/** + * This derived value is used to get the report attributes for the report. + * Dependency on ONYXKEYS.PERSONAL_DETAILS_LIST is to ensure that the report attributes are generated after the personal details are available. + */ + +export default createOnyxDerivedValueConfig({ + key: ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, + dependencies: [ONYXKEYS.COLLECTION.REPORT, ONYXKEYS.PERSONAL_DETAILS_LIST, ONYXKEYS.NVP_PREFERRED_LOCALE], + compute: ([reports, personalDetails, preferredLocale], {currentValue, sourceValues}) => { + if (!reports || !personalDetails || !preferredLocale) { + return {}; + } + + const reportUpdates = sourceValues?.[ONYXKEYS.COLLECTION.REPORT]; + + return Object.values(reportUpdates ?? reports).reduce>( + (acc, report) => { + if (!report) { + return acc; + } + + acc[report.reportID] = { + reportName: generateReportName(report), + }; + + return acc; + }, + reportUpdates && currentValue ? currentValue : {}, + ); + }, +}); diff --git a/src/libs/actions/OnyxDerived/index.ts b/src/libs/actions/OnyxDerived/index.ts index c0321d46b29f..4fbd0543cd12 100644 --- a/src/libs/actions/OnyxDerived/index.ts +++ b/src/libs/actions/OnyxDerived/index.ts @@ -10,6 +10,7 @@ import OnyxUtils from 'react-native-onyx/dist/OnyxUtils'; import Log from '@libs/Log'; import ObjectUtils from '@src/types/utils/ObjectUtils'; import ONYX_DERIVED_VALUES from './ONYX_DERIVED_VALUES'; +import type {DerivedValueContext} from './types'; /** * Initialize all Onyx derived values, store them in Onyx, and setup listeners to update them when dependencies change. @@ -23,11 +24,12 @@ function init() { OnyxUtils.get(key).then((storedDerivedValue) => { let derivedValue = storedDerivedValue; if (derivedValue) { - Log.info(`Derived value ${derivedValue} for ${key} restored from disk`); + Log.info(`Derived value for ${key} restored from disk`); } else { OnyxUtils.tupleGet(dependencies).then((values) => { + // @ts-expect-error TypeScript can't confirm the shape of tupleGet's return value matches the compute function's parameters + derivedValue = compute(values, {currentValue: derivedValue}); dependencyValues = values; - derivedValue = compute(values, derivedValue); Onyx.set(key, derivedValue ?? null); }); } @@ -36,13 +38,23 @@ function init() { dependencyValues[i] = value; }; - const recomputeDerivedValue = () => { - const newDerivedValue = compute(dependencyValues, derivedValue); - if (newDerivedValue !== derivedValue) { - Log.info(`[OnyxDerived] value for key ${key} changed, updating it in Onyx`, false, {old: derivedValue ?? null, new: newDerivedValue ?? null}); - derivedValue = newDerivedValue; - Onyx.set(key, derivedValue ?? null); + const recomputeDerivedValue = (sourceKey?: string, sourceValue?: unknown) => { + const context: DerivedValueContext = { + currentValue: derivedValue, + sourceValues: undefined, + }; + + // If we got a source key and value, add it to the sourceValues object + if (sourceKey && sourceValue !== undefined) { + context.sourceValues = { + [sourceKey]: sourceValue, + }; } + // @ts-expect-error TypeScript can't confirm the shape of dependencyValues matches the compute function's parameters + const newDerivedValue = compute(dependencyValues, context); + Log.info(`[OnyxDerived] updating value for ${key} in Onyx`, false, {old: derivedValue ?? null, new: newDerivedValue ?? null}); + derivedValue = newDerivedValue; + Onyx.set(key, derivedValue ?? null); }; for (let i = 0; i < dependencies.length; i++) { @@ -52,10 +64,10 @@ function init() { Onyx.connect({ key: dependencyOnyxKey, waitForCollectionCallback: true, - callback: (value) => { - Log.info(`[OnyxDerived] dependency ${dependencyOnyxKey} for derived key ${key} changed, recomputing`); + callback: (value, collectionKey, sourceValue) => { + Log.info(`[OnyxDerived] dependency ${collectionKey} for derived key ${key} changed, recomputing`); setDependencyValue(i, value as Parameters[0][typeof i]); - recomputeDerivedValue(); + recomputeDerivedValue(dependencyOnyxKey, sourceValue); }, }); } else { @@ -64,7 +76,7 @@ function init() { callback: (value) => { Log.info(`[OnyxDerived] dependency ${dependencyOnyxKey} for derived key ${key} changed, recomputing`); setDependencyValue(i, value as Parameters[0][typeof i]); - recomputeDerivedValue(); + recomputeDerivedValue(dependencyOnyxKey); }, }); } diff --git a/src/libs/actions/OnyxDerived/types.ts b/src/libs/actions/OnyxDerived/types.ts index 36a019c9b0ba..a7b14999a9ba 100644 --- a/src/libs/actions/OnyxDerived/types.ts +++ b/src/libs/actions/OnyxDerived/types.ts @@ -1,8 +1,23 @@ -import type {OnyxValue} from 'react-native-onyx'; +import type {OnyxCollection, OnyxValue} from 'react-native-onyx'; import type {NonEmptyTuple, ValueOf} from 'type-fest'; -import type {OnyxDerivedValuesMapping, OnyxKey} from '@src/ONYXKEYS'; +import type {OnyxCollectionKey, OnyxCollectionValuesMapping, OnyxDerivedValuesMapping, OnyxKey} from '@src/ONYXKEYS'; import type ONYXKEYS from '@src/ONYXKEYS'; +type OnyxCollectionSourceValue = K extends OnyxCollectionKey + ? K extends keyof OnyxCollectionValuesMapping + ? OnyxCollection + : never + : never; + +type DerivedSourceValues = Partial<{ + [K in Deps[number]]: OnyxCollectionSourceValue; +}>; + +type DerivedValueContext>> = { + currentValue?: OnyxValue; + sourceValues?: DerivedSourceValues; +}; + /** * A derived value configuration describes: * - a tuple of Onyx keys to subscribe to (dependencies), @@ -17,9 +32,8 @@ type OnyxDerivedValueConfig, Deps e args: { [Index in keyof Deps]: OnyxValue; }, - currentValue: OnyxValue, + context: DerivedValueContext, ) => OnyxDerivedValuesMapping[Key]; }; -// eslint-disable-next-line import/prefer-default-export -export type {OnyxDerivedValueConfig}; +export type {OnyxDerivedValueConfig, DerivedValueContext}; diff --git a/src/types/onyx/DerivedValues.ts b/src/types/onyx/DerivedValues.ts new file mode 100644 index 000000000000..73ec398fe36d --- /dev/null +++ b/src/types/onyx/DerivedValues.ts @@ -0,0 +1,11 @@ +/** + * The attributes of a report. + */ +type ReportAttributes = { + /** + * The name of the report. + */ + reportName: string; +}; + +export default ReportAttributes; diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index d56ba028d38b..24b5bdc6709a 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -22,6 +22,7 @@ import type Credentials from './Credentials'; import type Currency from './Currency'; import type {CurrencyList} from './Currency'; import type CustomStatusDraft from './CustomStatusDraft'; +import type ReportAttributes from './DerivedValues'; import type DismissedProductTraining from './DismissedProductTraining'; import type DismissedReferralBanners from './DismissedReferralBanners'; import type Download from './Download'; @@ -260,5 +261,6 @@ export type { TravelProvisioning, SidePanel, LastPaymentMethodType, + ReportAttributes, TalkToAISales, }; diff --git a/tests/ui/GroupChatNameTests.tsx b/tests/ui/GroupChatNameTests.tsx index 1a06f2c1e37c..b01803992670 100644 --- a/tests/ui/GroupChatNameTests.tsx +++ b/tests/ui/GroupChatNameTests.tsx @@ -97,26 +97,27 @@ function signInAndGetApp(reportName = '', participantAccountIDs?: number[]): Pro }) .then(async () => { // Simulate setting an unread report and personal details - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, { - reportID: REPORT_ID, - reportName, - lastMessageText: 'Test', - participants, - lastActorAccountID: USER_B_ACCOUNT_ID, - type: CONST.REPORT.TYPE.CHAT, - chatType: CONST.REPORT.CHAT_TYPE.GROUP, - }); - - await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, { - [USER_A_ACCOUNT_ID]: TestHelper.buildPersonalDetails(USER_A_EMAIL, USER_A_ACCOUNT_ID, 'A'), - [USER_B_ACCOUNT_ID]: TestHelper.buildPersonalDetails(USER_B_EMAIL, USER_B_ACCOUNT_ID, 'B'), - [USER_C_ACCOUNT_ID]: TestHelper.buildPersonalDetails(USER_C_EMAIL, USER_C_ACCOUNT_ID, 'C'), - [USER_D_ACCOUNT_ID]: TestHelper.buildPersonalDetails(USER_D_EMAIL, USER_D_ACCOUNT_ID, 'D'), - [USER_E_ACCOUNT_ID]: TestHelper.buildPersonalDetails(USER_E_EMAIL, USER_E_ACCOUNT_ID, 'E'), - [USER_F_ACCOUNT_ID]: TestHelper.buildPersonalDetails(USER_F_EMAIL, USER_F_ACCOUNT_ID, 'F'), - [USER_G_ACCOUNT_ID]: TestHelper.buildPersonalDetails(USER_G_EMAIL, USER_G_ACCOUNT_ID, 'G'), - [USER_H_ACCOUNT_ID]: TestHelper.buildPersonalDetails(USER_H_EMAIL, USER_H_ACCOUNT_ID, 'H'), - }); + await Promise.all([ + Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, { + reportID: REPORT_ID, + reportName, + lastMessageText: 'Test', + participants, + lastActorAccountID: USER_B_ACCOUNT_ID, + type: CONST.REPORT.TYPE.CHAT, + chatType: CONST.REPORT.CHAT_TYPE.GROUP, + }), + Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, { + [USER_A_ACCOUNT_ID]: TestHelper.buildPersonalDetails(USER_A_EMAIL, USER_A_ACCOUNT_ID, 'A'), + [USER_B_ACCOUNT_ID]: TestHelper.buildPersonalDetails(USER_B_EMAIL, USER_B_ACCOUNT_ID, 'B'), + [USER_C_ACCOUNT_ID]: TestHelper.buildPersonalDetails(USER_C_EMAIL, USER_C_ACCOUNT_ID, 'C'), + [USER_D_ACCOUNT_ID]: TestHelper.buildPersonalDetails(USER_D_EMAIL, USER_D_ACCOUNT_ID, 'D'), + [USER_E_ACCOUNT_ID]: TestHelper.buildPersonalDetails(USER_E_EMAIL, USER_E_ACCOUNT_ID, 'E'), + [USER_F_ACCOUNT_ID]: TestHelper.buildPersonalDetails(USER_F_EMAIL, USER_F_ACCOUNT_ID, 'F'), + [USER_G_ACCOUNT_ID]: TestHelper.buildPersonalDetails(USER_G_EMAIL, USER_G_ACCOUNT_ID, 'G'), + [USER_H_ACCOUNT_ID]: TestHelper.buildPersonalDetails(USER_H_EMAIL, USER_H_ACCOUNT_ID, 'H'), + }), + ]); // We manually setting the sidebar as loaded since the onLayout event does not fire in tests setSidebarLoaded(); diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index a0ff35f332e4..a68a2933f9aa 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -577,6 +577,48 @@ describe('ReportUtils', () => { expect(getReportName({...report, reportName: htmlTaskTitle})).toEqual(translateLocal('parentReportAction.deletedTask')); }); }); + + describe('Derived values', () => { + const report: Report = { + reportID: '1', + chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT, + currency: 'CLP', + ownerAccountID: 1, + isPinned: false, + isOwnPolicyExpenseChat: true, + isWaitingOnBankAccount: false, + policyID: '1', + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + beforeAll(async () => { + await Onyx.mergeCollection(ONYXKEYS.COLLECTION.REPORT, { + report_1: report, + }); + }); + + test('should return report name from a derived value', () => { + expect(getReportName(report)).toEqual("Ragnar Lothbrok's expenses"); + }); + + test('should generate report name if report is not merged in the Onyx', () => { + const expenseChatReport: Report = { + reportID: '2', + chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT, + currency: 'CLP', + ownerAccountID: 1, + isPinned: false, + isOwnPolicyExpenseChat: true, + isWaitingOnBankAccount: false, + policyID: '1', + }; + + expect(getReportName(expenseChatReport)).toEqual("Ragnar Lothbrok's expenses"); + }); + }); }); describe('requiresAttentionFromCurrentUser', () => { diff --git a/tests/unit/navigateAfterOnboardingTest.ts b/tests/unit/navigateAfterOnboardingTest.ts index 85cafb6fc535..97a233e87ce0 100644 --- a/tests/unit/navigateAfterOnboardingTest.ts +++ b/tests/unit/navigateAfterOnboardingTest.ts @@ -34,6 +34,7 @@ jest.mock('@libs/ReportUtils', () => ({ isConciergeChatReport: jest.requireActual('@libs/ReportUtils').isConciergeChatReport, isArchivedReportWithID: jest.requireActual('@libs/ReportUtils').isArchivedReportWithID, isThread: jest.requireActual('@libs/ReportUtils').isThread, + generateReportName: jest.requireActual('@libs/ReportUtils').generateReportName, })); jest.mock('@libs/Navigation/helpers/shouldOpenOnAdminRoom', () => ({