diff --git a/__mocks__/react-native-onyx.ts b/__mocks__/react-native-onyx.ts index 6db22ac1b627..f5b09f2949da 100644 --- a/__mocks__/react-native-onyx.ts +++ b/__mocks__/react-native-onyx.ts @@ -5,7 +5,7 @@ import type {ConnectOptions, OnyxKey} from 'react-native-onyx'; // eslint-disable-next-line no-restricted-imports -import Onyx, {useOnyx} from 'react-native-onyx'; +import Onyx, {useOnyx, useOnyxState} from 'react-native-onyx'; let connectCallbackDelay = 0; function addDelayToConnectCallback(delay: number) { @@ -41,4 +41,4 @@ const reactNativeOnyxMock: ReactNativeOnyxMock = { export default reactNativeOnyxMock; -export {useOnyx}; +export {useOnyx, useOnyxState}; diff --git a/config/eslint/eslint.config.mjs b/config/eslint/eslint.config.mjs index 5dac371b0558..6549aa224bbb 100644 --- a/config/eslint/eslint.config.mjs +++ b/config/eslint/eslint.config.mjs @@ -737,7 +737,7 @@ const config = defineConfig([ // here too (flat config is last-wins per rule, not additive). { files: ['**/*.ts', '**/*.tsx'], - ignores: ['src/libs/actions/OnyxDerived/configs/reportAttributes.ts'], + ignores: ['src/libs/ReportAttributesComputeUtils.ts'], rules: { 'no-restricted-imports': [ 'error', diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv index 55699c3b77e4..965636d3cf20 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -631,7 +631,6 @@ "../../src/libs/actions/OdometerTransactionUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/actions/OnyxDerived/configs/guideAccountIDs.ts" "rulesdir/no-direct-personal-details-list" 1 "../../src/libs/actions/OnyxDerived/configs/loginToAccountIDMap.ts" "rulesdir/no-direct-personal-details-list" 1 -"../../src/libs/actions/OnyxDerived/configs/reportAttributes.ts" "rulesdir/no-direct-personal-details-list" 2 "../../src/libs/actions/OnyxDerived/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/actions/OnyxUpdateManager/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/libs/actions/OnyxUpdateManager/utils/__mocks__/applyUpdates.ts" "no-restricted-syntax" 1 @@ -720,6 +719,7 @@ "../../src/libs/ComposerFocusManager.ts" "@typescript-eslint/no-deprecated/TextInput.State.currentlyFocusedField" 1 "../../src/libs/ComposerFocusManager.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/ComposerUtils/index.ts" "no-restricted-syntax" 1 +"../../src/libs/computeReportAttributes.ts" "rulesdir/no-direct-personal-details-list" 2 "../../src/libs/CountryUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/libs/CurrencyUtils.ts" "rulesdir/no-onyx-connect" 1 "../../src/libs/DateUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 @@ -2065,10 +2065,8 @@ "../../tests/unit/ReportSecondaryActionUtilsTest.ts" "no-restricted-imports" 2 "../../tests/unit/ReportTitleUtilsTest.ts" "no-restricted-imports" 1 "../../tests/unit/ReportUtilsTest.ts" "no-restricted-imports" 1 -"../../tests/unit/SearchAutocompleteListTest.tsx" "@typescript-eslint/no-restricted-imports" 1 "../../tests/unit/SidebarUtilsTest.ts" "no-restricted-imports" 1 "../../tests/unit/TransactionTest.ts" "@typescript-eslint/no-restricted-imports" 1 -"../../tests/unit/WorkspacesSettingsUtilsTest.ts" "@typescript-eslint/no-restricted-imports" 1 "../../tests/utils/createMock.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../rsbuild/CustomVersionFilePlugin.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../rsbuild/ModuleInitTimingPlugin.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 diff --git a/config/oxlint/oxlint.seatbelt.tsv b/config/oxlint/oxlint.seatbelt.tsv index a1b5f1f4edb2..d7e925aa7dac 100644 --- a/config/oxlint/oxlint.seatbelt.tsv +++ b/config/oxlint/oxlint.seatbelt.tsv @@ -712,7 +712,6 @@ "../../src/libs/actions/OnyxDerived/configs/cardFeedErrors.ts" "@typescript-eslint/no-unnecessary-type-assertion" 1 "../../src/libs/actions/OnyxDerived/configs/guideAccountIDs.ts" "rulesdir/no-direct-personal-details-list" 1 "../../src/libs/actions/OnyxDerived/configs/loginToAccountIDMap.ts" "rulesdir/no-direct-personal-details-list" 1 -"../../src/libs/actions/OnyxDerived/configs/reportAttributes.ts" "rulesdir/no-direct-personal-details-list" 2 "../../src/libs/actions/OnyxDerived/index.ts" "@typescript-eslint/no-unnecessary-type-assertion" 2 "../../src/libs/actions/OnyxDerived/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/actions/OnyxUpdateManager/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 @@ -824,6 +823,7 @@ "../../src/libs/ComposerFocusManager.ts" "@typescript-eslint/no-deprecated/TextInput.State.currentlyFocusedField" 1 "../../src/libs/ComposerFocusManager.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/ComposerUtils/index.ts" "no-restricted-syntax" 1 +"../../src/libs/computeReportAttributes.ts" "rulesdir/no-direct-personal-details-list" 2 "../../src/libs/CountryUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/libs/CurrencyUtils.ts" "rulesdir/no-onyx-connect" 1 "../../src/libs/DateUtils.ts" "@typescript-eslint/no-unnecessary-type-assertion" 1 @@ -2511,7 +2511,7 @@ "../../tests/unit/OnyxDerived/visibleReportActionsTest.ts" "@typescript-eslint/no-unnecessary-type-assertion" 2 "../../tests/unit/onyxDerivedComputeSpanTest.ts" "@typescript-eslint/no-unnecessary-type-assertion" 1 "../../tests/unit/OnyxDerivedSelfHealTest.ts" "no-restricted-imports" 1 -"../../tests/unit/OnyxDerivedTest.tsx" "@typescript-eslint/no-unnecessary-type-assertion" 3 +"../../tests/unit/OnyxDerivedTest.tsx" "@typescript-eslint/no-unnecessary-type-assertion" 1 "../../tests/unit/OnyxDerivedTest.tsx" "no-restricted-imports" 1 "../../tests/unit/OptionsListUtilsTest.tsx" "@typescript-eslint/no-unnecessary-type-assertion" 14 "../../tests/unit/pages/DynamicReportDetailsPage/useReportDetailsDeleteModalTest.ts" "@typescript-eslint/no-deprecated/originalMessage" 1 @@ -2558,7 +2558,6 @@ "../../tests/unit/SearchActionsTest.ts" "@typescript-eslint/no-unnecessary-type-assertion" 5 "../../tests/unit/SearchAutocompleteListTest.tsx" "@typescript-eslint/no-deprecated/originalMessage" 3 "../../tests/unit/SearchAutocompleteListTest.tsx" "import/no-duplicates" 1 -"../../tests/unit/SearchAutocompleteListTest.tsx" "no-restricted-imports" 1 "../../tests/unit/SearchEditMultipleUtilsTest.ts" "@typescript-eslint/no-deprecated/originalMessage" 1 "../../tests/unit/SequentialQueueTest.ts" "@typescript-eslint/no-unnecessary-type-assertion" 2 "../../tests/unit/showReportActionNotificationTest.ts" "@typescript-eslint/no-unnecessary-type-assertion" 3 @@ -2601,7 +2600,6 @@ "../../tests/unit/useTwoFactorAuthRouteTests.ts" "@typescript-eslint/no-unnecessary-type-assertion" 3 "../../tests/unit/ViolationFixParityTest.ts" "@typescript-eslint/no-unnecessary-type-assertion" 1 "../../tests/unit/WhisperContentMentionContextTest.tsx" "@typescript-eslint/no-unnecessary-type-assertion" 2 -"../../tests/unit/WorkspacesSettingsUtilsTest.ts" "no-restricted-imports" 1 "../../tests/utils/collections/card.ts" "@typescript-eslint/no-unnecessary-type-assertion" 1 "../../tests/utils/createMock.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../tests/utils/LHNTestUtils.tsx" "@typescript-eslint/no-unnecessary-type-assertion" 1 diff --git a/oxlint.config.mts b/oxlint.config.mts index c3939e2b7da0..f98e27af1cf1 100644 --- a/oxlint.config.mts +++ b/oxlint.config.mts @@ -1087,9 +1087,9 @@ export default defineConfig({ {files: ['src/styles/**'], excludeFiles: ['src/styles/typography.ts', 'src/styles/variables.ts'], rules: {'rulesdir/no-raw-typography': ['error', {allowVariablesReferences: true}]}}, {files: ['**/en.ts', '**/es.ts'], rules: {'rulesdir/use-periods-for-error-messages': 'error'}}, {files: ['src/libs/ReportNameUtils.ts'], rules: {'report-name-utils/no-function-call-in-get-report-name': 'error'}}, - // reportAttributes.ts computes report names, so the TypeScript-only bans do not apply. + // ReportAttributesComputeUtils.ts computes report names, so the TypeScript-only bans do not apply. { - files: ['src/libs/actions/OnyxDerived/configs/reportAttributes.ts'], + files: ['src/libs/ReportAttributesComputeUtils.ts'], rules: {'no-restricted-imports': ['error', {paths: restrictedImportPaths, patterns: restrictedImportPatterns}]}, }, { diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index f870557af1e2..efee8f5cc31a 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -285,6 +285,9 @@ const ONYXKEYS = { /** Whether the app is currently loading a translation */ RAM_ONLY_ARE_TRANSLATIONS_LOADING: 'areTranslationsLoading', + /** The report attributes saved on idle, so the next app start doesn't have to compute them all before showing the LHN */ + REPORT_ATTRIBUTES_SNAPSHOT: 'reportAttributesSnapshot', + /** Whether the user has tried focus mode yet */ NVP_TRY_FOCUS_MODE: 'nvp_tryFocusMode', @@ -666,6 +669,9 @@ const ONYXKEYS = { /** @deprecated Use ACTIVE_SERVER. */ SHOULD_USE_STAGING_SERVER: 'shouldUseStagingServer', + /** @deprecated The report attributes are computed through useOnyxState now. Only read by the RemoveReportAttributesDerivedValue migration. */ + LEGACY_REPORT_ATTRIBUTES: 'reportAttributes', + /** Which server environment the app talks to */ ACTIVE_SERVER: 'activeServer', @@ -1479,7 +1485,6 @@ const ONYXKEYS = { BUSINESS_CENTRAL_CREDENTIALS_FORM_DRAFT: 'businessCentralCredentialsFormDraft', }, DERIVED: { - REPORT_ATTRIBUTES: 'reportAttributes', REPORT_TRANSACTIONS_AND_VIOLATIONS: 'reportTransactionsAndViolations', OUTSTANDING_REPORTS_BY_POLICY_ID: 'outstandingReportsByPolicyID', VISIBLE_REPORT_ACTIONS: 'visibleReportActions', @@ -1853,6 +1858,7 @@ type OnyxValuesMapping = { [ONYXKEYS.ONFIDO_APPLICANT_ID]: string; [ONYXKEYS.NVP_PREFERRED_LOCALE]: OnyxTypes.Locale; [ONYXKEYS.RAM_ONLY_ARE_TRANSLATIONS_LOADING]: boolean; + [ONYXKEYS.REPORT_ATTRIBUTES_SNAPSHOT]: OnyxTypes.ReportAttributesSnapshot; [ONYXKEYS.NVP_ACTIVE_POLICY_ID]: string; [ONYXKEYS.NVP_LAST_DISMISSED_MARKETING_WINDOW]: string; [ONYXKEYS.NVP_DISMISSED_REFERRAL_BANNERS]: OnyxTypes.DismissedReferralBanners; @@ -1963,6 +1969,8 @@ type OnyxValuesMapping = { [ONYXKEYS.SHOULD_MASK_ONYX_STATE]: boolean; // eslint-disable-next-line @typescript-eslint/no-deprecated -- the key stays typed while it is still read [ONYXKEYS.SHOULD_USE_STAGING_SERVER]: boolean; + // eslint-disable-next-line @typescript-eslint/no-deprecated -- the key stays typed until the migration has removed it everywhere + [ONYXKEYS.LEGACY_REPORT_ATTRIBUTES]: OnyxTypes.ReportAttributesDerivedValue; [ONYXKEYS.ACTIVE_SERVER]: ValueOf; [ONYXKEYS.CLOUDFLARE_SESSION]: OnyxTypes.CloudflareSession; [ONYXKEYS.IS_DEBUG_MODE_ENABLED]: boolean; @@ -2080,7 +2088,6 @@ type OnyxValuesMapping = { }; type OnyxDerivedValuesMapping = { - [ONYXKEYS.DERIVED.REPORT_ATTRIBUTES]: OnyxTypes.ReportAttributesDerivedValue; [ONYXKEYS.DERIVED.REPORT_TRANSACTIONS_AND_VIOLATIONS]: OnyxTypes.ReportTransactionsAndViolationsDerivedValue; [ONYXKEYS.DERIVED.OUTSTANDING_REPORTS_BY_POLICY_ID]: OnyxTypes.OutstandingReportsByPolicyIDDerivedValue; [ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS]: OnyxTypes.VisibleReportActionsDerivedValue; diff --git a/src/components/AvatarWithDisplayName.tsx b/src/components/AvatarWithDisplayName.tsx index bac91876f5c8..0d8791797460 100644 --- a/src/components/AvatarWithDisplayName.tsx +++ b/src/components/AvatarWithDisplayName.tsx @@ -5,7 +5,7 @@ import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import {useAllPersonalDetails} from '@hooks/usePersonalDetails'; import usePolicy from '@hooks/usePolicy'; -import {useDerivedReportNamesByReportIDs} from '@hooks/useReportAttributes'; +import {useReportNames} from '@hooks/useReportAttributes'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; @@ -226,7 +226,7 @@ function AvatarWithDisplayName({ // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth} = useResponsiveLayout(); - const derivedReportNames = useDerivedReportNamesByReportIDs([report?.reportID, report?.parentReportID]); + const derivedReportNames = useReportNames([report?.reportID, report?.parentReportID]); const isReportArchived = useReportIsArchived(report?.reportID); const isParentReportArchived = useReportIsArchived(report?.parentReportID); const icons = useMemoizedLazyExpensifyIcons(['Pencil']); diff --git a/src/components/LHNOptionsList/LHNTooltipContextProvider.tsx b/src/components/LHNOptionsList/LHNTooltipContextProvider.tsx index c2037058f131..522927b1ff7b 100644 --- a/src/components/LHNOptionsList/LHNTooltipContextProvider.tsx +++ b/src/components/LHNOptionsList/LHNTooltipContextProvider.tsx @@ -1,5 +1,5 @@ import useOnyx from '@hooks/useOnyx'; -import useReportAttributes from '@hooks/useReportAttributes'; +import {useAllReportAttributes} from '@hooks/useReportAttributes'; import useRootNavigationState from '@hooks/useRootNavigationState'; import NAVIGATORS from '@src/NAVIGATORS'; @@ -24,7 +24,7 @@ function LHNTooltipContextProvider({data, children}: LHNTooltipContextProviderPr const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [onboarding] = useOnyx(ONYXKEYS.NVP_ONBOARDING); const [isFullscreenVisible] = useOnyx(ONYXKEYS.FULLSCREEN_VISIBILITY); - const reportAttributes = useReportAttributes(); + const reportAttributes = useAllReportAttributes(); const firstReportIDWithGBRorRBR = data.find((reportID) => { const attrs = reportAttributes?.[reportID]; diff --git a/src/components/LHNOptionsList/OptionRowLHN/OptionRowLHNData.tsx b/src/components/LHNOptionsList/OptionRowLHN/OptionRowLHNData.tsx index 6972a29728e2..11b39961c47e 100644 --- a/src/components/LHNOptionsList/OptionRowLHN/OptionRowLHNData.tsx +++ b/src/components/LHNOptionsList/OptionRowLHN/OptionRowLHNData.tsx @@ -9,7 +9,7 @@ import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePersonalDetailByLogin from '@hooks/usePersonalDetailByLogin'; import {usePersonalDetailsByIDs} from '@hooks/usePersonalDetails'; -import {useReportAttributesByID} from '@hooks/useReportAttributes'; +import {useReportAttributesEntry, useReportNameEntries} from '@hooks/useReportAttributes'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import { @@ -31,13 +31,12 @@ import CONST from '@src/CONST'; import {getMovedReportID} from '@src/libs/ModifiedExpenseMessage'; import ONYXKEYS from '@src/ONYXKEYS'; import type {PersonalDetailsList, Report, ReportAction, ReportActions as ReportActionsType} from '@src/types/onyx'; -import type {ReportAttributesDerivedValue, VisibleReportActionsDerivedValue} from '@src/types/onyx/DerivedValues'; +import type {VisibleReportActionsDerivedValue} from '@src/types/onyx/DerivedValues'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import {isTrackIntentUserSelector} from '@selectors/Onboarding'; import {accountIDSelector} from '@selectors/PersonalDetails'; -import {reportNamesByIDsSelector} from '@selectors/ReportAttributes'; import {optionRowReportMetadataSelector} from '@selectors/ReportMetaData'; import React from 'react'; @@ -126,7 +125,7 @@ function OptionRowLHNData({isOptionFocused = false, viewMode = 'default', report const {login, accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const [fullReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); - const reportAttributes = useReportAttributesByID(reportID); + const reportAttributes = useReportAttributesEntry(reportID); const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`); const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`); const [draftComment] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`); @@ -205,9 +204,7 @@ function OptionRowLHNData({isOptionFocused = false, viewMode = 'default', report const [lastActionReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(lastActionReportID ? String(lastActionReportID) : undefined)}`); const [movedFromReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getMovedReportID(lastAction, CONST.REPORT.MOVE_TYPE.FROM)}`); const [movedToReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getMovedReportID(lastAction, CONST.REPORT.MOVE_TYPE.TO)}`); - const [reportNames] = useOnyx(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, { - selector: (attributes: OnyxEntry) => reportNamesByIDsSelector(attributes, reportNameIDs), - }); + const reportNames = useReportNameEntries(reportNameIDs); const [personalDetails] = usePersonalDetailsByIDs( getOptionRowAccountIDs({ report: fullReport, diff --git a/src/components/MoneyRequestConfirmationList/sections/ReportField.tsx b/src/components/MoneyRequestConfirmationList/sections/ReportField.tsx index 941d0e3a8331..f364a26c5289 100644 --- a/src/components/MoneyRequestConfirmationList/sections/ReportField.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/ReportField.tsx @@ -3,7 +3,7 @@ import MenuItemFieldHTML from '@components/MenuItem/presets/MenuItemFieldHTML'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useOutstandingReports from '@hooks/useOutstandingReports'; -import {useDerivedReportNameByReportID} from '@hooks/useReportAttributes'; +import {useReportName} from '@hooks/useReportAttributes'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; @@ -100,7 +100,7 @@ function ReportField({selectedParticipants, iouType, reportID, reportActionID, a return [reportIDToUse, reportToUse ?? undefined] as const; })(); - const derivedReportName = useDerivedReportNameByReportID(selectedReportID); + const derivedReportName = useReportName(selectedReportID); const reportName = (() => { const name = getReportName(selectedReport, derivedReportName); diff --git a/src/components/Navigation/DebugTabView.tsx b/src/components/Navigation/DebugTabView.tsx index a1912b14459a..820a33a59cd8 100644 --- a/src/components/Navigation/DebugTabView.tsx +++ b/src/components/Navigation/DebugTabView.tsx @@ -6,7 +6,7 @@ import useIndicatorStatus from '@hooks/useIndicatorStatus'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; -import useReportAttributes from '@hooks/useReportAttributes'; +import {useAllReportAttributes} from '@hooks/useReportAttributes'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useRootNavigationState from '@hooks/useRootNavigationState'; import {useSidebarOrderedReportsState} from '@hooks/useSidebarOrderedReports'; @@ -162,7 +162,7 @@ function DebugTabView({selectedTab}: Props) { const {windowWidth} = useWindowDimensions(); const sidePanelOffset = isExtraLargeScreenWidth && !shouldHideSidePanel ? variables.sidePanelWidth : 0; const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT); - const reportAttributes = useReportAttributes(); + const reportAttributes = useAllReportAttributes(); const {status, indicatorColor, indicatorPolicyID} = useIndicatorStatus(); const {orderedReportIDs, chatTabBrickRoad} = useSidebarOrderedReportsState(); const icons = useMemoizedLazyExpensifyIcons(['DotIndicator']); diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/ReportPreviewHeader.tsx b/src/components/ReportActionItem/MoneyRequestReportPreview/ReportPreviewHeader.tsx index 81514556e501..e43a9933eb57 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/ReportPreviewHeader.tsx +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/ReportPreviewHeader.tsx @@ -6,7 +6,7 @@ import Text from '@components/Text'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; -import useOnyx from '@hooks/useOnyx'; +import {useReportName} from '@hooks/useReportAttributes'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -17,12 +17,7 @@ import {getReportStatusColorStyle, getReportStatusTooltipTranslation, getReportS import variables from '@styles/variables'; import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import type {ReportAttributesDerivedValue} from '@src/types/onyx'; -import type {OnyxEntry} from 'react-native-onyx'; - -import {reportNameSelector} from '@selectors/ReportAttributes'; import React from 'react'; import {View} from 'react-native'; import Animated from 'react-native-reanimated'; @@ -45,8 +40,7 @@ function ReportPreviewHeader() { const {goToPrevious, goToNext} = useReportPreviewActions(); const numberOfRequests = transactions.length; - const selectReportName = (attributes: OnyxEntry) => reportNameSelector(attributes, iouReportID); - const [derivedReportName] = useOnyx(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, {selector: selectReportName}); + const derivedReportName = useReportName(iouReportID); const reportName = derivedReportName ?? iouReport?.reportName ?? ''; const formattedReportName = Parser.htmlToText(reportName || (action.childReportName ?? '')); diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index a1b6557cef33..183ef58f51bf 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -36,7 +36,7 @@ import usePermissions from '@hooks/usePermissions'; import {usePersonalDetail} from '@hooks/usePersonalDetails'; import usePersonalPolicy from '@hooks/usePersonalPolicy'; import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; -import {useDerivedReportNamesByReportIDs} from '@hooks/useReportAttributes'; +import {useReportNames} from '@hooks/useReportAttributes'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useRestrictedActionPolicyID from '@hooks/useRestrictedActionPolicyID'; @@ -612,7 +612,7 @@ function MoneyRequestView({ const tripID = tripRoomReportNameValuePairs?.tripData?.tripID; const shouldShowViewTripDetails = hasReservationList(transaction) && !!tripID; - const derivedReportNames = useDerivedReportNamesByReportIDs([tripRoomReportID, parentReport?.reportID]); + const derivedReportNames = useReportNames([tripRoomReportID, parentReport?.reportID]); const tripRoomDerivedName = tripRoomReportID ? derivedReportNames?.[tripRoomReportID] : undefined; const tripRoomName = tripRoomReport ? getReportName(tripRoomReport, tripRoomDerivedName) : undefined; diff --git a/src/components/ReportActionItem/MovedTransactionAction.tsx b/src/components/ReportActionItem/MovedTransactionAction.tsx index 56094759d570..cf77a2886705 100644 --- a/src/components/ReportActionItem/MovedTransactionAction.tsx +++ b/src/components/ReportActionItem/MovedTransactionAction.tsx @@ -2,7 +2,7 @@ import RenderHTML from '@components/RenderHTML'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; -import {useDerivedReportNameByReportID} from '@hooks/useReportAttributes'; +import {useReportName} from '@hooks/useReportAttributes'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import Parser from '@libs/Parser'; @@ -37,7 +37,7 @@ function MovedTransactionAction({action, originalReport}: MovedTransactionAction const isPendingDelete = fromReport?.pendingFields?.preview === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; - const derivedReportName = useDerivedReportNameByReportID(displayReportID); + const derivedReportName = useReportName(displayReportID); const message = getMovedTransactionMessage({translate, movedReport: displayReport, fromReportID, toReportID, derivedReportName}); if (hasReasoning(action)) { diff --git a/src/components/ReportActionItem/SupportTicketPreview.tsx b/src/components/ReportActionItem/SupportTicketPreview.tsx index ebb30117a996..0b7debbe6e18 100644 --- a/src/components/ReportActionItem/SupportTicketPreview.tsx +++ b/src/components/ReportActionItem/SupportTicketPreview.tsx @@ -10,7 +10,7 @@ import useConfirmModal from '@hooks/useConfirmModal'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; -import {useDerivedReportNameByReportID} from '@hooks/useReportAttributes'; +import {useReportName} from '@hooks/useReportAttributes'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -49,7 +49,7 @@ function SupportTicketPreview({action, isHovered, style}: SupportTicketPreviewPr const [supportTicket] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(action?.childReportID)}`); const supportTicketReportID = supportTicket?.reportID ?? action?.childReportID; const supportRepAccountID = supportTicket?.managerID ?? CONST.DEFAULT_NUMBER_ID; - const derivedSupportTicketName = useDerivedReportNameByReportID(supportTicketReportID); + const derivedSupportTicketName = useReportName(supportTicketReportID); const isResolved = isResolvedSupportTicket(supportTicket, action); const errors = typeof action?.errors === 'object' ? action.errors : undefined; const createReportErrors = typeof errors?.createReport === 'object' ? errors.createReport : undefined; diff --git a/src/components/ReportActionItem/SupportTicketView.tsx b/src/components/ReportActionItem/SupportTicketView.tsx index 0589271ef282..64794888d02f 100644 --- a/src/components/ReportActionItem/SupportTicketView.tsx +++ b/src/components/ReportActionItem/SupportTicketView.tsx @@ -8,7 +8,7 @@ import UserPill from '@components/UserPill'; import useConfirmModal from '@hooks/useConfirmModal'; import useLocalize from '@hooks/useLocalize'; -import {useDerivedReportNameByReportID} from '@hooks/useReportAttributes'; +import {useReportName} from '@hooks/useReportAttributes'; import useThemeStyles from '@hooks/useThemeStyles'; import {getDisplayNameForParticipant, isResolvedSupportTicket} from '@libs/ReportUtils'; @@ -46,7 +46,7 @@ function SupportTicketView({report, action}: SupportTicketViewProps) { hiddenTranslation: translate('common.hidden'), }) : ''; - const derivedSupportTicketName = useDerivedReportNameByReportID(report?.reportID); + const derivedSupportTicketName = useReportName(report?.reportID); const supportTicketName = derivedSupportTicketName ?? report?.reportName ?? translate('supportTicket.fallbackTitle'); const dismissSupportTicket = () => { diff --git a/src/components/ReportActionItem/UnreportedTransactionAction.tsx b/src/components/ReportActionItem/UnreportedTransactionAction.tsx index b115d66cc320..ae1298c9563f 100644 --- a/src/components/ReportActionItem/UnreportedTransactionAction.tsx +++ b/src/components/ReportActionItem/UnreportedTransactionAction.tsx @@ -2,7 +2,7 @@ import RenderHTML from '@components/RenderHTML'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; -import {useDerivedReportNameByReportID} from '@hooks/useReportAttributes'; +import {useReportName} from '@hooks/useReportAttributes'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import Parser from '@libs/Parser'; @@ -35,7 +35,7 @@ function UnreportedTransactionAction({action, originalReport}: UnreportedTransac const [childReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(action.childReportID)}`); const isPendingDelete = fromReport?.pendingFields?.preview === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; - const derivedReportName = useDerivedReportNameByReportID(fromReportID); + const derivedReportName = useReportName(fromReportID); const unreportedTransactionMessage = getUnreportedTransactionMessage({translate, fromReport, fromReportID, derivedReportName}); if (hasReasoning(action)) { diff --git a/src/components/ReportWelcomeText.tsx b/src/components/ReportWelcomeText.tsx index 37190dc8450f..b596487f6729 100644 --- a/src/components/ReportWelcomeText.tsx +++ b/src/components/ReportWelcomeText.tsx @@ -4,7 +4,7 @@ import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import {useAllPersonalDetails} from '@hooks/usePersonalDetails'; import usePreferredPolicy from '@hooks/usePreferredPolicy'; -import {useDerivedReportNameByReportID} from '@hooks/useReportAttributes'; +import {useReportName} from '@hooks/useReportAttributes'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useScreenBoundDynamicRoute from '@hooks/useScreenBoundDynamicRoute'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -52,7 +52,7 @@ function ReportWelcomeText({report, policy}: ReportWelcomeTextProps) { const styles = useThemeStyles(); const {environmentURL} = useEnvironment(); const buildDynamicRoute = useScreenBoundDynamicRoute(); - const derivedReportName = useDerivedReportNameByReportID(report?.reportID); + const derivedReportName = useReportName(report?.reportID); const [personalDetails] = useAllPersonalDetails(); const {isRestrictedToPreferredPolicy} = usePreferredPolicy(); const isPolicyExpenseChat = isPolicyExpenseChatReportUtils(report); diff --git a/src/components/Search/FilterComponents/InSelector.tsx b/src/components/Search/FilterComponents/InSelector.tsx index 4da10764527e..0213a7165d5b 100644 --- a/src/components/Search/FilterComponents/InSelector.tsx +++ b/src/components/Search/FilterComponents/InSelector.tsx @@ -13,7 +13,7 @@ import useInitialValue from '@hooks/useInitialValue'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePrivateIsArchivedMap from '@hooks/usePrivateIsArchivedMap'; -import useReportAttributes from '@hooks/useReportAttributes'; +import {useAllReportAttributes} from '@hooks/useReportAttributes'; import useShouldFooterBeInsideList from '@hooks/useShouldFooterBeInsideList'; import useSortedReportActionsData from '@hooks/useSortedReportActionsData'; @@ -85,7 +85,7 @@ function InSelector({value = [], selectionListTextInputStyle, selectionListStyle const currentUserEmail = currentUserPersonalDetails.email ?? ''; const [isSearchingForReports] = useOnyx(ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_REPORTS); - const reportAttributesDerived = useReportAttributes(); + const reportAttributesDerived = useAllReportAttributes(); const cleanSearchTerm = searchTerm.trim().toLowerCase(); const [draftComments] = useOnyx(ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT); const privateIsArchivedMap = usePrivateIsArchivedMap(); diff --git a/src/components/Search/SearchAutocompleteList.tsx b/src/components/Search/SearchAutocompleteList.tsx index 209f0f8c831d..e26cb1e460fd 100644 --- a/src/components/Search/SearchAutocompleteList.tsx +++ b/src/components/Search/SearchAutocompleteList.tsx @@ -19,7 +19,7 @@ import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; -import useReportAttributes from '@hooks/useReportAttributes'; +import {useAllReportAttributes} from '@hooks/useReportAttributes'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSortedReportActionsData from '@hooks/useSortedReportActionsData'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -207,7 +207,7 @@ function SearchAutocompleteList({ const {isBetaEnabled} = usePermissions(); const isDefaultRoomsBetaEnabled = isBetaEnabled(CONST.BETAS.DEFAULT_ROOMS); const feedKeysWithCards = useFeedKeysWithAssignedCards(); - const reportAttributes = useReportAttributes(); + const reportAttributes = useAllReportAttributes(); const [draftComments] = useOnyx(ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT); const [recentSearches, recentSearchesMetadata] = useOnyx(ONYXKEYS.RECENT_SEARCHES); const [countryCode] = useOnyx(ONYXKEYS.COUNTRY_CODE); diff --git a/src/components/Search/SearchList/ListItem/TaskListItem.tsx b/src/components/Search/SearchList/ListItem/TaskListItem.tsx index be9b2526da82..c93829309419 100644 --- a/src/components/Search/SearchList/ListItem/TaskListItem.tsx +++ b/src/components/Search/SearchList/ListItem/TaskListItem.tsx @@ -4,7 +4,7 @@ import ListItemComposed from '@components/SelectionList/ListItemComposed'; import type {ListItem} from '@components/SelectionList/types'; import useLayoutSpacing from '@hooks/useLayoutSpacing'; -import useOnyx from '@hooks/useOnyx'; +import {useReportName} from '@hooks/useReportAttributes'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -14,9 +14,6 @@ import FS from '@libs/Fullstory'; import variables from '@styles/variables'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {ReportAttributesDerivedValue} from '@src/types/onyx'; - -import type {OnyxEntry} from 'react-native-onyx'; import React from 'react'; import {View} from 'react-native'; @@ -45,9 +42,7 @@ function TaskListItem({ const taskItem = item as unknown as TaskListItemType; const parentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${taskItem?.parentReportID}`]; const parentReportID = taskItem?.parentReportID; - const [liveParentReportAttributeName] = useOnyx(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, { - selector: (value: OnyxEntry) => (parentReportID ? value?.reports?.[parentReportID]?.reportName : undefined), - }); + const liveParentReportAttributeName = useReportName(parentReportID); const liveTaskItem: TaskListItemType = liveParentReportAttributeName && liveParentReportAttributeName !== taskItem.parentReportName ? {...taskItem, parentReportName: liveParentReportAttributeName} : taskItem; const styles = useThemeStyles(); diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index 91e3aaf69e1f..476694cd0ea0 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -20,7 +20,7 @@ import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import {useGetPersonalDetailsByLogin} from '@hooks/usePersonalDetailByLogin'; -import useReportAttributes from '@hooks/useReportAttributes'; +import {useAllReportAttributes} from '@hooks/useReportAttributes'; import useReportOrReportDraft from '@hooks/useReportOrReportDraft'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useRootNavigationState from '@hooks/useRootNavigationState'; @@ -136,7 +136,7 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); const getPersonalDetailsByLogin = useGetPersonalDetailsByLogin(); const feedKeysWithCards = useFeedKeysWithAssignedCards(); - const reportAttributes = useReportAttributes(); + const reportAttributes = useAllReportAttributes(); // Seed the input on open. When the SearchRouter is opened from the search button on the search page // and `shouldShowSearchQuery` is true, we build a user-readable query string from the current search diff --git a/src/components/Search/SearchRouter/SearchRouterOptionsWarmer.tsx b/src/components/Search/SearchRouter/SearchRouterOptionsWarmer.tsx index 6318b0bca6ba..87c0eda28a5a 100644 --- a/src/components/Search/SearchRouter/SearchRouterOptionsWarmer.tsx +++ b/src/components/Search/SearchRouter/SearchRouterOptionsWarmer.tsx @@ -1,7 +1,7 @@ import useFilteredOptions from '@hooks/useFilteredOptions'; import useOnyx from '@hooks/useOnyx'; import {useAllPersonalDetails} from '@hooks/usePersonalDetails'; -import useReportAttributes from '@hooks/useReportAttributes'; +import {useAllReportAttributes} from '@hooks/useReportAttributes'; import useSortedReportActionsData from '@hooks/useSortedReportActionsData'; import {Scheduler} from '@libs/Scheduler'; @@ -35,7 +35,7 @@ function SearchRouterOptionsWarmer({onDone}: SearchRouterOptionsWarmerProps) { const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const [personalDetails] = useAllPersonalDetails(); - const reportAttributes = useReportAttributes(); + const reportAttributes = useAllReportAttributes(); const sortedReportActionsData = useSortedReportActionsData(); const [allPolicyTags] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS); const [visibleReportActionsData] = useOnyx(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS); diff --git a/src/components/Search/hooks/useFilterReportValue.tsx b/src/components/Search/hooks/useFilterReportValue.tsx index b2c281184185..1305b56003d9 100644 --- a/src/components/Search/hooks/useFilterReportValue.tsx +++ b/src/components/Search/hooks/useFilterReportValue.tsx @@ -1,5 +1,5 @@ import useOnyx from '@hooks/useOnyx'; -import {useDerivedReportNamesByReportIDs} from '@hooks/useReportAttributes'; +import {useReportNames} from '@hooks/useReportAttributes'; import {getReportName} from '@libs/ReportNameUtils'; import type {SearchFilter} from '@libs/SearchUIUtils'; @@ -8,7 +8,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; function useFilterReportValue(reportIDs: SearchFilter['value']): string { const reportIDList = Array.isArray(reportIDs) ? reportIDs : []; - const derivedReportNames = useDerivedReportNamesByReportIDs(reportIDList); + const derivedReportNames = useReportNames(reportIDList); const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); if (!Array.isArray(reportIDs)) { diff --git a/src/components/Search/hooks/useSearchSnapshot.ts b/src/components/Search/hooks/useSearchSnapshot.ts index f29acd32ae8f..bd608f2d4dce 100644 --- a/src/components/Search/hooks/useSearchSnapshot.ts +++ b/src/components/Search/hooks/useSearchSnapshot.ts @@ -11,7 +11,7 @@ import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import {useAllPersonalDetails} from '@hooks/usePersonalDetails'; import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; -import useReportAttributes from '@hooks/useReportAttributes'; +import {useAllReportAttributes} from '@hooks/useReportAttributes'; import useShouldReturnTripRooms from '@hooks/useShouldReturnTripRooms'; import {getSections, getSortedSections, getSortedTransactionData, getValidGroupBy, isSearchDataLoaded, isTransactionGroupListItemType} from '@libs/SearchUIUtils'; @@ -161,7 +161,7 @@ function useSearchSnapshot({queryJSON, searchResults, transactions, reportAction const {currentSearchKey} = useSearchQueryContext(); const {shouldUseLiveData} = useSearchResultsContext(); const isActionLoadingSet = useActionLoadingReportIDs(); - const reportAttributesDerivedValue = useReportAttributes(); + const reportAttributesDerivedValue = useAllReportAttributes(); const {policyForMovingExpensesID, policyForMovingExpenses} = usePolicyForMovingExpenses(); const isAttendeesEnabledForMovingPolicy = shouldShowAttendees(CONST.IOU.TYPE.SUBMIT, policyForMovingExpenses); diff --git a/src/hooks/useFilteredOptions.ts b/src/hooks/useFilteredOptions.ts index 08b0df0cf3da..b668c37bfb53 100644 --- a/src/hooks/useFilteredOptions.ts +++ b/src/hooks/useFilteredOptions.ts @@ -17,7 +17,7 @@ import useLocalize from './useLocalize'; import useOnyx from './useOnyx'; import {useAllPersonalDetails} from './usePersonalDetails'; import usePrivateIsArchivedMap from './usePrivateIsArchivedMap'; -import useReportAttributes from './useReportAttributes'; +import {useAllReportAttributes} from './useReportAttributes'; import useSortedReportActionsData from './useSortedReportActionsData'; type UseFilteredOptionsConfig = { @@ -102,7 +102,7 @@ function useFilteredOptions(config: UseFilteredOptionsConfig): UseFilteredOption const [pendingDeleteMemberAccountIDsByReportID] = useOnyx(ONYXKEYS.COLLECTION.REPORT_METADATA, {selector: pendingDeleteMemberAccountIDsByReportIDSelector}); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); - const reportAttributesDerived = useReportAttributes(); + const reportAttributesDerived = useAllReportAttributes(); const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); // Option building is locale-dependent, so a consumer that stays mounted through a language switch recomputes. diff --git a/src/hooks/useMarkAsRead.ts b/src/hooks/useMarkAsRead.ts index de4623971f63..4f4c8b8ea8b6 100644 --- a/src/hooks/useMarkAsRead.ts +++ b/src/hooks/useMarkAsRead.ts @@ -27,7 +27,7 @@ import useIsAnonymousUser from './useIsAnonymousUser'; import useIsHiddenWideTabPreMount from './useIsHiddenWideTabPreMount'; import useIsReportActionsLoaded from './useIsReportActionsLoaded'; import useIsReportOutOfSight from './useIsReportOutOfSight'; -import {useDerivedIsEmptyReport} from './useReportAttributes'; +import {useIsEmptyReport} from './useReportAttributes'; import useReportIsArchived from './useReportIsArchived'; // useRef gets reset when the reportID changes (the list reuses the same instance per report), @@ -121,7 +121,7 @@ function useMarkAsRead({ const route = useRoute>(); const isFocused = useIsFocused(); const isReportArchived = useReportIsArchived(reportID); - const derivedIsEmptyReport = useDerivedIsEmptyReport(reportID); + const derivedIsEmptyReport = useIsEmptyReport(reportID); const isReportActionsLoaded = useIsReportActionsLoaded(reportID); // A preloaded tab or a hidden wide submit pre-mount mounts this screen before the user sees it. Marking read assumes the // user is looking, so hold every readNewestAction until the tab is focused or the pre-mount is revealed. diff --git a/src/hooks/usePersonalDetailOptions.ts b/src/hooks/usePersonalDetailOptions.ts index 91a578764003..d9c8dfe9c733 100644 --- a/src/hooks/usePersonalDetailOptions.ts +++ b/src/hooks/usePersonalDetailOptions.ts @@ -17,6 +17,7 @@ import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; import useLocalize from './useLocalize'; import useOnyx from './useOnyx'; +import {useReportAttributesSelector} from './useReportAttributes'; type UseFilteredOptionsConfig = { /** Whether the hook should be enabled (default: true) */ @@ -98,8 +99,8 @@ type FilteredReportAttributes = Record - (reportAttributes: OnyxEntry): FilteredReportAttributes | undefined => { - if ((!shouldStoreReportErrors && !shouldShowBrickRoadIndicator) || !reportAttributes?.reports) { + (reportAttributes: ReportAttributesDerivedValue): FilteredReportAttributes | undefined => { + if (!shouldStoreReportErrors && !shouldShowBrickRoadIndicator) { return undefined; } const map: FilteredReportAttributes = {}; @@ -197,11 +198,11 @@ function usePersonalDetailOptions(config: UseFilteredOptionsConfig = {}): UseFil const {accountID} = useCurrentUserPersonalDetails(); const {formatPhoneNumber, translate} = useLocalize(); const [reports, reportsMetadata] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {selector: reportsSelector}); - const [reportAttributes, reportAttributesMetadata] = useOnyx(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, {selector: reportAttributesSelector}); + const reportAttributes = useReportAttributesSelector(reportAttributesSelector); const [reportNameValuePairs, reportNameValuePairsMetadata] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, {selector: privateIsArchivedSelector}); const allPersonalDetails = usePersonalDetails(); - const isLoading = !enabled || isLoadingOnyxValue(reportsMetadata, reportAttributesMetadata, reportNameValuePairsMetadata); + const isLoading = !enabled || isLoadingOnyxValue(reportsMetadata, reportNameValuePairsMetadata); // The whole derivation chain is skipped while loading (or disabled), so a consumer that only holds the Onyx // subscriptions doesn't rebuild these maps on every collection update. diff --git a/src/hooks/useReportActionsListModel.ts b/src/hooks/useReportActionsListModel.ts index 932f5f5d0af9..697329ff7c24 100644 --- a/src/hooks/useReportActionsListModel.ts +++ b/src/hooks/useReportActionsListModel.ts @@ -19,7 +19,7 @@ import useOnyx from './useOnyx'; import useParentReportAction from './useParentReportAction'; import useReportActionsPagination from './useReportActionsPagination'; import useReportActionsVisibility from './useReportActionsVisibility'; -import {useDerivedIsEmptyReport} from './useReportAttributes'; +import {useIsEmptyReport} from './useReportAttributes'; import useReportIsArchived from './useReportIsArchived'; /** @@ -72,7 +72,7 @@ function useReportActionsListModel(reportID: string, isReportLoadPending: boolea const shouldBeAlignedToTop = shouldReportAlignToTop(report, parentReportAction); const isReportArchived = useReportIsArchived(reportID); - const derivedIsEmptyReport = useDerivedIsEmptyReport(reportID); + const derivedIsEmptyReport = useIsEmptyReport(reportID); const canPerformWriteAction = !!canUserPerformWriteAction(report, isReportArchived); const isAppLoadPending = useIsAppLoadPending(); diff --git a/src/hooks/useReportAttributes.ts b/src/hooks/useReportAttributes.ts index 0f13893c4f99..4234222fa9c6 100644 --- a/src/hooks/useReportAttributes.ts +++ b/src/hooks/useReportAttributes.ts @@ -1,85 +1,93 @@ -import ONYXKEYS from '@src/ONYXKEYS'; -import type {ReportAttributesDerivedValue} from '@src/types/onyx'; +import {getReportAttributes, REPORT_ATTRIBUTES_STATE_DEPENDENCIES} from '@libs/ReportAttributesCache'; -import type {OnyxEntry} from 'react-native-onyx'; +import type {ReportAttributesDerivedValue} from '@src/types/onyx'; -import {reportIsEmptySelector, reportNameSelector} from '@selectors/ReportAttributes'; +import reportNamesByIDsSelector from '@selectors/ReportAttributes'; +import {useOnyxState} from 'react-native-onyx'; -import useOnyx from './useOnyx'; +const isSameReference = (a: T, b: T) => a === b; /** - * Returns `reports` from the REPORT_ATTRIBUTES derived value. - * - * This hook intentionally avoids using a selector. When a selector is passed to - * `useOnyx`, it forces a `deepEqual` comparison on every Onyx update cycle. Because - * `reports` is a large `Record`, that deep comparison is - * O(n) and expensive. + * Returns the attributes of every report, computed from the live Onyx state. The map keeps its reference until a + * change touches one of its reports, so it is compared by reference: a deep compare would walk every report. */ -function useReportAttributes() { - const [reportAttributes] = useOnyx(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES); - return reportAttributes?.reports; +function useAllReportAttributes(): ReportAttributesDerivedValue['reports'] { + return useOnyxState((state) => getReportAttributes(state).reports, { + dependencies: REPORT_ATTRIBUTES_STATE_DEPENDENCIES, + selectorEquality: isSameReference, + }); } /** - * Returns a single report's attributes using a selector. - * Deep comparison is cheap (single small object), so re-renders only occur - * when that specific report's attributes change — not on every global report change. + * Returns what `selector` picks from the report attributes. The output is compared deeply, so keep it small: the + * component re-renders only when it changes. */ -function useReportAttributesByID(reportID: string | undefined) { - const reportAttributesByIDSelector = (value: OnyxEntry) => (reportID ? value?.reports?.[reportID] : undefined); - const [reportAttributes] = useOnyx(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, { - selector: reportAttributesByIDSelector, +function useReportAttributesSelector(selector: (attributes: ReportAttributesDerivedValue) => TOutput): TOutput { + return useOnyxState((state) => selector(getReportAttributes(state)), {dependencies: REPORT_ATTRIBUTES_STATE_DEPENDENCIES}); +} + +/** Returns one report's attributes. The component re-renders only when that report's attributes change. */ +function useReportAttributesEntry(reportID: string | undefined) { + return useOnyxState((state) => (reportID ? getReportAttributes(state).reports[reportID] : undefined), { + dependencies: REPORT_ATTRIBUTES_STATE_DEPENDENCIES, }); - return reportAttributes; } -/** - * Returns a single report's name using a selector. - * - * Use this when a component only needs one report's name: the selector output is a primitive string, so its - * comparison is trivial and the component re-renders only when that specific report's name changes — not on - * every global report attribute change. - */ -function useDerivedReportNameByReportID(reportID: string | undefined) { - const [reportName] = useOnyx(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, { - selector: (value: OnyxEntry) => reportNameSelector(value, reportID), +/** Returns the action badge and RBR/GBR status of one report, re-rendering only when one of them changes. */ +function useReportStatus(reportID: string | undefined) { + return useOnyxState( + (state) => { + const attributes = reportID ? getReportAttributes(state).reports[reportID] : undefined; + if (!attributes) { + return undefined; + } + return { + actionBadge: attributes.actionBadge, + actionTargetReportActionID: attributes.actionTargetReportActionID, + brickRoadStatus: attributes.brickRoadStatus, + }; + }, + {dependencies: REPORT_ATTRIBUTES_STATE_DEPENDENCIES}, + ); +} + +/** Returns one report's name. The component re-renders only when that name changes. */ +function useReportName(reportID: string | undefined) { + return useOnyxState((state) => (reportID ? getReportAttributes(state).reports[reportID]?.reportName : undefined), { + dependencies: REPORT_ATTRIBUTES_STATE_DEPENDENCIES, }); - return reportName; } -/** - * Returns a `{reportID: reportName}` map for the given reportIDs from a single REPORT_ATTRIBUTES subscription. - * - * Use this instead of calling `useDerivedReportNameByReportID` multiple times in one component: it subscribes to - * REPORT_ATTRIBUTES once (not once per report) while still selecting only the needed names (reusing - * `reportNameSelector`), so the component re-renders only when one of those names changes. The selector output is - * tiny (one name per requested ID), so its `deepEqual` is cheap. - */ -function useDerivedReportNamesByReportIDs(reportIDs: Array) { - const [reportNames] = useOnyx(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, { - selector: (value: OnyxEntry) => - reportIDs.reduce>((acc, reportID) => { +/** Returns a `{reportID: reportName}` map for the given reportIDs, re-rendering only when one of those names changes. */ +function useReportNames(reportIDs: Array) { + return useOnyxState( + (state) => { + const {reports} = getReportAttributes(state); + const reportNames: Record = {}; + for (const reportID of reportIDs) { if (reportID) { - acc[reportID] = reportNameSelector(value, reportID); + reportNames[reportID] = reports[reportID]?.reportName; } - return acc; - }, {}), - }); - return reportNames; + } + return reportNames; + }, + {dependencies: REPORT_ATTRIBUTES_STATE_DEPENDENCIES}, + ); } /** - * Returns a single report's cached `isEmpty` flag using a selector. - * - * The selector output is a primitive boolean, so its comparison is trivial and the component re-renders only when - * that specific report's emptiness changes — not on every global report attribute change. + * Returns `{reportID: {reportName}}` for the given reportIDs, the shape the LHN option builders take, re-rendering + * only when one of those names changes. */ -function useDerivedIsEmptyReport(reportID: string | undefined) { - const [isEmpty] = useOnyx(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, { - selector: (value: OnyxEntry) => reportIsEmptySelector(value, reportID), +function useReportNameEntries(reportIDs: Array) { + return useOnyxState((state) => reportNamesByIDsSelector(getReportAttributes(state), reportIDs), {dependencies: REPORT_ATTRIBUTES_STATE_DEPENDENCIES}); +} + +/** Returns one report's `isEmpty` flag. The component re-renders only when it changes. */ +function useIsEmptyReport(reportID: string | undefined) { + return useOnyxState((state) => (reportID ? getReportAttributes(state).reports[reportID]?.isEmpty : undefined), { + dependencies: REPORT_ATTRIBUTES_STATE_DEPENDENCIES, }); - return isEmpty; } -export default useReportAttributes; -export {useReportAttributesByID, useDerivedReportNameByReportID, useDerivedReportNamesByReportIDs, useDerivedIsEmptyReport}; +export {useAllReportAttributes, useReportAttributesSelector, useReportAttributesEntry, useReportStatus, useReportName, useReportNames, useReportNameEntries, useIsEmptyReport}; diff --git a/src/hooks/useSearchSections.ts b/src/hooks/useSearchSections.ts index 19733b0ac474..ae91888314ea 100644 --- a/src/hooks/useSearchSections.ts +++ b/src/hooks/useSearchSections.ts @@ -11,7 +11,7 @@ import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; import useFilterPendingDeleteReports from './useFilterPendingDeleteReports'; import useLocalize from './useLocalize'; import useOnyx from './useOnyx'; -import useReportAttributes from './useReportAttributes'; +import {useAllReportAttributes} from './useReportAttributes'; type UseSearchSectionsResult = { allReports: Array; @@ -35,7 +35,7 @@ function useSearchSections(): UseSearchSectionsResult { const [reportNameValuePairs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); - const reportAttributesDerivedValue = useReportAttributes(); + const reportAttributesDerivedValue = useAllReportAttributes(); const {type, sortBy, sortOrder, groupBy} = lastSearchQuery?.queryJSON ?? {}; const searchResultsData = currentSearchResults?.data; diff --git a/src/hooks/useSearchSelector/base.ts b/src/hooks/useSearchSelector/base.ts index 8b3989a2db3f..9fb899b11c18 100644 --- a/src/hooks/useSearchSelector/base.ts +++ b/src/hooks/useSearchSelector/base.ts @@ -8,6 +8,7 @@ import useFilteredOptions from '@hooks/useFilteredOptions'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; +import {useAllReportAttributes} from '@hooks/useReportAttributes'; import useSortedReportActionsData from '@hooks/useSortedReportActionsData'; import type {GetOptionsConfig, Option, OptionList, Options, SearchOption} from '@libs/OptionsListUtils'; @@ -193,7 +194,7 @@ function useSearchSelectorBase({ const {translate, dateFnsLocale, localeCompare, formatPhoneNumber} = useLocalize(); const {convertToDisplayString, convertToDisplayStringWithoutCurrency} = useCurrencyListActions(); const {isBetaEnabled} = usePermissions(); - const [reportAttributesDerived] = useOnyx(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES); + const reportAttributesDerived = useAllReportAttributes(); const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); const [selectedOptions, setSelectedOptions] = useState(initialSelected ?? []); const [maxResults, setMaxResults] = useState(maxResultsPerPage); @@ -310,7 +311,7 @@ function useSearchSelectorBase({ currentUserAccountID, currentUserEmail, personalDetails, - reportAttributesDerived: reportAttributesDerived?.reports, + reportAttributesDerived, allPolicyTags, sortedActions, transactionThreadIDs, @@ -353,7 +354,7 @@ function useSearchSelectorBase({ recentAttendees, includeRecentReports: !shouldAllowNameOnlyOptions, countryCode, - reportAttributesDerived: reportAttributesDerived?.reports, + reportAttributesDerived, allPolicyTags, sortedActions, transactionThreadIDs, @@ -398,7 +399,7 @@ function useSearchSelectorBase({ includeUserToInvite, personalDetails, countryCode, - reportAttributesDerived: reportAttributesDerived?.reports, + reportAttributesDerived, allPolicyTags, visibleReportActionsData, sortedActions, @@ -442,7 +443,7 @@ function useSearchSelectorBase({ shouldAcceptName: true, personalDetails, countryCode, - reportAttributesDerived: reportAttributesDerived?.reports, + reportAttributesDerived, allPolicyTags, sortedActions, transactionThreadIDs, diff --git a/src/hooks/useSidebarOrderedReports.tsx b/src/hooks/useSidebarOrderedReports.tsx index 8612b4f409cf..1e5aad489a0f 100644 --- a/src/hooks/useSidebarOrderedReports.tsx +++ b/src/hooks/useSidebarOrderedReports.tsx @@ -22,7 +22,7 @@ import useNetwork from './useNetwork'; import useOnyx from './useOnyx'; import usePermissions from './usePermissions'; import usePrevious from './usePrevious'; -import useReportAttributes from './useReportAttributes'; +import {useAllReportAttributes} from './useReportAttributes'; import useResponsiveLayout from './useResponsiveLayout'; import useStableArrayReference from './useStableArrayReference'; @@ -121,7 +121,7 @@ function SidebarOrderedReportsContextProvider({ const [guideAccountIDs] = useOnyx(ONYXKEYS.DERIVED.GUIDE_ACCOUNT_IDS); const prevGuideAccountIDs = usePrevious(guideAccountIDs); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); - const reportAttributes = useReportAttributes(); + const reportAttributes = useAllReportAttributes(); const prevReportAttributes = usePrevious(reportAttributes); const reportAttributesUpdates = useMemo(() => getReportAttributesUpdates(reportAttributes, prevReportAttributes), [reportAttributes, prevReportAttributes]); const [currentReportsToDisplay, setCurrentReportsToDisplay] = useState({}); diff --git a/src/libs/IOUAmountSubmission.ts b/src/libs/IOUAmountSubmission.ts index b4476bf79aab..51bb24f31250 100644 --- a/src/libs/IOUAmountSubmission.ts +++ b/src/libs/IOUAmountSubmission.ts @@ -111,7 +111,7 @@ type SubmitAmountArgs = { duplicateTransactions: OnyxCollection; duplicateTransactionViolations: OnyxCollection; isTrackIntentUser: boolean | undefined; - reportAttributesDerivedValue: OnyxEntry; + reportAttributes: ReportAttributesDerivedValue['reports'] | undefined; isASAPSubmitBetaEnabled: boolean; quickAction: OnyxEntry; onboarding: OnyxEntry; @@ -264,7 +264,7 @@ function buildReportParticipants(args: SubmitAmountArgs) { report, policy, currentUserPersonalDetails, - reportAttributesDerivedValue, + reportAttributes, allReportDrafts, allReportNVPs, allPersonalDetails, @@ -276,7 +276,7 @@ function buildReportParticipants(args: SubmitAmountArgs) { rules, } = args; const selectedParticipants = getMoneyRequestParticipantsFromReport(report, currentUserPersonalDetails.accountID); - const reportAttributesReports = reportAttributesDerivedValue?.reports; + const reportAttributesReports = reportAttributes; const reportIDToCheck = isMoneyRequestReport(report) ? report?.chatReportID : report?.reportID; const reportDraft = allReportDrafts?.[`${ONYXKEYS.COLLECTION.REPORT_DRAFT}${reportIDToCheck}`]; return selectedParticipants.map((participant) => { diff --git a/src/libs/Navigation/AppNavigator/AuthScreensInitHandler.tsx b/src/libs/Navigation/AppNavigator/AuthScreensInitHandler.tsx index b46f91efc8bb..4f53f3694fe5 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreensInitHandler.tsx +++ b/src/libs/Navigation/AppNavigator/AuthScreensInitHandler.tsx @@ -12,7 +12,7 @@ import useOneTransactionThreadReportID from '@hooks/useOneTransactionThreadRepor import useOnyx from '@hooks/useOnyx'; import usePersonalDetailByLogin from '@hooks/usePersonalDetailByLogin'; import useReconcileHighContrastIntent from '@hooks/useReconcileHighContrastIntent'; -import useReportAttributes from '@hooks/useReportAttributes'; +import {useAllReportAttributes} from '@hooks/useReportAttributes'; import useRootNavigationState from '@hooks/useRootNavigationState'; import {init, isClientTheLeader} from '@libs/ActiveClientManager'; @@ -113,7 +113,7 @@ function AuthScreensInitHandler() { const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const delegateAccountID = useDelegateAccountID(); - const reportAttributes = useReportAttributes(); + const reportAttributes = useAllReportAttributes(); // We use a ref so the Pusher callback (registered once on mount) always reads the latest value without re-subscribing. const reportAttributesRef = useRef(reportAttributes); reportAttributesRef.current = reportAttributes; diff --git a/src/libs/Navigation/AppNavigator/KeyboardShortcutsHandler/MarkAllMessagesAsReadHandler.tsx b/src/libs/Navigation/AppNavigator/KeyboardShortcutsHandler/MarkAllMessagesAsReadHandler.tsx index abd34129f683..294c2c61474b 100644 --- a/src/libs/Navigation/AppNavigator/KeyboardShortcutsHandler/MarkAllMessagesAsReadHandler.tsx +++ b/src/libs/Navigation/AppNavigator/KeyboardShortcutsHandler/MarkAllMessagesAsReadHandler.tsx @@ -1,5 +1,5 @@ import useOnyx from '@hooks/useOnyx'; -import useReportAttributes from '@hooks/useReportAttributes'; +import {useAllReportAttributes} from '@hooks/useReportAttributes'; import markAllMessagesAsRead from '@libs/actions/Report/MarkAllMessageAsRead'; import KeyboardShortcut from '@libs/KeyboardShortcut'; @@ -13,7 +13,7 @@ import {useEffect, useRef} from 'react'; function MarkAllMessagesAsReadHandler() { const [reportNameValuePairs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, {selector: reportNameValuePairsArchivedSelector}); - const reportAttributesDerived = useReportAttributes(); + const reportAttributesDerived = useAllReportAttributes(); const [session] = useOnyx(ONYXKEYS.SESSION, {selector: authTokenTypeSelector}); // The keyboard-shortcut callback below is registered once on mount, so keep the latest archived state in a ref // for it to read at fire time instead of closing over a stale value. diff --git a/src/libs/ReportAttributesCache.ts b/src/libs/ReportAttributesCache.ts new file mode 100644 index 000000000000..4c1283bedffa --- /dev/null +++ b/src/libs/ReportAttributesCache.ts @@ -0,0 +1,217 @@ +/** + * One module-level cache of the report attributes map, computed on demand from the Onyx state that the + * `useOnyxState` hooks hand in. useOnyxState caches per hook instance, so without this every consumer would + * recompute the whole map. The first consumer that renders after a write diffs the dependencies against the + * ones behind the cached map, recomputes only the reports that change touched, and every other consumer of the + * same state gets the cached map back. + * + * The map is saved to Onyx on idle, and the first compute after an app start begins from that snapshot instead + * of computing every report, the way the derived value used to begin from its stored value. + */ + +import IntlStore from '@src/languages/IntlStore'; +import type {OnyxKey} from '@src/ONYXKEYS'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Report, ReportAttributesDerivedValue, ReportAttributesSnapshot} from '@src/types/onyx'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; + +import type {OnyxCollection, OnyxEntry, UseOnyxStateSelector} from 'react-native-onyx'; + +import debounce from 'lodash/debounce'; +import {AppState} from 'react-native'; +import OnyxKeys from 'react-native-onyx/dist/OnyxKeys'; + +import type {ReportAttributesComputeContext, ReportAttributesDependencyValues} from './computeReportAttributes'; + +import saveReportAttributesSnapshot from './actions/ReportAttributesSnapshot'; +import createReportAttributesComputer, {REPORT_ATTRIBUTES_DEPENDENCIES} from './computeReportAttributes'; +import getCollectionDelta from './getCollectionDelta'; +import Log from './Log'; + +// Translations load asynchronously after a locale change, and names must not be computed in the new locale +// before its strings are there, so the locale is taken from IntlStore only once loading has finished. The snapshot +// is read only by the first compute after an app start. +const REPORT_ATTRIBUTES_STATE_DEPENDENCIES = [...REPORT_ATTRIBUTES_DEPENDENCIES, ONYXKEYS.RAM_ONLY_ARE_TRANSLATIONS_LOADING, ONYXKEYS.SESSION, ONYXKEYS.REPORT_ATTRIBUTES_SNAPSHOT] as const; + +// Saving rewrites the whole map, so it waits for a quiet moment instead of following every change. +const SNAPSHOT_SAVE_DELAY_MS = 5000; +const SNAPSHOT_SAVE_MAX_WAIT_MS = 30000; + +type ReportAttributesStateView = Parameters>[0]; + +const PREFERRED_LOCALE_INDEX = REPORT_ATTRIBUTES_DEPENDENCIES.indexOf(ONYXKEYS.NVP_PREFERRED_LOCALE); + +const EMPTY_REPORT_ATTRIBUTES: ReportAttributesDerivedValue = {reports: {}, locale: null}; + +let computeReportAttributes = createReportAttributesComputer(); +let cachedDependencyValues: ReportAttributesDependencyValues | undefined; +let cachedValue: ReportAttributesDerivedValue | undefined; +let loadedLocale: ReturnType; + +let snapshotAccountID: number | undefined; + +const saveSnapshot = debounce( + () => { + if (!cachedValue || snapshotAccountID === undefined) { + return; + } + saveReportAttributesSnapshot({...cachedValue, accountID: snapshotAccountID}); + }, + SNAPSHOT_SAVE_DELAY_MS, + {maxWait: SNAPSHOT_SAVE_MAX_WAIT_MS}, +); + +/** Runs a pending snapshot save right away instead of waiting for a quiet moment. */ +function flushReportAttributesSnapshot() { + saveSnapshot.flush(); +} + +// The app can be killed once it is in the background, so a pending save runs before that. +AppState.addEventListener('change', (state) => { + if (state !== 'background') { + return; + } + flushReportAttributesSnapshot(); +}); + +function resetReportAttributesCache() { + saveSnapshot.cancel(); + computeReportAttributes = createReportAttributesComputer(); + cachedDependencyValues = undefined; + cachedValue = undefined; +} + +/** + * Starts from the saved snapshot when it was computed for this account and locale. Reports added or removed since + * it was saved are handed to the compute as changed, so only they are computed. + */ +function buildSnapshotContext(snapshot: OnyxEntry, reports: OnyxCollection, accountID: number | undefined): ReportAttributesComputeContext | undefined { + if (!snapshot || !reports || snapshot.accountID !== accountID || snapshot.locale !== loadedLocale) { + return undefined; + } + const changedReports: Record = {}; + let hasChangedReports = false; + for (const key of Object.keys(reports)) { + if (!snapshot.reports[key.slice(ONYXKEYS.COLLECTION.REPORT.length)]) { + changedReports[key] = reports[key]; + hasChangedReports = true; + } + } + for (const reportID of Object.keys(snapshot.reports)) { + const key = `${ONYXKEYS.COLLECTION.REPORT}${reportID}`; + if (!(key in reports)) { + changedReports[key] = undefined; + hasChangedReports = true; + } + } + return { + currentValue: {reports: snapshot.reports, locale: snapshot.locale}, + sourceValues: hasChangedReports ? {[ONYXKEYS.COLLECTION.REPORT]: changedReports} : undefined, + triggeredKeys: new Set([ONYXKEYS.COLLECTION.REPORT]), + }; +} + +function readDependencyValues(state: ReportAttributesStateView): ReportAttributesDependencyValues { + // A tuple can't be built by mapping over the keys and keep its per-index types. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const values = REPORT_ATTRIBUTES_DEPENDENCIES.map((key) => state[key]) as unknown as ReportAttributesDependencyValues; + values[PREFERRED_LOCALE_INDEX] = loadedLocale; + return values; +} + +function areDependencyValuesUnchanged(values: ReportAttributesDependencyValues, previousValues: ReportAttributesDependencyValues | undefined): boolean { + if (!previousValues) { + return false; + } + for (let index = 0; index < values.length; index++) { + if (values[index] !== previousValues[index]) { + return false; + } + } + return true; +} + +/** Builds the same delta the OnyxDerived engine hands a config: the changed members of each collection, and every changed key. */ +function buildComputeContext(values: ReportAttributesDependencyValues, previousValues: ReportAttributesDependencyValues): ReportAttributesComputeContext { + const triggeredKeys = new Set(); + const sourceValues: Record = {}; + let hasSourceValues = false; + for (let index = 0; index < REPORT_ATTRIBUTES_DEPENDENCIES.length; index++) { + if (values[index] === previousValues[index]) { + continue; + } + const key = REPORT_ATTRIBUTES_DEPENDENCIES[index]; + triggeredKeys.add(key); + if (OnyxKeys.isCollectionKey(key)) { + // A collection dependency holds a collection at every collection index. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const delta = getCollectionDelta(values[index] as OnyxCollection, previousValues[index] as OnyxCollection); + if (delta !== undefined) { + sourceValues[key] = delta; + hasSourceValues = true; + } + } else if (values[index] !== undefined) { + sourceValues[key] = values[index]; + hasSourceValues = true; + } + } + return { + currentValue: cachedValue, + sourceValues: hasSourceValues ? sourceValues : undefined, + triggeredKeys, + }; +} + +/** + * Returns the report attributes for the given Onyx state, computing only what changed since the last call. + * Returns the same reference while none of the dependencies changed. + */ +function getReportAttributes(state: ReportAttributesStateView): ReportAttributesDerivedValue { + // The flag is undefined after Onyx.clear until the next locale change, and the loaded translations stay usable then. + if (state[ONYXKEYS.RAM_ONLY_ARE_TRANSLATIONS_LOADING] !== true) { + loadedLocale = IntlStore.getCurrentLocale() ?? loadedLocale; + } + + const reports = state[ONYXKEYS.COLLECTION.REPORT]; + // No reports means Onyx was cleared (sign out) or has not loaded yet, so the next compute must start from scratch. + if (!reports || isEmptyObject(reports)) { + resetReportAttributesCache(); + return EMPTY_REPORT_ATTRIBUTES; + } + + // The names depend on the loaded translations, so wait for them instead of computing every name twice. + if (!loadedLocale) { + return cachedValue ?? EMPTY_REPORT_ATTRIBUTES; + } + + const values = readDependencyValues(state); + if (cachedValue && areDependencyValuesUnchanged(values, cachedDependencyValues)) { + return cachedValue; + } + + const accountID = state[ONYXKEYS.SESSION]?.accountID; + const context: ReportAttributesComputeContext = cachedDependencyValues + ? buildComputeContext(values, cachedDependencyValues) + : (buildSnapshotContext(state[ONYXKEYS.REPORT_ATTRIBUTES_SNAPSHOT], reports, accountID) ?? { + currentValue: undefined, + sourceValues: undefined, + triggeredKeys: new Set(REPORT_ATTRIBUTES_DEPENDENCIES), + }); + + try { + const previousValue = cachedValue; + cachedValue = computeReportAttributes(values, context); + cachedDependencyValues = values; + snapshotAccountID = accountID; + if (cachedValue !== previousValue) { + saveSnapshot(); + } + } catch (error) { + // Keep the old baselines, so the next change re-diffs everything since the last good compute. + Log.alert('[ReportAttributesCache] compute threw, keeping the previous report attributes', {error}); + } + return cachedValue ?? EMPTY_REPORT_ATTRIBUTES; +} + +export {flushReportAttributesSnapshot, getReportAttributes, resetReportAttributesCache, REPORT_ATTRIBUTES_STATE_DEPENDENCIES}; +export type {ReportAttributesStateView}; diff --git a/src/libs/ReportAttributesComputeUtils.ts b/src/libs/ReportAttributesComputeUtils.ts new file mode 100644 index 000000000000..313b1de24e82 --- /dev/null +++ b/src/libs/ReportAttributesComputeUtils.ts @@ -0,0 +1,395 @@ +/** + * Pure per-report computations behind the report attributes (name, RBR/GBR status, action badge) that the LHN, + * option lists and report screens show. They take every Onyx input as a parameter, so ReportAttributesCache can + * run them from the state the useOnyxState hooks hand in. + */ + +import type {LocaleContextProps, LocalizedTranslate} from '@components/LocaleContextProvider'; + +import type {CurrencyListActionsContextType} from '@hooks/useCurrencyList'; + +import CONST from '@src/CONST'; +import IntlStore from '@src/languages/IntlStore'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type { + CardList, + CurrencyList, + IntroSelected, + Locale, + PersonalDetailsList, + Policy, + PolicyTagLists, + Report, + ReportActions, + ReportAttributesDerivedValue, + ReportMetadata, + ReportNameValuePairs, + Rule, + Session, + Transaction, + TransactionViolation, +} from '@src/types/onyx'; +import type {ReportAttributes} from '@src/types/onyx/DerivedValues'; + +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; + +import {isTrackIntentUserSelector} from '@selectors/Onboarding'; + +import {getReportPreviewReportAction} from './actions/IOU/MoneyRequestBuilder'; +import {convertToFrontendAmountAsInteger, sanitizeCurrencyCode} from './CurrencyUtils'; +import {formatPhoneNumberWithCountryCode} from './LocalePhoneNumber'; +import {translate as translateForLocale} from './Localize'; +import {format, formatToParts} from './NumberFormatUtils'; +import {getLoginByAccountID} from './PersonalDetailsUtils'; +import {isDeletedAction} from './ReportActionsUtils'; +import {computeReportName} from './ReportNameUtils'; +import { + generateReportAttributes, + getPendingDeleteMemberAccountIDs, + hasViolations, + hasVisibleReportFieldViolations, + isArchivedReport, + isOpenReport, + isPolicyAdmin, + isPolicyExpenseChat, + isProcessingReport, + isSupportTicket, +} from './ReportUtils'; +import SidebarUtils from './SidebarUtils'; + +type ReportAttributesInputs = { + reports: OnyxCollection; + reportActions: OnyxCollection; + reportNameValuePairs: OnyxCollection; + reportMetadata: OnyxCollection; + transactions: OnyxCollection; + transactionViolations: OnyxCollection; + personalDetails: OnyxEntry; + session: OnyxEntry; + policies: OnyxCollection; + policyTags: OnyxCollection; + conciergeReportID: OnyxEntry; + introSelected: OnyxEntry; + rules: OnyxCollection; + cardList: OnyxEntry; + isOffline: boolean; + /** Transactions grouped by their reportID, see `buildTransactionsByReportID`. */ + reportsTransactions: Record; +}; + +type ReportAttributesFormatters = { + translate: LocalizedTranslate; + dateFnsLocale: ReturnType; + formatPhoneNumber: LocaleContextProps['formatPhoneNumber']; + getCurrencySymbol: CurrencyListActionsContextType['getCurrencySymbol']; + convertToDisplayString: CurrencyListActionsContextType['convertToDisplayString']; + convertToDisplayStringWithoutCurrency: CurrencyListActionsContextType['convertToDisplayStringWithoutCurrency']; +}; + +type ReportStatusEntry = Omit; + +/** + * There is no component here to inject the formatters from LocaleContextProvider and CurrencyListContextProvider, + * so this mirrors the providers' implementations from the locale and the currency list. + */ +function buildReportAttributesFormatters(preferredLocale: OnyxEntry, countryCodeByIP: OnyxEntry, currencyList: OnyxEntry): ReportAttributesFormatters { + const getCurrencyDecimals = (currencyCode: string): number => currencyList?.[currencyCode]?.decimals ?? CONST.DEFAULT_CURRENCY_DECIMALS; + return { + translate: (path, ...parameters) => translateForLocale(preferredLocale, path, ...parameters), + dateFnsLocale: IntlStore.getDateFnsLocale(preferredLocale), + formatPhoneNumber: (phoneNumber) => formatPhoneNumberWithCountryCode(phoneNumber, countryCodeByIP ?? CONST.DEFAULT_COUNTRY_CODE), + getCurrencySymbol: (currencyCode) => currencyList?.[currencyCode]?.symbol, + convertToDisplayString: (amountInCents, currencyCode) => { + const sanitizedCurrency = sanitizeCurrencyCode(currencyCode); + const decimals = getCurrencyDecimals(sanitizedCurrency); + const convertedAmount = convertToFrontendAmountAsInteger(amountInCents ?? 0, decimals); + return format(preferredLocale, convertedAmount, { + style: 'currency', + currency: sanitizedCurrency, + + // We are forcing the number of decimals because we override the default number of decimals in the backend for some currencies + // See: https://github.com/Expensify/PHP-Libs/pull/834 + minimumFractionDigits: decimals, + // For currencies that have decimal places > 2, floor to 2 instead as we don't support more than 2 decimal places. + maximumFractionDigits: 2, + }); + }, + convertToDisplayStringWithoutCurrency: (amountInCents, currencyCode = CONST.CURRENCY.USD) => { + const sanitizedCurrency = sanitizeCurrencyCode(currencyCode); + const decimals = getCurrencyDecimals(sanitizedCurrency); + const convertedAmount = convertToFrontendAmountAsInteger(amountInCents, decimals); + return formatToParts(preferredLocale, convertedAmount, { + style: 'currency', + currency: sanitizedCurrency, + minimumFractionDigits: decimals, + maximumFractionDigits: 2, + }) + .filter((x) => x.type !== 'currency') + .filter((x) => x.type !== 'literal' || x.value.trim().length !== 0) + .map((x) => x.value) + .join(''); + }, + }; +} + +/** + * `getReportNameByID` names the report a moved or unreported transaction thread took its expense from. Leave it + * undefined when computing that report's name, so a chain of such threads stops after one step. + */ +function computeReportNameEntry( + report: Report, + inputs: ReportAttributesInputs, + formatters: ReportAttributesFormatters, + getReportNameByID: ((reportID: string) => string | undefined) | undefined, +): string { + const reportMetadata = inputs.reportMetadata?.[`${ONYXKEYS.COLLECTION.REPORT_METADATA}${report.reportID}`]; + return computeReportName({ + report, + reports: inputs.reports, + policies: inputs.policies, + transactions: inputs.transactions, + allReportNameValuePairs: inputs.reportNameValuePairs, + personalDetailsList: inputs.personalDetails, + reportActions: inputs.reportActions, + currentUserAccountID: inputs.session?.accountID ?? CONST.DEFAULT_NUMBER_ID, + currentUserLogin: inputs.session?.email ?? '', + translate: formatters.translate, + dateFnsLocale: formatters.dateFnsLocale, + allPolicyTags: inputs.policyTags, + conciergeReportID: inputs.conciergeReportID ?? undefined, + getReportNameByID, + reportTransactions: inputs.reportsTransactions, + isTrackIntentUser: isTrackIntentUserSelector(inputs.introSelected), + convertToDisplayString: formatters.convertToDisplayString, + convertToDisplayStringWithoutCurrency: formatters.convertToDisplayStringWithoutCurrency, + getCurrencySymbol: formatters.getCurrencySymbol, + pendingDeleteMemberAccountIDs: getPendingDeleteMemberAccountIDs(reportMetadata?.pendingChatMembers), + formatPhoneNumber: formatters.formatPhoneNumber, + rules: inputs.rules, + }); +} + +function computeReportStatusEntry(report: Report, inputs: ReportAttributesInputs): ReportStatusEntry { + const {reports, reportActions, transactionViolations, transactions, policies, session, rules, cardList, isOffline} = inputs; + const currentUserAccountID = session?.accountID ?? CONST.DEFAULT_NUMBER_ID; + const chatReport = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${report.chatReportID}`]; + const reportNameValuePair = inputs.reportNameValuePairs?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report.reportID}`]; + const reportActionsList = reportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`]; + const isReportArchived = isArchivedReport(reportNameValuePair); + const reportMetadata = inputs.reportMetadata?.[`${ONYXKEYS.COLLECTION.REPORT_METADATA}${report.reportID}`]; + const { + violatingReportID, + hasAnyViolations, + requiresAttention, + reportErrors, + oneTransactionThreadReportID, + actionBadge: actionGreenBadge, + actionTargetReportActionID: actionGreenTargetReportActionID, + } = generateReportAttributes({ + report, + chatReport, + reportActions, + transactionViolations, + isReportArchived, + allTransactions: transactions, + reports, + policies, + reportMetadata, + cardList, + currentUserAccountID, + currentUserLogin: session?.email ?? '', + }); + + const policy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`]; + const hasFieldViolations = hasVisibleReportFieldViolations(report, policy, session?.accountID, rules, reportNameValuePair); + + let brickRoadStatus; + let actionBadge; + let actionTargetReportActionID; + let needsParentChatErrorPropagation = false; + const reasonAndReportAction = SidebarUtils.getReasonAndReportActionThatHasRedBrickRoad({ + report, + chatReport, + reportActions: reportActionsList, + hasViolations: hasAnyViolations || hasFieldViolations, + reportErrors, + transactions, + isOffline, + currentUserAccountID, + transactionViolations, + isReportArchived: !!isReportArchived, + reports, + violatingReportID, + }); + + // When the report is ready to submit, always show the green Submit badge + // regardless of violations — the user can submit without fix. + const willShowGreenSubmit = requiresAttention && actionGreenBadge === CONST.REPORT.ACTION_BADGE.SUBMIT; + + // if report has errors or violations, show red dot + // Also skip setting ERROR when we'll show the green Submit badge — let the user submit without fix. + if (!isSupportTicket(report) && reasonAndReportAction && !willShowGreenSubmit) { + needsParentChatErrorPropagation = true; + + // RBR/Fix mirrors GBR's access rule: only show on the child when the user can't already + // see it on the parent workspace chat. The parent still gets ERROR/FIX through + // getParentChatPropagationUpdates, so the actionable indicator surfaces on the workspace chat row + // (which is where C+ wants it). Skips when the chat parent isn't accessible to the user. + const chatPolicy = chatReport?.policyID ? policies?.[`${ONYXKEYS.COLLECTION.POLICY}${chatReport.policyID}`] : undefined; + const isChildOfAccessiblePolicyExpenseChat = !!chatReport && isPolicyExpenseChat(chatReport) && (!!chatReport.isOwnPolicyExpenseChat || isPolicyAdmin(chatPolicy)); + if (!isChildOfAccessiblePolicyExpenseChat) { + brickRoadStatus = CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR; + actionBadge = CONST.REPORT.ACTION_BADGE.FIX; + actionTargetReportActionID = reasonAndReportAction.reportAction?.reportActionID; + } + } + // if report does not have error, check if it should show green dot + if (brickRoadStatus !== CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR && requiresAttention) { + brickRoadStatus = CONST.BRICK_ROAD_INDICATOR_STATUS.INFO; + actionBadge = actionGreenBadge; + actionTargetReportActionID = actionGreenTargetReportActionID; + } + + return { + brickRoadStatus, + requiresAttention, + actionBadge, + actionTargetReportActionID, + reportErrors, + oneTransactionThreadReportID, + needsParentChatErrorPropagation, + }; +} + +// Returns the report-preview action ID of the oldest child in `reportIDs` matching `predicate` +// (oldest by preview-action creation time), or undefined when none match. +const getOldestPreviewActionID = ( + chatReportID: string, + reportIDs: string[] | undefined, + reports: OnyxCollection, + chatReportActions: OnyxEntry, + predicate?: (childReport: OnyxEntry) => boolean, +) => { + let oldestCreated: string | undefined; + let targetReportActionID: string | undefined; + for (const childReportID of reportIDs ?? []) { + if (predicate && !predicate(reports?.[`${ONYXKEYS.COLLECTION.REPORT}${childReportID}`])) { + continue; + } + const reportPreviewAction = getReportPreviewReportAction(chatReportID, childReportID, chatReportActions); + if (!reportPreviewAction) { + continue; + } + if (oldestCreated === undefined || reportPreviewAction.created < oldestCreated) { + oldestCreated = reportPreviewAction.created; + targetReportActionID = reportPreviewAction.reportActionID; + } + } + return targetReportActionID; +}; + +const isActionable = (childReport: OnyxEntry) => isOpenReport(childReport) || isProcessingReport(childReport); + +// Open/processing and the user still needs to fix a violation on it. Violations are read directly from +// transactionViolations so this works even when owner data is absent (e.g. masked Onyx exports). +const needsViolationFix = ( + childReport: OnyxEntry, + childReportOwnerLogin: string | undefined, + policies: OnyxCollection, + transactionViolations: OnyxCollection, + currentUserAccountID: number, + currentUserEmail: string, +) => { + if (!childReport || !isActionable(childReport)) { + return false; + } + const childPolicy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${childReport.policyID}`]; + return hasViolations(childReport.reportID, transactionViolations, currentUserAccountID, currentUserEmail, true, undefined, childReport, childReportOwnerLogin, childPolicy); +}; + +/** + * Returns the new entries of the parent chats that have an errored child report: ERROR/FIX, with the badge pointing + * at the oldest actionable child preview. + */ +function getParentChatPropagationUpdates(reportAttributes: ReportAttributesDerivedValue['reports'], inputs: ReportAttributesInputs): ReportAttributesDerivedValue['reports'] { + const {reports, reportActions, personalDetails, policies, transactionViolations, session} = inputs; + const currentUserAccountID = session?.accountID ?? CONST.DEFAULT_NUMBER_ID; + const currentUserEmail = session?.email ?? ''; + const erroredChildReportIDsByChat = new Map(); + const childReportIDsByChat = new Map(); + for (const report of Object.values(reports ?? {})) { + if (!report?.reportID || !report.chatReportID || report.reportID === report.chatReportID) { + continue; + } + + const childReportIDs = childReportIDsByChat.get(report.chatReportID) ?? []; + childReportIDs.push(report.reportID); + childReportIDsByChat.set(report.chatReportID, childReportIDs); + + // We read `needsParentChatErrorPropagation` rather than `brickRoadStatus` because the per-report + // pass suppresses the child's own brickRoadStatus when the parent workspace chat is accessible — + // we still need to propagate the error up so the parent shows the indicator. + const attributes = reportAttributes[report.reportID]; + if (!attributes?.needsParentChatErrorPropagation && attributes?.brickRoadStatus !== CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR) { + continue; + } + + // When the child IOU's parent action in the chat is deleted (e.g. another user deleted the request + // while an optimistic pay was queued offline), the chat has no actionable surface for the error. + // Skip propagation so the parent DM row doesn't show a stale "Fix" for a request that no longer exists. + // Checked only for children with an error, as it is the costly part of this loop over every report. + const parentReportAction = report.parentReportActionID ? reportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID}`]?.[report.parentReportActionID] : undefined; + if (isDeletedAction(parentReportAction)) { + continue; + } + + const erroredChildReportIDs = erroredChildReportIDsByChat.get(report.chatReportID) ?? []; + erroredChildReportIDs.push(report.reportID); + erroredChildReportIDsByChat.set(report.chatReportID, erroredChildReportIDs); + } + + const updates: ReportAttributesDerivedValue['reports'] = {}; + for (const [chatReportID, erroredChildReportIDs] of erroredChildReportIDsByChat) { + const chatAttributes = reportAttributes[chatReportID]; + if (!chatAttributes) { + continue; + } + + const chatReportActions = reportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReportID}`]; + const actionTargetReportActionID = + getOldestPreviewActionID(chatReportID, erroredChildReportIDs, reports, chatReportActions, isActionable) ?? + getOldestPreviewActionID(chatReportID, childReportIDsByChat.get(chatReportID), reports, chatReportActions, (childReport) => + needsViolationFix(childReport, getLoginByAccountID(childReport?.ownerAccountID, personalDetails), policies, transactionViolations, currentUserAccountID, currentUserEmail), + ) ?? + getOldestPreviewActionID(chatReportID, erroredChildReportIDs, reports, chatReportActions) ?? + chatAttributes.actionTargetReportActionID; + + updates[chatReportID] = { + ...chatAttributes, + brickRoadStatus: CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR, + actionBadge: CONST.REPORT.ACTION_BADGE.FIX, + actionTargetReportActionID, + }; + } + return updates; +} + +/** + * Builds the `getReportNameByID` for one compute pass. It computes the requested report's name from the same inputs + * instead of reading the previous map, so the result does not depend on which report was computed first. + */ +function createReportNameLookup(inputs: ReportAttributesInputs, formatters: ReportAttributesFormatters): (reportID: string) => string | undefined { + const names = new Map(); + return (reportID) => { + if (names.has(reportID)) { + return names.get(reportID); + } + const report = inputs.reports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; + const name = report ? computeReportNameEntry(report, inputs, formatters, undefined) : undefined; + names.set(reportID, name); + return name; + }; +} + +export {getParentChatPropagationUpdates, buildReportAttributesFormatters, computeReportNameEntry, createReportNameLookup, computeReportStatusEntry, getOldestPreviewActionID}; +export type {ReportAttributesFormatters, ReportAttributesInputs, ReportStatusEntry}; diff --git a/src/libs/ReportAttributesUtils.ts b/src/libs/ReportAttributesUtils.ts index 536254f0996f..0db65e3165dc 100644 --- a/src/libs/ReportAttributesUtils.ts +++ b/src/libs/ReportAttributesUtils.ts @@ -1,6 +1,6 @@ import type {OnyxEntry} from 'react-native-onyx'; -/** A per-reportID lookup of report names, as produced by `useDerivedReportNamesByReportIDs`. */ +/** A per-reportID lookup of report names, as produced by `useReportNames`. */ type ReportNamesByID = Record; /** diff --git a/src/libs/ReportNameUtils.ts b/src/libs/ReportNameUtils.ts index c2249c238fab..f1cf3a5afec2 100644 --- a/src/libs/ReportNameUtils.ts +++ b/src/libs/ReportNameUtils.ts @@ -4,19 +4,7 @@ import type {CurrencyListActionsContextType} from '@hooks/useCurrencyList'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type { - PersonalDetails, - PersonalDetailsList, - Policy, - PolicyTagLists, - Report, - ReportAction, - ReportActions, - ReportAttributesDerivedValue, - ReportNameValuePairs, - Rule, - Transaction, -} from '@src/types/onyx'; +import type {PersonalDetails, PersonalDetailsList, Policy, PolicyTagLists, Report, ReportAction, ReportActions, ReportNameValuePairs, Rule, Transaction} from '@src/types/onyx'; import type {SelectedParticipant} from '@src/types/onyx/NewGroupChatDraft'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -217,7 +205,8 @@ type ComputeReportName = { currentUserLogin: string; translate: LocalizedTranslate; conciergeReportID: string | undefined; - reportAttributes?: ReportAttributesDerivedValue['reports']; + /** Returns another report's name, used by moved and unreported transaction threads to name the report the expense came from. */ + getReportNameByID?: (reportID: string) => string | undefined; reportTransactions: Record; isTrackIntentUser: boolean | undefined; convertToDisplayString: CurrencyListActionsContextType['convertToDisplayString']; @@ -565,7 +554,7 @@ function computeReportNameBasedOnReportAction({ reportPolicy, parentReport, personalDetailsList, - reportAttributes, + getReportNameByID, isTrackIntentUser, currentUserAccountID, convertToDisplayString, @@ -580,7 +569,7 @@ function computeReportNameBasedOnReportAction({ reportPolicy: Policy | undefined; parentReport: Report | undefined; personalDetailsList: OnyxEntry; - reportAttributes: ReportAttributesDerivedValue['reports'] | undefined; + getReportNameByID: ((reportID: string) => string | undefined) | undefined; isTrackIntentUser: boolean | undefined; currentUserAccountID: number; convertToDisplayString: CurrencyListActionsContextType['convertToDisplayString']; @@ -745,15 +734,13 @@ function computeReportNameBasedOnReportAction({ if (isActionOfType(parentReportAction, CONST.REPORT.ACTIONS.TYPE.UNREPORTED_TRANSACTION)) { const {fromReportID} = parseMovedTransactionReportIDs(parentReportAction); - const reportName = fromReportID ? reportAttributes?.[fromReportID]?.reportName : undefined; + const reportName = fromReportID ? getReportNameByID?.(fromReportID) : undefined; return Parser.htmlToText(getUnreportedTransactionMessage({translate, fromReportID, derivedReportName: reportName})); } if (isActionOfType(parentReportAction, CONST.REPORT.ACTIONS.TYPE.MOVED_TRANSACTION)) { const {fromReportID, toReportID, displayReportID} = parseMovedTransactionReportIDs(parentReportAction); - return Parser.htmlToText( - getMovedTransactionMessage({translate, fromReportID, toReportID, derivedReportName: displayReportID ? reportAttributes?.[displayReportID]?.reportName : undefined}), - ); + return Parser.htmlToText(getMovedTransactionMessage({translate, fromReportID, toReportID, derivedReportName: displayReportID ? getReportNameByID?.(displayReportID) : undefined})); } if (isActionOfType(parentReportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_MAX_EXPENSE_AMOUNT)) { @@ -1226,7 +1213,7 @@ function computeChatThreadReportName({ } /** - * @internal Use this only for computation in derived report attributes + * @internal Use this only for computing the report attributes (ReportAttributesComputeUtils) * In all other cases you should use `getReportName` */ function computeReportName({ @@ -1243,7 +1230,7 @@ function computeReportName({ translate, allPolicyTags, conciergeReportID, - reportAttributes, + getReportNameByID, reportTransactions, isTrackIntentUser, convertToDisplayString, @@ -1270,7 +1257,7 @@ function computeReportName({ reportPolicy, parentReport, personalDetailsList, - reportAttributes, + getReportNameByID, isTrackIntentUser, currentUserAccountID: currentUserAccountID ?? CONST.DEFAULT_NUMBER_ID, convertToDisplayString, @@ -1302,7 +1289,7 @@ function computeReportName({ currentUserLogin, translate, conciergeReportID, - reportAttributes, + getReportNameByID, reportTransactions, isTrackIntentUser, convertToDisplayString, @@ -1449,15 +1436,14 @@ function computeReportName({ } /** - * Returns the report name from OnyxDerived `reportAttributes` when available, falling back to the raw report object. + * Returns the computed report name when available, falling back to the raw report object. * - * **Always prefer passing `derivedReportName`**, sourced from the derived Onyx key (`ONYXKEYS.DERIVED.REPORT_ATTRIBUTES`) - * i.e. `reportAttributes?.[reportID]?.reportName`. Prefer the O(1) selectors (`useDerivedReportNameByReportID`) over - * subscribing to the whole attributes Record just to read one name. - * The fallback to `report.reportName` exists only for edge-cases where the derived value is not yet populated. + * **Always prefer passing `derivedReportName`**, the name from the report attributes, i.e. `reportAttributes?.[reportID]?.reportName`. + * Prefer `useReportName` over reading the whole attributes map just to read one name. + * The fallback to `report.reportName` exists only for edge-cases where the attributes are not computed yet. * Do NOT compute any part of the name here. Adjust `computeReportName` (internal) function if any change to report name are required. * - * @param derivedReportName the cached name for this report from `ONYXKEYS.DERIVED.REPORT_ATTRIBUTES`. + * @param derivedReportName the computed name for this report from the report attributes (`useReportName`). */ function getReportName(report?: Report, derivedReportName?: string): string { if (!report?.reportID) { diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 260b6773c270..46fcf05da0bd 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -10663,7 +10663,7 @@ function buildOptimisticMoneyRequestEntities({ /** * Check if the report is empty, meaning it has no visible messages (i.e. only a "created" report action). * - * @param derivedIsEmptyReport Always prefer passing this cached flag for this report from ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, i.e. + * @param derivedIsEmptyReport Always prefer passing this flag for this report from the report attributes (`useIsEmptyReport`), i.e. * `reportAttributes?.[reportID]?.isEmpty`. */ function isEmptyReport(report: OnyxEntry, isReportArchived: boolean | undefined, derivedIsEmptyReport: boolean | undefined): boolean { @@ -11337,11 +11337,11 @@ type ShouldReportBeInOptionListParams = { isReportArchived: boolean | undefined; draftComment: string | undefined; conciergeReportID: string | undefined; - /** Pre-computed value from reportAttributes derived value. When provided, skips the expensive requiresAttentionFromCurrentUser recomputation. */ + /** Pre-computed value from the report attributes. When provided, skips the expensive requiresAttentionFromCurrentUser recomputation. */ requiresAttention?: boolean; /** Needed when `requiresAttention` is not pre-computed: the SUBMIT badge is suppressed when every expense was auto-rejected, which is only knowable from the violations. */ transactionViolations?: OnyxCollection; - /** Pre-computed isEmpty flag from reportAttributes derived value. When provided, skips the module-level reportAttributesDerivedValue read inside isEmptyReport. */ + /** Pre-computed isEmpty flag from the report attributes. When provided, isEmptyReport skips computing it. */ derivedIsEmptyReport: boolean | undefined; hasGuidesEmails: boolean; }; @@ -14772,7 +14772,8 @@ function generateReportAttributes({ }) { const reportActionsList = reportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report?.reportID}`]; const parentReportActionsList = reportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report?.parentReportID}`]; - const hasViolationsToDisplayInLHN = !!getViolatingReportIDForRBRInLHN(report, transactionViolations); + const violatingReportID = getViolatingReportIDForRBRInLHN(report, transactionViolations); + const hasViolationsToDisplayInLHN = !!violatingReportID; const hasAnyTypeOfViolations = hasViolationsToDisplayInLHN; const reportErrors = getAllReportErrors( report, @@ -14802,6 +14803,7 @@ function generateReportAttributes({ }) ?? {}; return { + violatingReportID, hasViolationsToDisplayInLHN, hasAnyViolations: hasAnyTypeOfViolations, reportErrors, diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts index acad521f2b8d..7b1135018262 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -683,6 +683,8 @@ type GetReasonAndReportActionThatHasRedBrickRoadParams = { transactionViolations?: OnyxCollection; isReportArchived?: boolean; reports?: OnyxCollection; + /** The result of `getViolatingReportIDForRBRInLHN` when the caller already has it, so the report's sibling reports are not sorted again. */ + violatingReportID?: string | null; }; function getReasonAndReportActionThatHasRedBrickRoad({ @@ -697,12 +699,13 @@ function getReasonAndReportActionThatHasRedBrickRoad({ transactionViolations, isReportArchived = false, reports, + violatingReportID: precomputedViolatingReportID, }: GetReasonAndReportActionThatHasRedBrickRoadParams): ReasonAndReportActionThatHasRedBrickRoad | null { if (isReportArchived) { return null; } - const violatingReportID = getViolatingReportIDForRBRInLHN(report, transactionViolations); + const violatingReportID = precomputedViolatingReportID !== undefined ? precomputedViolatingReportID : getViolatingReportIDForRBRInLHN(report, transactionViolations); if (violatingReportID) { const reportPreviewAction = Object.values(reportActions ?? {}).find((action) => getIOUReportIDFromReportActionPreview(action) === violatingReportID); return { @@ -711,11 +714,12 @@ function getReasonAndReportActionThatHasRedBrickRoad({ }; } - const {reportAction} = getAllReportActionsErrorsAndReportActionThatRequiresAttention(report, reportActions, transactions, currentUserAccountID, isReportArchived, reports); const errors = reportErrors; const hasErrors = Object.keys(errors).length !== 0; if (hasErrors) { + // Scanning the actions for errors is the costly part, and only the errored path needs the action. + const {reportAction} = getAllReportActionsErrorsAndReportActionThatRequiresAttention(report, reportActions, transactions, currentUserAccountID, isReportArchived, reports); return { reason: CONST.RBR_REASONS.HAS_ERRORS, reportAction, diff --git a/src/libs/UnreadIndicatorUpdater/index.ts b/src/libs/UnreadIndicatorUpdater/index.ts index f4572dce6ee1..e6ade4012f61 100644 --- a/src/libs/UnreadIndicatorUpdater/index.ts +++ b/src/libs/UnreadIndicatorUpdater/index.ts @@ -8,7 +8,7 @@ import Navigation, {navigationRef} from '@navigation/Navigation'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Report, ReportActions, ReportAttributesDerivedValue, ReportNameValuePairs, Session} from '@src/types/onyx'; +import type {Report, ReportActions, ReportNameValuePairs, Session} from '@src/types/onyx'; import type {OnyxCollection} from 'react-native-onyx'; @@ -65,15 +65,6 @@ Onyx.connectWithoutView({ }, }); -let reportAttributesDerived: ReportAttributesDerivedValue['reports'] | undefined; -// This subscription is used to update the unread indicators count which is not linked to UI and it does not update any UI state. -Onyx.connectWithoutView({ - key: ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, - callback: (value) => { - reportAttributesDerived = value?.reports; - }, -}); - function getUnreadReportsForUnreadIndicator(reports: OnyxCollection, currentReportID: string | undefined, draftComment: string | undefined) { // Read the in-memory offline state directly since this is an imperative one-shot computation (reactivity is not needed here). const isOffline = getIsOffline(); @@ -100,10 +91,7 @@ function getUnreadReportsForUnreadIndicator(reports: OnyxCollection, cur const oneTransactionThreadReportID = getOneTransactionThreadReportID(report, chatReport, allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report?.reportID}`], isOffline); const oneTransactionThreadReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${oneTransactionThreadReportID}`]; - const derivedIsEmptyReport = report?.reportID ? reportAttributesDerived?.[report.reportID]?.isEmpty : undefined; - const requiresAttention = report?.reportID ? reportAttributesDerived?.[report.reportID]?.requiresAttention : undefined; - - if (!ReportUtils.isUnread(report, oneTransactionThreadReport, isReportArchived, derivedIsEmptyReport)) { + if (!ReportUtils.isUnread(report, oneTransactionThreadReport, isReportArchived, undefined)) { return false; } @@ -124,8 +112,7 @@ function getUnreadReportsForUnreadIndicator(reports: OnyxCollection, cur // TODO: Pass guideAccountIDs in Phase B. hasExpensifyGuidesEmails falls back to the personal details store until then. See https://github.com/Expensify/App/issues/66413. hasGuidesEmails: ReportUtils.isDefaultRoom(report) ? ReportUtils.hasExpensifyGuidesEmails(Object.keys(report?.participants ?? {}).map(Number), undefined) : false, conciergeReportID, - requiresAttention, - derivedIsEmptyReport, + derivedIsEmptyReport: undefined, }); }); } diff --git a/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts b/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts index 4ea4fabd9560..53ea8a0ea641 100644 --- a/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts +++ b/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts @@ -11,7 +11,6 @@ import nonPersonalAndWorkspaceCardListConfig from './configs/nonPersonalAndWorks import oneOnOneChatReportIDsConfig from './configs/oneOnOneChatReportIDs'; import outstandingReportsByPolicyIDConfig from './configs/outstandingReportsByPolicyID'; import personalAndWorkspaceCardListConfig from './configs/personalAndWorkspaceCardList'; -import reportAttributesConfig from './configs/reportAttributes'; import reportTransactionsAndViolationsConfig from './configs/reportTransactionsAndViolations'; import sortedReportActionsConfig from './configs/sortedReportActions'; import spendDataSignatureConfig from './configs/spendDataSignature'; @@ -22,7 +21,6 @@ import visibleReportActionsConfig from './configs/visibleReportActions'; * This object holds our derived value configurations. */ const ONYX_DERIVED_VALUES = { - [ONYXKEYS.DERIVED.REPORT_ATTRIBUTES]: reportAttributesConfig, [ONYXKEYS.DERIVED.REPORT_TRANSACTIONS_AND_VIOLATIONS]: reportTransactionsAndViolationsConfig, [ONYXKEYS.DERIVED.OUTSTANDING_REPORTS_BY_POLICY_ID]: outstandingReportsByPolicyIDConfig, [ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS]: visibleReportActionsConfig, diff --git a/src/libs/actions/OnyxDerived/index.ts b/src/libs/actions/OnyxDerived/index.ts index 1b96c739e1e2..04c8438a225c 100644 --- a/src/libs/actions/OnyxDerived/index.ts +++ b/src/libs/actions/OnyxDerived/index.ts @@ -4,9 +4,7 @@ import {endSpan, getSpan, getSpanByPrefix, getUniqueSpanByPrefix, startSpan} fro import detectOnyxDerivedLoop from '@libs/telemetry/detectOnyxDerivedLoop'; import CONST from '@src/CONST'; -import IntlStore from '@src/languages/IntlStore'; import type {OnyxKey} from '@src/ONYXKEYS'; -import ONYXKEYS from '@src/ONYXKEYS'; import ObjectUtils from '@src/types/utils/ObjectUtils'; import type {OnyxCollection, OnyxValue} from 'react-native-onyx'; @@ -297,26 +295,6 @@ function init() { recomputeDerivedValue(dependencyIndex); }, }); - } else if (dependencyOnyxKey === ONYXKEYS.NVP_PREFERRED_LOCALE) { - // Special case for locale, we want to recompute derived values when the locale change actually loads. - Onyx.connectWithoutView({ - key: ONYXKEYS.RAM_ONLY_ARE_TRANSLATIONS_LOADING, - callback: (value) => { - if (value ?? true) { - Log.info(`[OnyxDerived] translations are still loading, not recomputing derived value for ${key}`); - return; - } - Log.info(`[OnyxDerived] translations loaded, recomputing derived value for ${key}`); - const localeValue = IntlStore.getCurrentLocale(); - if (!localeValue) { - Log.info(`[OnyxDerived] No locale found for derived key ${key}, skipping recompute`); - return; - } - Log.info(`[OnyxDerived] dependency ${dependencyOnyxKey} for derived key ${key} changed, recomputing`); - setDependencyValue(dependencyIndex, localeValue as Parameters[0][typeof dependencyIndex]); - recomputeDerivedValue(dependencyIndex); - }, - }); } else { Onyx.connectWithoutView({ key: dependencyOnyxKey, diff --git a/src/libs/actions/OnyxDerived/types.ts b/src/libs/actions/OnyxDerived/types.ts index c1ca523ec186..16a518193125 100644 --- a/src/libs/actions/OnyxDerived/types.ts +++ b/src/libs/actions/OnyxDerived/types.ts @@ -48,4 +48,4 @@ type OnyxDerivedValueConfig, Deps e onReset?: () => void; }; -export type {OnyxDerivedValueConfig, DerivedValueContext}; +export type {OnyxDerivedValueConfig, DerivedValueContext, DerivedSourceValues}; diff --git a/src/libs/actions/ReportAttributesSnapshot.ts b/src/libs/actions/ReportAttributesSnapshot.ts new file mode 100644 index 000000000000..0e45a5d0aa93 --- /dev/null +++ b/src/libs/actions/ReportAttributesSnapshot.ts @@ -0,0 +1,12 @@ +import ONYXKEYS from '@src/ONYXKEYS'; +import type {ReportAttributesSnapshot} from '@src/types/onyx'; + +import Onyx from 'react-native-onyx'; + +/** Saves the report attributes, so the next app start can begin from them instead of computing every report. */ +function saveReportAttributesSnapshot(snapshot: ReportAttributesSnapshot) { + // The map is rebuilt by ReportAttributesCache only, so comparing it with the stored one would be wasted work. + Onyx.set(ONYXKEYS.REPORT_ATTRIBUTES_SNAPSHOT, snapshot, {skipCacheCheck: true}); +} + +export default saveReportAttributesSnapshot; diff --git a/src/libs/actions/OnyxDerived/configs/reportAttributes.ts b/src/libs/computeReportAttributes.ts similarity index 54% rename from src/libs/actions/OnyxDerived/configs/reportAttributes.ts rename to src/libs/computeReportAttributes.ts index f494c560cc63..7507a7cd4331 100644 --- a/src/libs/actions/OnyxDerived/configs/reportAttributes.ts +++ b/src/libs/computeReportAttributes.ts @@ -1,53 +1,65 @@ -import type {LocaleContextProps, LocalizedTranslate} from '@components/LocaleContextProvider'; - -import type {CurrencyListActionsContextType} from '@hooks/useCurrencyList'; - -import {getReportPreviewReportAction} from '@libs/actions/IOU/MoneyRequestBuilder'; -import {convertToFrontendAmountAsInteger, sanitizeCurrencyCode} from '@libs/CurrencyUtils'; -import {formatPhoneNumberWithCountryCode} from '@libs/LocalePhoneNumber'; -import {translate as translateForLocale} from '@libs/Localize'; -import {getIsOffline} from '@libs/NetworkState'; -import {format, formatToParts} from '@libs/NumberFormatUtils'; -import {getLoginByAccountID} from '@libs/PersonalDetailsUtils'; -import {isPolicyFieldListEmpty} from '@libs/PolicyUtils'; -import {getLinkedTransactionID, isActionableCardFraudAlert, isDeletedAction} from '@libs/ReportActionsUtils'; -import {computeReportName} from '@libs/ReportNameUtils'; -import { - generateIsEmptyReport, - generateReportAttributes, - hasViolations, - hasVisibleReportFieldViolations, - isArchivedReport, - isOpenReport, - isPolicyAdmin, - isPolicyExpenseChat, - isProcessingReport, - isSupportTicket, - getPendingDeleteMemberAccountIDs, - isValidReport, -} from '@libs/ReportUtils'; -import SidebarUtils from '@libs/SidebarUtils'; -import {buildTransactionsByReportID} from '@libs/TodosUtils'; - -import createOnyxDerivedValueConfig from '@userActions/OnyxDerived/createOnyxDerivedValueConfig'; -import {hasKeyTriggeredCompute} from '@userActions/OnyxDerived/utils'; +/** + * Computes the report attributes map (name, RBR/GBR status and action badge for every report) incrementally: + * from the changed collection members it works out which reports need recomputing and reuses the rest of the + * previous map. + */ import CONST from '@src/CONST'; -import IntlStore from '@src/languages/IntlStore'; +import type {OnyxKey} from '@src/ONYXKEYS'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {CardList, PersonalDetails, PersonalDetailsList, Policy, Report, ReportActions, ReportAttributesDerivedValue, Transaction, TransactionViolation} from '@src/types/onyx'; - -import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import type {CardList, PersonalDetails, PersonalDetailsList, Policy, Report, ReportAttributesDerivedValue, Transaction} from '@src/types/onyx'; +import type {ReportAttributes} from '@src/types/onyx/DerivedValues'; +import type {Errors} from '@src/types/onyx/OnyxCommon'; + +import type {OnyxCollection, OnyxEntry, OnyxValue} from 'react-native-onyx'; + +import type {DerivedSourceValues} from './actions/OnyxDerived/types'; +import type {ReportAttributesInputs} from './ReportAttributesComputeUtils'; + +import {hasKeyTriggeredCompute} from './actions/OnyxDerived/utils'; +import {getIsOffline} from './NetworkState'; +import {isPolicyFieldListEmpty} from './PolicyUtils'; +import {getLinkedTransactionID, isActionableCardFraudAlert} from './ReportActionsUtils'; +import {getParentChatPropagationUpdates, buildReportAttributesFormatters, computeReportNameEntry, computeReportStatusEntry, createReportNameLookup} from './ReportAttributesComputeUtils'; +import {generateIsEmptyReport, isArchivedReport, isValidReport} from './ReportUtils'; +import {buildTransactionsByReportID} from './TodosUtils'; + +const REPORT_ATTRIBUTES_DEPENDENCIES = [ + ONYXKEYS.COLLECTION.REPORT, + ONYXKEYS.NVP_PREFERRED_LOCALE, + ONYXKEYS.COUNTRY_CODE, + ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS, + ONYXKEYS.COLLECTION.REPORT_ACTIONS, + ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, + ONYXKEYS.COLLECTION.TRANSACTION, + ONYXKEYS.PERSONAL_DETAILS_LIST, + ONYXKEYS.SESSION, + ONYXKEYS.COLLECTION.POLICY, + ONYXKEYS.COLLECTION.POLICY_TAGS, + ONYXKEYS.CONCIERGE_REPORT_ID, + ONYXKEYS.NVP_INTRO_SELECTED, + ONYXKEYS.COLLECTION.REPORT_METADATA, + ONYXKEYS.CURRENCY_LIST, + ONYXKEYS.COLLECTION.RULE, + ONYXKEYS.CARD_LIST, + ONYXKEYS.NETWORK, +] as const; + +type ReportAttributesDependencies = typeof REPORT_ATTRIBUTES_DEPENDENCIES; + +// Mapping over a type parameter keeps the result a tuple; mapping over the concrete tuple type would not. +type DependencyValues = { + -readonly [Index in keyof Dependencies]: OnyxValue; +}; -import {isTrackIntentUserSelector} from '@selectors/Onboarding'; +type ReportAttributesDependencyValues = DependencyValues; -// The name-related fields we saw for each account last time, so we can spot which accounts changed. -let previousDisplayNames: Record = {}; -let previousPersonalDetails: OnyxEntry | undefined; -let previousPolicies: OnyxCollection; -let previousReportsTransactions: Record | undefined; -let previousFraudAlertReportIDs: Set | undefined; -let previousCardList: OnyxEntry; +/** What changed since the previous compute: the changed collection members, and every dependency that changed. */ +type ReportAttributesComputeContext = { + currentValue?: ReportAttributesDerivedValue; + sourceValues?: DerivedSourceValues; + triggeredKeys?: Set; +}; const RECOMPUTE_ALL = 'all' as const; @@ -92,64 +104,6 @@ const snapshotDisplayNames = (personalDetails: PersonalDetailsList): Record, snapshot: Record) => { - previousDisplayNames = snapshot; - previousPersonalDetails = personalDetails; -}; - -// Records the current names the first time we have them on a pass that wasn't itself a personal-details -// change. The report names already match these names, so there's nothing to recompute — we just save them -// so the next personal-details change can be compared and narrowed to the reports it actually affects. -const seedDisplayNamesBaseline = (personalDetails: OnyxEntry) => { - if (!personalDetails || previousPersonalDetails === personalDetails || Object.keys(previousDisplayNames).length > 0) { - return; - } - commitDisplayNamesBaseline(personalDetails, snapshotDisplayNames(personalDetails)); -}; - -// Works out which accounts had a name change since last time. Returns the set of those accountIDs, or null -// when nothing changed, or 'all' when there's no previous data to compare against yet and every report needs -// refreshing. -const getDisplayNameChanges = (personalDetails: OnyxEntry): Set | typeof RECOMPUTE_ALL | null => { - if (!personalDetails) { - return null; - } - - if (previousPersonalDetails === personalDetails) { - return null; - } - - const hadBaseline = Object.keys(previousDisplayNames).length > 0; - const nextSnapshot: Record = {}; - const changedAccountIDs = new Set(); - - // Build the new snapshot and compare it against the old one in the same loop. - for (const key of Object.keys(personalDetails)) { - const signature = displayNameSignature(personalDetails[key]); - nextSnapshot[key] = signature; - if (hadBaseline && signature !== previousDisplayNames[key]) { - changedAccountIDs.add(Number(key)); - } - } - // An account that existed before but is gone now also affects any report name that used it. - if (hadBaseline) { - for (const key of Object.keys(previousDisplayNames)) { - if (!(key in nextSnapshot)) { - changedAccountIDs.add(Number(key)); - } - } - } - - commitDisplayNamesBaseline(personalDetails, nextSnapshot); - - if (!hadBaseline) { - return Object.keys(nextSnapshot).length > 0 ? RECOMPUTE_ALL : null; - } - return changedAccountIDs.size > 0 ? changedAccountIDs : null; -}; - // True when the report's name could depend on one of these accounts. We look at the accounts stored on the report itself. const reportReferencesAccountIDs = (report: Report, accountIDs: Set): boolean => { if (report.ownerAccountID !== undefined && accountIDs.has(report.ownerAccountID)) { @@ -169,78 +123,96 @@ const reportReferencesAccountIDs = (report: Report, accountIDs: Set): bo return false; }; -// Returns the report-preview action ID of the oldest child in `reportIDs` matching `predicate` -// (oldest by preview-action creation time), or undefined when none match. -const getOldestPreviewActionID = ( - chatReportID: string, - reportIDs: string[] | undefined, - reports: OnyxCollection, - chatReportActions: OnyxEntry, - predicate?: (childReport: OnyxEntry) => boolean, -) => { - let oldestCreated: string | undefined; - let targetReportActionID: string | undefined; - for (const childReportID of reportIDs ?? []) { - if (predicate && !predicate(reports?.[`${ONYXKEYS.COLLECTION.REPORT}${childReportID}`])) { - continue; - } - const reportPreviewAction = getReportPreviewReportAction(chatReportID, childReportID, chatReportActions); - if (!reportPreviewAction) { - continue; - } - if (oldestCreated === undefined || reportPreviewAction.created < oldestCreated) { - oldestCreated = reportPreviewAction.created; - targetReportActionID = reportPreviewAction.reportActionID; - } - } - return targetReportActionID; +// The errors are rebuilt on every compute, but their values are the messages stored on the report and its actions. +const areReportErrorsEqual = (previous: Errors, next: Errors): boolean => { + const previousKeys = Object.keys(previous); + return previousKeys.length === Object.keys(next).length && previousKeys.every((key) => key in next && previous[key] === next[key]); }; -const isActionable = (childReport: OnyxEntry) => isOpenReport(childReport) || isProcessingReport(childReport); - -// Open/processing and the user still needs to fix a violation on it. Violations are read directly from -// transactionViolations so this works even when owner data is absent (e.g. masked Onyx exports). -const needsViolationFix = ( - childReport: OnyxEntry, - childReportOwnerLogin: string | undefined, - policies: OnyxCollection, - transactionViolations: OnyxCollection, - currentUserAccountID: number, - currentUserEmail: string, -) => { - if (!childReport || !isActionable(childReport)) { - return false; - } - const childPolicy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${childReport.policyID}`]; - return hasViolations(childReport.reportID, transactionViolations, currentUserAccountID, currentUserEmail, true, undefined, childReport, childReportOwnerLogin, childPolicy); -}; +// Compares every ReportAttributes field, so a field added to the type has to be added here too. +const areReportAttributesEqual = (previous: ReportAttributes, next: ReportAttributes): boolean => + previous.reportName === next.reportName && + previous.isEmpty === next.isEmpty && + previous.brickRoadStatus === next.brickRoadStatus && + previous.requiresAttention === next.requiresAttention && + previous.actionBadge === next.actionBadge && + previous.actionTargetReportActionID === next.actionTargetReportActionID && + previous.oneTransactionThreadReportID === next.oneTransactionThreadReportID && + previous.needsParentChatErrorPropagation === next.needsParentChatErrorPropagation && + areReportErrorsEqual(previous.reportErrors, next.reportErrors); /** - * This derived value is used to get the report attributes for the report. + * Creates a compute function with its own baselines: the personal details, policies, transactions and cards it + * saw last time, which tell it which reports a change touched. Each caller keeps its own so their diffs don't mix. */ -export default createOnyxDerivedValueConfig({ - key: ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, - dependencies: [ - ONYXKEYS.COLLECTION.REPORT, - ONYXKEYS.NVP_PREFERRED_LOCALE, - ONYXKEYS.COUNTRY_CODE, - ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS, - ONYXKEYS.COLLECTION.REPORT_ACTIONS, - ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, - ONYXKEYS.COLLECTION.TRANSACTION, - ONYXKEYS.PERSONAL_DETAILS_LIST, - ONYXKEYS.SESSION, - ONYXKEYS.COLLECTION.POLICY, - ONYXKEYS.COLLECTION.POLICY_TAGS, - ONYXKEYS.CONCIERGE_REPORT_ID, - ONYXKEYS.NVP_INTRO_SELECTED, - ONYXKEYS.COLLECTION.REPORT_METADATA, - ONYXKEYS.CURRENCY_LIST, - ONYXKEYS.COLLECTION.RULE, - ONYXKEYS.CARD_LIST, - ONYXKEYS.NETWORK, - ], - compute: ( +function createReportAttributesComputer() { + // The name-related fields we saw for each account last time, so we can spot which accounts changed. + let previousDisplayNames: Record = {}; + let previousPersonalDetails: OnyxEntry | undefined; + let previousPolicies: OnyxCollection; + let previousReportsTransactions: Record | undefined; + let previousFraudAlertReportIDs: Set | undefined; + let previousCardList: OnyxEntry; + + // Remembers the personal details we just used, so the next change can be compared against them. + // Updating both fields together here keeps them from getting out of sync. + const commitDisplayNamesBaseline = (personalDetails: OnyxEntry, snapshot: Record) => { + previousDisplayNames = snapshot; + previousPersonalDetails = personalDetails; + }; + + // Records the current names the first time we have them on a pass that wasn't itself a personal-details + // change. The report names already match these names, so there's nothing to recompute — we just save them + // so the next personal-details change can be compared and narrowed to the reports it actually affects. + const seedDisplayNamesBaseline = (personalDetails: OnyxEntry) => { + if (!personalDetails || previousPersonalDetails === personalDetails || Object.keys(previousDisplayNames).length > 0) { + return; + } + commitDisplayNamesBaseline(personalDetails, snapshotDisplayNames(personalDetails)); + }; + + // Works out which accounts had a name change since last time. Returns the set of those accountIDs, or null + // when nothing changed, or 'all' when there's no previous data to compare against yet and every report needs + // refreshing. + const getDisplayNameChanges = (personalDetails: OnyxEntry): Set | typeof RECOMPUTE_ALL | null => { + if (!personalDetails) { + return null; + } + + if (previousPersonalDetails === personalDetails) { + return null; + } + + const hadBaseline = Object.keys(previousDisplayNames).length > 0; + const nextSnapshot: Record = {}; + const changedAccountIDs = new Set(); + + // Build the new snapshot and compare it against the old one in the same loop. + for (const key of Object.keys(personalDetails)) { + const signature = displayNameSignature(personalDetails[key]); + nextSnapshot[key] = signature; + if (hadBaseline && signature !== previousDisplayNames[key]) { + changedAccountIDs.add(Number(key)); + } + } + // An account that existed before but is gone now also affects any report name that used it. + if (hadBaseline) { + for (const key of Object.keys(previousDisplayNames)) { + if (!(key in nextSnapshot)) { + changedAccountIDs.add(Number(key)); + } + } + } + + commitDisplayNamesBaseline(personalDetails, nextSnapshot); + + if (!hadBaseline) { + return Object.keys(nextSnapshot).length > 0 ? RECOMPUTE_ALL : null; + } + return changedAccountIDs.size > 0 ? changedAccountIDs : null; + }; + + function computeReportAttributes( [ reports, preferredLocale, @@ -259,48 +231,11 @@ export default createOnyxDerivedValueConfig({ currencyList, rules, cardList, - ], - {currentValue, sourceValues, triggeredKeys}, - ) => { + ]: ReportAttributesDependencyValues, + {currentValue, sourceValues, triggeredKeys}: ReportAttributesComputeContext, + ): ReportAttributesDerivedValue { // Read the in-memory offline state directly (NETWORK is a dependency so recompute still fires when it changes). const isOffline = getIsOffline(); - const dateFnsLocale = IntlStore.getDateFnsLocale(preferredLocale); - const translate: LocalizedTranslate = (path, ...parameters) => translateForLocale(preferredLocale, path, ...parameters); - const formatPhoneNumber: LocaleContextProps['formatPhoneNumber'] = (phoneNumber) => formatPhoneNumberWithCountryCode(phoneNumber, countryCodeByIP ?? CONST.DEFAULT_COUNTRY_CODE); - // Non-React computation: there is no component to inject the currency formatters from CurrencyListContextProvider, - // so mirror the provider's implementations here using the CURRENCY_LIST dependency and the preferred locale. - const getCurrencyDecimals = (currencyCode: string): number => currencyList?.[currencyCode]?.decimals ?? CONST.DEFAULT_CURRENCY_DECIMALS; - const getCurrencySymbol: CurrencyListActionsContextType['getCurrencySymbol'] = (currencyCode) => currencyList?.[currencyCode]?.symbol; - const convertToDisplayString: CurrencyListActionsContextType['convertToDisplayString'] = (amountInCents, currencyCode) => { - const sanitizedCurrency = sanitizeCurrencyCode(currencyCode); - const decimals = getCurrencyDecimals(sanitizedCurrency); - const convertedAmount = convertToFrontendAmountAsInteger(amountInCents ?? 0, decimals); - return format(preferredLocale, convertedAmount, { - style: 'currency', - currency: sanitizedCurrency, - - // We are forcing the number of decimals because we override the default number of decimals in the backend for some currencies - // See: https://github.com/Expensify/PHP-Libs/pull/834 - minimumFractionDigits: decimals, - // For currencies that have decimal places > 2, floor to 2 instead as we don't support more than 2 decimal places. - maximumFractionDigits: 2, - }); - }; - const convertToDisplayStringWithoutCurrency: CurrencyListActionsContextType['convertToDisplayStringWithoutCurrency'] = (amountInCents, currencyCode = CONST.CURRENCY.USD) => { - const sanitizedCurrency = sanitizeCurrencyCode(currencyCode); - const decimals = getCurrencyDecimals(sanitizedCurrency); - const convertedAmount = convertToFrontendAmountAsInteger(amountInCents, decimals); - return formatToParts(preferredLocale, convertedAmount, { - style: 'currency', - currency: sanitizedCurrency, - minimumFractionDigits: decimals, - maximumFractionDigits: 2, - }) - .filter((x) => x.type !== 'currency') - .filter((x) => x.type !== 'literal' || x.value.trim().length !== 0) - .map((x) => x.value) - .join(''); - }; // Check if display names changed when personal details are updated let displayNameChanges: Set | typeof RECOMPUTE_ALL | null = null; if (hasKeyTriggeredCompute(ONYXKEYS.PERSONAL_DETAILS_LIST, triggeredKeys)) { @@ -626,8 +561,28 @@ export default createOnyxDerivedValueConfig({ if (!previousReportsTransactions || !sourceValues || hasKeyTriggeredCompute(ONYXKEYS.COLLECTION.TRANSACTION, triggeredKeys)) { previousReportsTransactions = buildTransactionsByReportID(transactions); } - const reportsTransactions = previousReportsTransactions; + const inputs: ReportAttributesInputs = { + reports, + reportActions, + reportNameValuePairs, + reportMetadata, + transactions, + transactionViolations, + personalDetails, + session, + policies, + policyTags, + conciergeReportID, + introSelected, + rules, + cardList, + isOffline, + reportsTransactions: previousReportsTransactions ?? {}, + }; + const formatters = buildReportAttributesFormatters(preferredLocale, countryCodeByIP, currencyList); + const getReportNameByID = createReportNameLookup(inputs, formatters); + let hasRemovedEntries = false; const reportAttributes = dataToIterate.reduce( (acc, key) => { // source value sends partial data, so we need an entire report object to do computations @@ -637,223 +592,67 @@ export default createOnyxDerivedValueConfig({ const reportID = key.replace(ONYXKEYS.COLLECTION.REPORT, ''); if (acc[reportID]) { delete acc[reportID]; + hasRemovedEntries = true; } return acc; } - const chatReport = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${report.chatReportID}`]; - const reportNameValuePair = reportNameValuePairs?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report.reportID}`]; - const reportActionsList = reportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`]; - const isReportArchived = isArchivedReport(reportNameValuePair); - const reportReportMetadata = reportMetadata?.[`${ONYXKEYS.COLLECTION.REPORT_METADATA}${report.reportID}`]; - const { - hasAnyViolations, - requiresAttention, - reportErrors, - oneTransactionThreadReportID, - actionBadge: actionGreenBadge, - actionTargetReportActionID: actionGreenTargetReportActionID, - } = generateReportAttributes({ - report, - chatReport, - reportActions, - transactionViolations, - isReportArchived, - allTransactions: transactions, - reports, - policies, - reportMetadata: reportReportMetadata, - cardList, - currentUserAccountID: session?.accountID ?? CONST.DEFAULT_NUMBER_ID, - currentUserLogin: session?.email ?? '', - }); - - const policy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`]; - const hasFieldViolations = hasVisibleReportFieldViolations(report, policy, session?.accountID, rules, reportNameValuePair); - - let brickRoadStatus; - let actionBadge; - let actionTargetReportActionID; - let needsParentChatErrorPropagation = false; - const reasonAndReportAction = SidebarUtils.getReasonAndReportActionThatHasRedBrickRoad({ - report, - chatReport, - reportActions: reportActionsList, - hasViolations: hasAnyViolations || hasFieldViolations, - reportErrors, - transactions, - isOffline, - currentUserAccountID: session?.accountID ?? CONST.DEFAULT_NUMBER_ID, - transactionViolations, - isReportArchived: !!isReportArchived, - reports, - }); - - // When the report is ready to submit, always show the green Submit badge - // regardless of violations — the user can submit without fix. - const willShowGreenSubmit = requiresAttention && actionGreenBadge === CONST.REPORT.ACTION_BADGE.SUBMIT; - - // if report has errors or violations, show red dot - // Also skip setting ERROR when we'll show the green Submit badge — let the user submit without fix. - if (!isSupportTicket(report) && reasonAndReportAction && !willShowGreenSubmit) { - needsParentChatErrorPropagation = true; - - // RBR/Fix mirrors GBR's access rule: only show on the child when the user can't already - // see it on the parent workspace chat. The parent still gets ERROR/FIX through the - // propagation loop below, so the actionable indicator surfaces on the workspace chat row - // (which is where C+ wants it). Skips when the chat parent isn't accessible to the user. - const chatPolicy = chatReport?.policyID ? policies?.[`${ONYXKEYS.COLLECTION.POLICY}${chatReport.policyID}`] : undefined; - const isChildOfAccessiblePolicyExpenseChat = !!chatReport && isPolicyExpenseChat(chatReport) && (!!chatReport.isOwnPolicyExpenseChat || isPolicyAdmin(chatPolicy)); - if (!isChildOfAccessiblePolicyExpenseChat) { - brickRoadStatus = CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR; - actionBadge = CONST.REPORT.ACTION_BADGE.FIX; - actionTargetReportActionID = reasonAndReportAction.reportAction?.reportActionID; - } - } - // if report does not have error, check if it should show green dot - if (brickRoadStatus !== CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR && requiresAttention) { - brickRoadStatus = CONST.BRICK_ROAD_INDICATOR_STATUS.INFO; - actionBadge = actionGreenBadge; - actionTargetReportActionID = actionGreenTargetReportActionID; - } - - const pendingDeleteMemberAccountIDs = getPendingDeleteMemberAccountIDs(reportReportMetadata?.pendingChatMembers); + const isReportArchived = isArchivedReport(reportNameValuePairs?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report.reportID}`]); + const status = computeReportStatusEntry(report, inputs); // Skip computeReportName when the name can't have changed (see nameSkipKeys). const cachedName = currentValue?.reports?.[report.reportID]?.reportName; const canReuseCachedName = cachedName !== undefined && nameSkipKeys.has(key); acc[report.reportID] = { - reportName: canReuseCachedName - ? cachedName - : computeReportName({ - report, - reports, - policies, - transactions, - allReportNameValuePairs: reportNameValuePairs, - personalDetailsList: personalDetails, - reportActions, - currentUserAccountID: session?.accountID ?? CONST.DEFAULT_NUMBER_ID, - currentUserLogin: session?.email ?? '', - translate, - dateFnsLocale, - allPolicyTags: policyTags, - conciergeReportID: conciergeReportID ?? undefined, - reportAttributes: currentValue?.reports, - reportTransactions: reportsTransactions ?? {}, - isTrackIntentUser: isTrackIntentUserSelector(introSelected), - convertToDisplayString, - convertToDisplayStringWithoutCurrency, - getCurrencySymbol, - pendingDeleteMemberAccountIDs, - formatPhoneNumber, - rules, - }), + reportName: canReuseCachedName ? cachedName : computeReportNameEntry(report, inputs, formatters, getReportNameByID), isEmpty: generateIsEmptyReport(report, isReportArchived), - brickRoadStatus, - requiresAttention, - actionBadge, - actionTargetReportActionID, - reportErrors, - oneTransactionThreadReportID, - needsParentChatErrorPropagation, + ...status, }; return acc; }, - currentValue?.reports ? {...currentValue.reports} : {}, + // A full recompute visits every report, so it starts empty: carrying the previous map over would keep entries + // for reports deleted since it was computed. + useIncrementalUpdates && currentValue?.reports ? {...currentValue.reports} : {}, ); // Propagate errors from IOU reports to their parent chat reports. - const currentUserAccountID = session?.accountID ?? CONST.DEFAULT_NUMBER_ID; - const currentUserEmail = session?.email ?? ''; - const erroredChildReportIDsByChat = new Map(); - const childReportIDsByChat = new Map(); - for (const report of Object.values(reports)) { - if (!report?.reportID || !report.chatReportID || report.reportID === report.chatReportID) { - continue; - } + const propagationUpdates = getParentChatPropagationUpdates(reportAttributes, inputs); + Object.assign(reportAttributes, propagationUpdates); - const childReportIDs = childReportIDsByChat.get(report.chatReportID) ?? []; - childReportIDs.push(report.reportID); - childReportIDsByChat.set(report.chatReportID, childReportIDs); - - // If this is an IOU report and its calculated attributes have an error, - // then we need to mark its parent chat report. - // We read `needsParentChatErrorPropagation` rather than `brickRoadStatus` because the per-report - // pass suppresses the child's own brickRoadStatus when the parent workspace chat is accessible — - // we still need to propagate the error up so the parent shows the indicator. - const attributes = reportAttributes[report.reportID]; - if (!attributes?.needsParentChatErrorPropagation && attributes?.brickRoadStatus !== CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR) { - continue; - } + const locale = preferredLocale ?? null; + if (!currentValue?.reports || currentValue.locale !== locale) { + return {reports: reportAttributes, locale}; + } - // When the child IOU's parent action in the chat is deleted (e.g. another user deleted the request - // while an optimistic pay was queued offline), the chat has no actionable surface for the error. - // Skip propagation so the parent DM row doesn't show a stale "Fix" for a request that no longer exists. - // Checked only for children with an error, as it is the costly part of this loop over every report. - const parentReportAction = report.parentReportActionID - ? reportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID}`]?.[report.parentReportActionID] - : undefined; - if (isDeletedAction(parentReportAction)) { + // Most writes (a read receipt, a draft) leave every attribute as it was. Keeping the previous entry objects, and + // the previous map when none changed, lets every consumer skip its deep comparison of the whole map. + const recomputedReportIDs = useIncrementalUpdates + ? new Set([...dataToIterate.map((key) => key.replace(ONYXKEYS.COLLECTION.REPORT, '')), ...Object.keys(propagationUpdates)]) + : Object.keys(reportAttributes); + let hasChanges = hasRemovedEntries || (!useIncrementalUpdates && Object.keys(currentValue.reports).length !== Object.keys(reportAttributes).length); + for (const reportID of recomputedReportIDs) { + const nextAttributes = reportAttributes[reportID]; + if (!nextAttributes) { continue; } - - const erroredChildReportIDs = erroredChildReportIDsByChat.get(report.chatReportID) ?? []; - erroredChildReportIDs.push(report.reportID); - erroredChildReportIDsByChat.set(report.chatReportID, erroredChildReportIDs); + const previousAttributes = currentValue.reports[reportID]; + if (previousAttributes && areReportAttributesEqual(previousAttributes, nextAttributes)) { + reportAttributes[reportID] = previousAttributes; + } else { + hasChanges = true; + } + } + if (!hasChanges) { + return currentValue; } - // Apply the error status to the parent chat reports. - for (const [chatReportID, erroredChildReportIDs] of erroredChildReportIDsByChat) { - if (!reportAttributes[chatReportID]) { - continue; - } + return {reports: reportAttributes, locale}; + } - const chatAttributes = reportAttributes[chatReportID]; - let actionTargetReportActionID = chatAttributes.actionTargetReportActionID; - const chatReportActions = reportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReportID}`]; - - actionTargetReportActionID = - getOldestPreviewActionID(chatReportID, erroredChildReportIDs, reports, chatReportActions, isActionable) ?? - getOldestPreviewActionID(chatReportID, childReportIDsByChat.get(chatReportID), reports, chatReportActions, (childReport) => - needsViolationFix( - childReport, - getLoginByAccountID(childReport?.ownerAccountID, personalDetails), - policies, - transactionViolations, - currentUserAccountID, - currentUserEmail, - ), - ) ?? - getOldestPreviewActionID(chatReportID, erroredChildReportIDs, reports, chatReportActions) ?? - actionTargetReportActionID; - - // Clone the entry before mutating — it may be a reference carried over from - // currentValue.reports that wasn't recomputed in this incremental run. - reportAttributes[chatReportID] = { - ...chatAttributes, - brickRoadStatus: CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR, - actionBadge: CONST.REPORT.ACTION_BADGE.FIX, - actionTargetReportActionID, - }; - } + return computeReportAttributes; +} - return { - reports: reportAttributes, - locale: preferredLocale ?? null, - }; - }, - // On Onyx clear, drop the cross-compute baselines so the first post-clear pass is treated as a full - // change (see the engine's resetForClear). Otherwise the rehydrated data is diffed against the stale - // pre-clear baseline, "nothing changed" is concluded, and names computed while data was empty stay blank. - onReset: () => { - previousDisplayNames = {}; - previousPersonalDetails = undefined; - previousPolicies = undefined; - previousFraudAlertReportIDs = undefined; - previousCardList = undefined; - }, -}); - -export {hasPolicyRelevantFieldChanged, getOldestPreviewActionID}; +export default createReportAttributesComputer; +export {hasPolicyRelevantFieldChanged, REPORT_ATTRIBUTES_DEPENDENCIES}; +export type {ReportAttributesComputeContext, ReportAttributesDependencyValues}; diff --git a/src/libs/migrateOnyx.ts b/src/libs/migrateOnyx.ts index 38a651846af8..5677e4d99a79 100644 --- a/src/libs/migrateOnyx.ts +++ b/src/libs/migrateOnyx.ts @@ -3,6 +3,7 @@ import CONST from '@src/CONST'; import Log from './Log'; import MoveFilesOutOfDocuments from './migrations/MoveFilesOutOfDocuments'; import MoveSearchQueryByHashToSnapshots from './migrations/MoveSearchQueryByHashToSnapshots'; +import RemoveReportAttributesDerivedValue from './migrations/RemoveReportAttributesDerivedValue'; import RemoveSearchRowFieldsFromTransactions from './migrations/RemoveSearchRowFieldsFromTransactions'; import RenameLegacySageIntacctExportDate from './migrations/RenameLegacySageIntacctExportDate'; import ReplaceShouldUseStagingServerWithActiveServer from './migrations/ReplaceShouldUseStagingServerWithActiveServer'; @@ -26,6 +27,7 @@ export default function () { MoveSearchQueryByHashToSnapshots, RenameLegacySageIntacctExportDate, RemoveSearchRowFieldsFromTransactions, + // IOS-AB-TEMP: disabled so both legs keep their stored value on the shared sim DB ]; // Reduce all promises down to a single promise. All promises run in a linear fashion, waiting for the diff --git a/src/libs/migrations/RemoveReportAttributesDerivedValue.ts b/src/libs/migrations/RemoveReportAttributesDerivedValue.ts new file mode 100644 index 000000000000..c56ace13d4ed --- /dev/null +++ b/src/libs/migrations/RemoveReportAttributesDerivedValue.ts @@ -0,0 +1,19 @@ +/* eslint-disable @typescript-eslint/no-deprecated -- removing the old REPORT_ATTRIBUTES value is this file's entire purpose */ +import Log from '@libs/Log'; +import readOnceOnyxValue from '@libs/readOnceOnyxValue'; + +import ONYXKEYS from '@src/ONYXKEYS'; + +import Onyx from 'react-native-onyx'; + +/** + * REPORT_ATTRIBUTES used to be a derived value written to disk on every recompute, a few MB on large accounts. + * Nothing reads it anymore, so the stored copy is deleted instead of staying on the device for good. + */ +export default async function RemoveReportAttributesDerivedValue(): Promise { + if ((await readOnceOnyxValue(ONYXKEYS.LEGACY_REPORT_ATTRIBUTES)) === undefined) { + return; + } + await Onyx.multiSet({[ONYXKEYS.LEGACY_REPORT_ATTRIBUTES]: null}); + Log.info('[Migrate Onyx] Ran RemoveReportAttributesDerivedValue migration'); +} diff --git a/src/pages/Debug/Report/DebugReportActions.tsx b/src/pages/Debug/Report/DebugReportActions.tsx index fe0a343f7d41..14c661f07fd8 100644 --- a/src/pages/Debug/Report/DebugReportActions.tsx +++ b/src/pages/Debug/Report/DebugReportActions.tsx @@ -7,7 +7,7 @@ import useDebouncedState from '@hooks/useDebouncedState'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import {useAllPersonalDetails} from '@hooks/usePersonalDetails'; -import {useDerivedReportNameByReportID} from '@hooks/useReportAttributes'; +import {useReportName} from '@hooks/useReportAttributes'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -44,7 +44,7 @@ function DebugReportActions({reportID}: DebugReportActionsProps) { const ifUserCanPerformWriteAction = canUserPerformWriteAction(report, isReportArchived); const [personalDetails] = useAllPersonalDetails(); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); - const derivedReportName = useDerivedReportNameByReportID(report?.reportID); + const derivedReportName = useReportName(report?.reportID); const [sortedAllReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, { selector: (allReportActions: OnyxEntry): ReportAction[] => getSortedReportActionsForDisplay(allReportActions, ifUserCanPerformWriteAction, true, undefined, reportID), diff --git a/src/pages/Debug/Report/DebugReportPage.tsx b/src/pages/Debug/Report/DebugReportPage.tsx index 841cd9481ee3..c8a6238b7ac3 100644 --- a/src/pages/Debug/Report/DebugReportPage.tsx +++ b/src/pages/Debug/Report/DebugReportPage.tsx @@ -10,7 +10,7 @@ import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import {usePersonalDetail} from '@hooks/usePersonalDetails'; -import {useDerivedIsEmptyReport} from '@hooks/useReportAttributes'; +import {useIsEmptyReport, useReportAttributesEntry} from '@hooks/useReportAttributes'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; @@ -36,9 +36,6 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; -import type {ReportAttributesDerivedValue} from '@src/types/onyx'; - -import type {OnyxEntry} from 'react-native-onyx'; import {hasSeenTourSelector} from '@selectors/Onboarding'; import {pendingExpenseActionSelector} from '@selectors/ReportMetaData'; @@ -75,9 +72,7 @@ function DebugReportPage({ const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); const [pendingExpenseActionMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`, {selector: pendingExpenseActionSelector}); const {isOffline} = useNetwork(); - const [reportAttributes] = useOnyx(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, { - selector: (attributes: OnyxEntry) => attributes?.reports?.[reportID], - }); + const reportAttributes = useReportAttributesEntry(reportID); const [draftComment] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`); const [priorityMode] = useOnyx(ONYXKEYS.NVP_PRIORITY_MODE); const {isBetaEnabled} = usePermissions(); @@ -95,7 +90,7 @@ function DebugReportPage({ const isReportArchived = useReportIsArchived(reportID); const [guideAccountIDs] = useOnyx(ONYXKEYS.DERIVED.GUIDE_ACCOUNT_IDS); const hasGuidesEmails = hasExpensifyGuidesEmails(Object.keys(report?.participants ?? {}).map(Number), guideAccountIDs); - const derivedIsEmptyReport = useDerivedIsEmptyReport(reportID); + const derivedIsEmptyReport = useIsEmptyReport(reportID); let metadata: Metadata[]; if (!report) { diff --git a/src/pages/DynamicEditReportFieldPage.tsx b/src/pages/DynamicEditReportFieldPage.tsx index f24e212352c5..e047d3d6f4f2 100644 --- a/src/pages/DynamicEditReportFieldPage.tsx +++ b/src/pages/DynamicEditReportFieldPage.tsx @@ -11,7 +11,7 @@ import useDynamicBackPath from '@hooks/useDynamicBackPath'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; -import {useDerivedReportNameByReportID} from '@hooks/useReportAttributes'; +import {useReportName} from '@hooks/useReportAttributes'; import useSaveReportField from '@hooks/useSaveReportField'; import {deleteReportField} from '@libs/actions/Report'; @@ -52,7 +52,7 @@ function DynamicEditReportFieldPage({route}: DynamicEditReportFieldPageProps) { const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`); const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); - const derivedReportName = useDerivedReportNameByReportID(reportID); + const derivedReportName = useReportName(reportID); const saveReportField = useSaveReportField(report, policy); const isTitleField = route.params.fieldID === CONST.REPORT_FIELD_TITLE_FIELD_ID; diff --git a/src/pages/DynamicReportDetailsPage/useReportDetailsReportName.ts b/src/pages/DynamicReportDetailsPage/useReportDetailsReportName.ts index 504ff25b2fce..25eed91554f8 100644 --- a/src/pages/DynamicReportDetailsPage/useReportDetailsReportName.ts +++ b/src/pages/DynamicReportDetailsPage/useReportDetailsReportName.ts @@ -1,4 +1,4 @@ -import {useDerivedReportNamesByReportIDs} from '@hooks/useReportAttributes'; +import {useReportNames} from '@hooks/useReportAttributes'; import Parser from '@libs/Parser'; import {getReportNameFromNames} from '@libs/ReportAttributesUtils'; @@ -17,7 +17,7 @@ import type {OnyxEntry} from 'react-native-onyx'; */ function useReportDetailsReportName(report: OnyxEntry, parentReport: OnyxEntry, parentReportAction: OnyxEntry) { const reportForHeader = getReportForHeader(report, parentReport); - const derivedReportNames = useDerivedReportNamesByReportIDs([report?.parentReportID, reportForHeader?.reportID]); + const derivedReportNames = useReportNames([report?.parentReportID, reportForHeader?.reportID]); const derivedParentReportName = getReportNameFromNames(derivedReportNames, report?.parentReportID); const derivedHeaderReportName = getReportNameFromNames(derivedReportNames, reportForHeader?.reportID); diff --git a/src/pages/DynamicReportParticipantsPage.tsx b/src/pages/DynamicReportParticipantsPage.tsx index c5d0a7fae315..7d0bff2817d2 100755 --- a/src/pages/DynamicReportParticipantsPage.tsx +++ b/src/pages/DynamicReportParticipantsPage.tsx @@ -20,7 +20,7 @@ import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import {useAllPersonalDetails, usePersonalDetail} from '@hooks/usePersonalDetails'; import usePolicy from '@hooks/usePolicy'; -import {useDerivedReportNameByReportID} from '@hooks/useReportAttributes'; +import {useReportName} from '@hooks/useReportAttributes'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSearchBackPress from '@hooks/useSearchBackPress'; @@ -85,7 +85,7 @@ function DynamicReportParticipantsPage({report}: DynamicReportParticipantsPagePr const tableRef = useRef>(null); const isReportArchived = useReportIsArchived(report?.reportID); const [reportMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${report?.reportID}`); - const derivedReportName = useDerivedReportNameByReportID(report?.reportID); + const derivedReportName = useReportName(report?.reportID); const isMobileSelectionModeEnabled = useMobileSelectionMode(); const [currentUserAccountID] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector}); const [personalDetails] = useAllPersonalDetails(); diff --git a/src/pages/DynamicRoomInvitePage.tsx b/src/pages/DynamicRoomInvitePage.tsx index ad7969830bb6..cf2d07f0bf38 100644 --- a/src/pages/DynamicRoomInvitePage.tsx +++ b/src/pages/DynamicRoomInvitePage.tsx @@ -17,7 +17,7 @@ import useOnyx from '@hooks/useOnyx'; import {useAllPersonalDetails, usePersonalDetailsByIDs} from '@hooks/usePersonalDetails'; import usePersonalDetailSearchSelector from '@hooks/usePersonalDetailSearchSelector'; import usePressLoading from '@hooks/usePressLoading'; -import {useDerivedReportNameByReportID} from '@hooks/useReportAttributes'; +import {useReportName} from '@hooks/useReportAttributes'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -65,7 +65,7 @@ type DynamicRoomInvitePageProps = WithReportOrNotFoundProps & type MembersSection = SectionListData>; function DynamicRoomInvitePage({report, policy, didScreenTransitionEnd}: DynamicRoomInvitePageProps) { const styles = useThemeStyles(); - const derivedReportName = useDerivedReportNameByReportID(report?.reportID); + const derivedReportName = useReportName(report?.reportID); const {translate, formatPhoneNumber} = useLocalize(); const backPath = useDynamicBackPath(DYNAMIC_ROUTES.ROOM_INVITE.path); const [userSearchPhrase] = useOnyx(ONYXKEYS.ROOM_MEMBERS_USER_SEARCH_PHRASE); diff --git a/src/pages/DynamicRoomMembersPage.tsx b/src/pages/DynamicRoomMembersPage.tsx index 0c9b15d4461a..ebd422806dfd 100644 --- a/src/pages/DynamicRoomMembersPage.tsx +++ b/src/pages/DynamicRoomMembersPage.tsx @@ -23,7 +23,7 @@ import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; import useOnyx from '@hooks/useOnyx'; import useParentReportAction from '@hooks/useParentReportAction'; import {usePersonalDetail} from '@hooks/usePersonalDetails'; -import {useDerivedReportNameByReportID} from '@hooks/useReportAttributes'; +import {useReportName} from '@hooks/useReportAttributes'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSearchBackPress from '@hooks/useSearchBackPress'; @@ -92,7 +92,7 @@ function DynamicRoomMembersPage({report, policy}: DynamicRoomMembersPageProps) { const isReportArchived = useReportIsArchived(report.reportID); const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`); const reportForSubtitle = getReportForHeader(report, parentReport); - const derivedSubtitleReportName = useDerivedReportNameByReportID(reportForSubtitle?.reportID); + const derivedSubtitleReportName = useReportName(reportForSubtitle?.reportID); const subtitleReportName = getReportName(reportForSubtitle, derivedSubtitleReportName); const accountIDToName = useMentionedAccountIDToNameMap(subtitleReportName); diff --git a/src/pages/Search/SavedSearchList.tsx b/src/pages/Search/SavedSearchList.tsx index 2ed606ddd2aa..7241963ae18b 100644 --- a/src/pages/Search/SavedSearchList.tsx +++ b/src/pages/Search/SavedSearchList.tsx @@ -8,7 +8,7 @@ import useFeedKeysWithAssignedCards from '@hooks/useFeedKeysWithAssignedCards'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; -import useReportAttributes from '@hooks/useReportAttributes'; +import {useAllReportAttributes} from '@hooks/useReportAttributes'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useShareSavedSearch from '@hooks/useShareSavedSearch'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -105,7 +105,7 @@ function SavedSearchList() { const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); const feedKeysWithCards = useFeedKeysWithAssignedCards(); const [currentUserAccountID = -1] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector}); - const reportAttributes = useReportAttributes(); + const reportAttributes = useAllReportAttributes(); const {currentSearchKey} = useSearchQueryContext(); const {showDeleteModal} = useDeleteSavedSearch(); diff --git a/src/pages/Search/SearchMoneyRequestReportPage.tsx b/src/pages/Search/SearchMoneyRequestReportPage.tsx index 1a41e964f1bd..5a6731e2a8cc 100644 --- a/src/pages/Search/SearchMoneyRequestReportPage.tsx +++ b/src/pages/Search/SearchMoneyRequestReportPage.tsx @@ -15,7 +15,7 @@ import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useParentReportAction from '@hooks/useParentReportAction'; import usePrevious from '@hooks/usePrevious'; -import {useDerivedReportNameByReportID} from '@hooks/useReportAttributes'; +import {useReportName} from '@hooks/useReportAttributes'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSubmitToDestinationVisible from '@hooks/useSubmitToDestinationVisible'; @@ -143,7 +143,7 @@ function SearchMoneyRequestReportPage({route}: SearchMoneyRequestPageProps) { const reportID = report?.reportID; - const derivedReportName = useDerivedReportNameByReportID(reportID); + const derivedReportName = useReportName(reportID); useDocumentTitle(getReportName(report, derivedReportName)); const doesReportIDLookValid = isValidReportIDFromPath(reportID); diff --git a/src/pages/Search/SearchTypeMenuNarrow.tsx b/src/pages/Search/SearchTypeMenuNarrow.tsx index 9056e6ce8e04..ab675a9edacc 100644 --- a/src/pages/Search/SearchTypeMenuNarrow.tsx +++ b/src/pages/Search/SearchTypeMenuNarrow.tsx @@ -15,7 +15,7 @@ import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; -import useReportAttributes from '@hooks/useReportAttributes'; +import {useAllReportAttributes} from '@hooks/useReportAttributes'; import {useSearchTypeMenuSectionsForNavigation} from '@hooks/useSearchTypeMenuSections'; import useShareSavedSearch, {MENU_CLOSE_DELAY_MS} from '@hooks/useShareSavedSearch'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -105,7 +105,7 @@ function SearchTypeMenuNarrow({queryJSON, onTabPress}: SearchTypeMenuNarrowProps const [searchFilters] = useOnyx(ONYXKEYS.SEARCH_FILTERS); const {counts: reportCounts} = useTodoCounts(isFocused); const [currentUserAccountID = -1] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector}); - const reportAttributes = useReportAttributes(); + const reportAttributes = useAllReportAttributes(); const {currentSearchKey} = useSearchQueryContext(); const taxRates = getAllTaxRates(allPolicies); diff --git a/src/pages/Share/ShareDetailsPage.tsx b/src/pages/Share/ShareDetailsPage.tsx index efa0c7e7f482..72511b87a4ec 100644 --- a/src/pages/Share/ShareDetailsPage.tsx +++ b/src/pages/Share/ShareDetailsPage.tsx @@ -15,7 +15,7 @@ import useDelegateAccountID from '@hooks/useDelegateAccountID'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; -import useReportAttributes from '@hooks/useReportAttributes'; +import {useAllReportAttributes} from '@hooks/useReportAttributes'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useReportOrReportDraft from '@hooks/useReportOrReportDraft'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -75,7 +75,7 @@ function ShareDetailsPage({route}: ShareDetailsPageProps) { const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const delegateAccountID = useDelegateAccountID(); - const reportAttributesDerived = useReportAttributes(); + const reportAttributesDerived = useAllReportAttributes(); const personalDetails = usePersonalDetails(); const personalDetail = useCurrentUserPersonalDetails(); const isTextShared = currentAttachment?.mimeType === CONST.SHARE_FILE_MIMETYPE.TXT; diff --git a/src/pages/Share/SubmitDetailsPage.tsx b/src/pages/Share/SubmitDetailsPage.tsx index 5479b7a82785..3ec79e6578a5 100644 --- a/src/pages/Share/SubmitDetailsPage.tsx +++ b/src/pages/Share/SubmitDetailsPage.tsx @@ -17,7 +17,7 @@ import usePersonalPolicy from '@hooks/usePersonalPolicy'; import usePolicyForTransaction from '@hooks/usePolicyForTransaction'; import usePreMountDestination from '@hooks/usePreMountDestination'; import usePrivateIsArchivedMap from '@hooks/usePrivateIsArchivedMap'; -import useReportAttributes from '@hooks/useReportAttributes'; +import {useAllReportAttributes} from '@hooks/useReportAttributes'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useReportOrReportDraft from '@hooks/useReportOrReportDraft'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -115,7 +115,7 @@ function SubmitDetailsPage({ const [conciergeChat] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${conciergeReportID}`); const [quickAction] = useOnyx(ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE); const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); - const reportAttributesDerived = useReportAttributes(); + const reportAttributesDerived = useAllReportAttributes(); const privateIsArchivedMap = usePrivateIsArchivedMap(); const [currentDate] = useOnyx(ONYXKEYS.CURRENT_DATE); const [validFilesToUpload] = useOnyx(ONYXKEYS.VALIDATED_FILE_OBJECT); diff --git a/src/pages/ShareCodePage.tsx b/src/pages/ShareCodePage.tsx index 9b008d153be7..051c3724166f 100644 --- a/src/pages/ShareCodePage.tsx +++ b/src/pages/ShareCodePage.tsx @@ -15,7 +15,7 @@ import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePolicy from '@hooks/usePolicy'; -import {useDerivedReportNamesByReportIDs} from '@hooks/useReportAttributes'; +import {useReportNames} from '@hooks/useReportAttributes'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -117,7 +117,7 @@ function ShareCodePage({report, policy, backTo, shouldShowQRCodeOnly = false}: S const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`); const invoiceReceiverPolicy = usePolicy(getInvoiceReceiverPolicyID(parentReport)); const reportForTitle = getReportForHeader(report, parentReport); - const derivedReportNames = useDerivedReportNamesByReportIDs([report?.parentReportID, reportForTitle?.reportID]); + const derivedReportNames = useReportNames([report?.parentReportID, reportForTitle?.reportID]); const derivedParentReportName = getReportNameFromNames(derivedReportNames, report?.parentReportID); const derivedTitleReportName = getReportNameFromNames(derivedReportNames, reportForTitle?.reportID); const isParentReportArchived = useReportIsArchived(report?.parentReportID); diff --git a/src/pages/TripChatNameEditPage.tsx b/src/pages/TripChatNameEditPage.tsx index 20546fba6c87..836dc32ce9da 100644 --- a/src/pages/TripChatNameEditPage.tsx +++ b/src/pages/TripChatNameEditPage.tsx @@ -7,7 +7,7 @@ import TextInput from '@components/TextInput'; import useAutoFocusInput from '@hooks/useAutoFocusInput'; import useLocalize from '@hooks/useLocalize'; -import {useDerivedReportNameByReportID} from '@hooks/useReportAttributes'; +import {useReportName} from '@hooks/useReportAttributes'; import useThemeStyles from '@hooks/useThemeStyles'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; @@ -39,7 +39,7 @@ function TripChatNameEditPage({report}: TripChatNameEditPageProps) { const {inputCallbackRef} = useAutoFocusInput(); const reportID = report?.reportID; - const derivedReportName = useDerivedReportNameByReportID(reportID); + const derivedReportName = useReportName(reportID); const currentChatName = getReportName(report, derivedReportName); const validate = (values: FormOnyxValues): Errors => { diff --git a/src/pages/inbox/HeaderView.tsx b/src/pages/inbox/HeaderView.tsx index c4377f6a7202..756d6d60cd7f 100644 --- a/src/pages/inbox/HeaderView.tsx +++ b/src/pages/inbox/HeaderView.tsx @@ -31,7 +31,7 @@ import useParentReportAction from '@hooks/useParentReportAction'; import usePersonalDetailByLogin from '@hooks/usePersonalDetailByLogin'; import {useAllPersonalDetails} from '@hooks/usePersonalDetails'; import usePolicy from '@hooks/usePolicy'; -import {useDerivedReportNamesByReportIDs} from '@hooks/useReportAttributes'; +import {useReportNames} from '@hooks/useReportAttributes'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSubscriptionPlan from '@hooks/useSubscriptionPlan'; @@ -191,7 +191,7 @@ function HeaderView({onNavigationMenuButtonClicked, reportID}: HeaderViewProps) selector: getInvoiceReceiverPolicyID, }); const invoiceReceiverPolicy = usePolicy(invoiceReceiverPolicyID); - const derivedNames = useDerivedReportNamesByReportIDs([parentNavigationReport?.parentReportID, reportHeaderData?.reportID]); + const derivedNames = useReportNames([parentNavigationReport?.parentReportID, reportHeaderData?.reportID]); const derivedParentReportName = getReportNameFromNames(derivedNames, parentNavigationReport?.parentReportID); const derivedReportHeaderName = getReportNameFromNames(derivedNames, reportHeaderData?.reportID); const isReportHeaderDataArchived = useReportIsArchived(reportHeaderData?.reportID); diff --git a/src/pages/inbox/ReportHeader.tsx b/src/pages/inbox/ReportHeader.tsx index 6d72ee5e5eb1..2ef0c41793e1 100644 --- a/src/pages/inbox/ReportHeader.tsx +++ b/src/pages/inbox/ReportHeader.tsx @@ -5,7 +5,7 @@ import OfflineWithFeedback from '@components/OfflineWithFeedback'; import useDocumentTitle from '@hooks/useDocumentTitle'; import useIsInSidePanel from '@hooks/useIsInSidePanel'; import useOnyx from '@hooks/useOnyx'; -import {useDerivedReportNameByReportID} from '@hooks/useReportAttributes'; +import {useReportName} from '@hooks/useReportAttributes'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSidePanelActions from '@hooks/useSidePanelActions'; @@ -40,7 +40,7 @@ function ReportHeader() { const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportIDFromRoute}`); const reportID = report?.reportID; - const derivedReportName = useDerivedReportNameByReportID(reportID); + const derivedReportName = useReportName(reportID); useDocumentTitle(getReportName(report, derivedReportName)); const isTransactionThreadView = isReportTransactionThread(report); diff --git a/src/pages/inbox/report/ContextMenu/BaseReportActionContextMenu.tsx b/src/pages/inbox/report/ContextMenu/BaseReportActionContextMenu.tsx index 56e05508592c..1750392bfd92 100755 --- a/src/pages/inbox/report/ContextMenu/BaseReportActionContextMenu.tsx +++ b/src/pages/inbox/report/ContextMenu/BaseReportActionContextMenu.tsx @@ -17,7 +17,7 @@ import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePaginatedReportActions from '@hooks/usePaginatedReportActions'; -import useReportAttributes, {useDerivedIsEmptyReport, useDerivedReportNameByReportID} from '@hooks/useReportAttributes'; +import {useAllReportAttributes, useIsEmptyReport, useReportName} from '@hooks/useReportAttributes'; import useReportIDToNameMap from '@hooks/useReportIDToNameMap'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useReportOrReportDraft from '@hooks/useReportOrReportDraft'; @@ -250,7 +250,7 @@ function BaseReportActionContextMenu({ const [cardList] = useOnyx(ONYXKEYS.CARD_LIST); const [conciergeChat] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${conciergeReportID}`); const personalDetails = usePersonalDetails(); - const reportAttributes = useReportAttributes(); + const reportAttributes = useAllReportAttributes(); const delegateAccountID = useDelegateAccountID(); const isTrackIntentUser = isTrackOnboardingChoice(introSelected?.choice); @@ -271,13 +271,13 @@ function BaseReportActionContextMenu({ const isArchivedRoom = isArchivedNonExpenseReport(originalReport, isOriginalReportArchived); const isChronosReport = chatIncludesChronosWithID(originalReportID); const isPinnedChat = !!report?.isPinned; - const derivedIsEmptyReport = useDerivedIsEmptyReport(reportID); + const derivedIsEmptyReport = useIsEmptyReport(reportID); const isUnreadChat = isUnread(report, lhnOneTransactionThreadReport, isOriginalReportArchived, derivedIsEmptyReport); const shouldEnableArrowNavigation = !isMini && (isVisible || shouldKeepOpen); const isHarvestReport = isHarvestCreatedExpenseReport(reportNameValuePairs?.origin, reportNameValuePairs?.originalID); const memberChangeLogReportActionMessage = isMemberChangeAction(reportAction) ? getOriginalMessage(reportAction) : undefined; const [memberChangeLogRoomReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(`${memberChangeLogReportActionMessage?.reportID}`)}`); - const derivedMemberChangeLogRoomReportName = useDerivedReportNameByReportID(memberChangeLogRoomReport?.reportID); + const derivedMemberChangeLogRoomReportName = useReportName(memberChangeLogRoomReport?.reportID); const memberChangeLogRoomReportName = getReportName(memberChangeLogRoomReport, derivedMemberChangeLogRoomReportName) || memberChangeLogReportActionMessage?.roomName; let filteredContextMenuActions = ContextMenuActions.filter( diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index a73ce78e0a75..559e96249c07 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -12,6 +12,7 @@ import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import {usePersonalDetailsByIDs} from '@hooks/usePersonalDetails'; import useReportActionsScroll from '@hooks/useReportActionsScroll'; +import {useReportStatus} from '@hooks/useReportAttributes'; import useReportScrollManager from '@hooks/useReportScrollManager'; import useReportsParentHierarchy from '@hooks/useReportsParentHierarchy'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; @@ -182,20 +183,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(report?.policyID)}`); const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); - const reportAttributesSelector = (value: OnyxEntry) => { - const attrs = value?.reports?.[reportID]; - if (!attrs) { - return undefined; - } - return { - actionBadge: attrs.actionBadge, - actionTargetReportActionID: attrs.actionTargetReportActionID, - brickRoadStatus: attrs.brickRoadStatus, - }; - }; - const [reportAttributes] = useOnyx(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, { - selector: reportAttributesSelector, - }); + const reportAttributes = useReportStatus(reportID); const isHarvestCreatedExpenseReportAction = isHarvestCreatedExpenseReport(reportNameValuePairs?.origin, reportNameValuePairs?.originalID); const [reportStable] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {selector: getStableReportSelector}); diff --git a/src/pages/inbox/report/SearchActionHeader.tsx b/src/pages/inbox/report/SearchActionHeader.tsx index 1aeb2d1cdf99..95c693acd9a7 100644 --- a/src/pages/inbox/report/SearchActionHeader.tsx +++ b/src/pages/inbox/report/SearchActionHeader.tsx @@ -6,7 +6,7 @@ import TextLink from '@components/TextLink'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; -import {useDerivedReportNameByReportID} from '@hooks/useReportAttributes'; +import {useReportName} from '@hooks/useReportAttributes'; import useReportTransactionsCollection from '@hooks/useReportTransactionsCollection'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -52,7 +52,7 @@ function SearchActionHeaderContent({action, report, isWhisper, onPress, children const reportTransactionsCollection = useReportTransactionsCollection(reportForHeaderReportID); const linkedTransactions = Object.values(reportTransactionsCollection ?? {}).filter((transaction): transaction is Transaction => !!transaction); - const derivedReportName = useDerivedReportNameByReportID(report?.reportID); + const derivedReportName = useReportName(report?.reportID); const reportName = getChatListItemReportName( action, report, diff --git a/src/pages/inbox/report/actionContents/MemberChangeContent.tsx b/src/pages/inbox/report/actionContents/MemberChangeContent.tsx index c3b367fda7c9..f3d57a8751aa 100644 --- a/src/pages/inbox/report/actionContents/MemberChangeContent.tsx +++ b/src/pages/inbox/report/actionContents/MemberChangeContent.tsx @@ -1,7 +1,7 @@ import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import {usePersonalDetail, usePersonalDetailsByIDs} from '@hooks/usePersonalDetails'; -import {useDerivedReportNameByReportID} from '@hooks/useReportAttributes'; +import {useReportName} from '@hooks/useReportAttributes'; import useThemeStyles from '@hooks/useThemeStyles'; import {getMemberChangeMessageFragment, getOriginalMessage, isMemberChangeAction} from '@libs/ReportActionsUtils'; @@ -29,7 +29,7 @@ function MemberChangeContent({action}: MemberChangeContentProps) { const [actorDetails] = usePersonalDetail(action.actorAccountID); const [targetAccountDetailsList] = usePersonalDetailsByIDs(memberChangeLogReportActionMessage?.targetAccountIDs); const [memberChangeLogRoomReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${memberChangeLogReportActionMessage?.reportID}`); - const derivedMemberChangeLogRoomReportName = useDerivedReportNameByReportID(memberChangeLogRoomReport?.reportID); + const derivedMemberChangeLogRoomReportName = useReportName(memberChangeLogRoomReport?.reportID); const memberChangeLogRoomReportName = getReportName(memberChangeLogRoomReport, derivedMemberChangeLogRoomReportName) || memberChangeLogReportActionMessage?.roomName; const fragment = getMemberChangeMessageFragment(translate, action, actorDetails, targetAccountDetailsList, memberChangeLogRoomReportName); diff --git a/src/pages/inbox/sidebar/FABPopoverContent/menuItems/QuickActionMenuItem.tsx b/src/pages/inbox/sidebar/FABPopoverContent/menuItems/QuickActionMenuItem.tsx index 998c30c38491..bee0360bf2bb 100644 --- a/src/pages/inbox/sidebar/FABPopoverContent/menuItems/QuickActionMenuItem.tsx +++ b/src/pages/inbox/sidebar/FABPopoverContent/menuItems/QuickActionMenuItem.tsx @@ -6,7 +6,7 @@ import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import {useAllPersonalDetails} from '@hooks/usePersonalDetails'; import usePreferredPolicy from '@hooks/usePreferredPolicy'; -import {useDerivedReportNamesByReportIDs} from '@hooks/useReportAttributes'; +import {useReportNames} from '@hooks/useReportAttributes'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -77,7 +77,7 @@ function QuickActionMenuItem({reportID}: QuickActionMenuItemProps) { const policyChatForActivePolicy = !isEmptyObject(activePolicy) && isGroupPolicy(activePolicy) && policyChats.length > 0 ? policyChats.at(0) : undefined; const isPolicyChatForActivePolicyArchived = useReportIsArchived(policyChatForActivePolicy?.reportID); - const derivedNames = useDerivedReportNamesByReportIDs([quickActionReport?.reportID, policyChatForActivePolicy?.reportID]); + const derivedNames = useReportNames([quickActionReport?.reportID, policyChatForActivePolicy?.reportID]); const derivedQuickActionReportName = getReportNameFromNames(derivedNames, quickActionReport?.reportID); const derivedPolicyChatReportName = getReportNameFromNames(derivedNames, policyChatForActivePolicy?.reportID); diff --git a/src/pages/inbox/sidebar/InboxTabSelector.tsx b/src/pages/inbox/sidebar/InboxTabSelector.tsx index 3856d1e51f07..0a0a50147405 100644 --- a/src/pages/inbox/sidebar/InboxTabSelector.tsx +++ b/src/pages/inbox/sidebar/InboxTabSelector.tsx @@ -12,7 +12,7 @@ import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePopoverPosition from '@hooks/usePopoverPosition'; -import useReportAttributes from '@hooks/useReportAttributes'; +import {useAllReportAttributes} from '@hooks/useReportAttributes'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import {useSidebarOrderedReportsActions, useSidebarOrderedReportsState} from '@hooks/useSidebarOrderedReports'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -52,7 +52,7 @@ function InboxTabSelector() { const {activeTab, inboxTabCounts, hasStaleUnreadReport} = useSidebarOrderedReportsState(); const {setActiveTab, getReportIDsForTab} = useSidebarOrderedReportsActions(); const [reportNameValuePairs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, {selector: reportNameValuePairsArchivedSelector}); - const reportAttributesDerived = useReportAttributes(); + const reportAttributesDerived = useAllReportAttributes(); const icons = useMemoizedLazyExpensifyIcons(['Checkmark', 'Feed', 'ChatBubbleUnread', 'Task']); const {showConfirmModal} = useConfirmModal(); // Only show the tooltip if we have unread message > 3 months old. diff --git a/src/pages/iou/DynamicSplitBillDetailsPage.tsx b/src/pages/iou/DynamicSplitBillDetailsPage.tsx index c1543535cab4..ba3011b940ee 100644 --- a/src/pages/iou/DynamicSplitBillDetailsPage.tsx +++ b/src/pages/iou/DynamicSplitBillDetailsPage.tsx @@ -14,7 +14,7 @@ import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import {useAllPersonalDetails} from '@hooks/usePersonalDetails'; -import useReportAttributes from '@hooks/useReportAttributes'; +import {useAllReportAttributes} from '@hooks/useReportAttributes'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useReportTransactions from '@hooks/useReportTransactions'; import useTheme from '@hooks/useTheme'; @@ -71,7 +71,7 @@ function DynamicSplitBillDetailsPage({report, reportAction}: SplitBillDetailsPag const [transactionReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transaction?.reportID}`); const [quickAction] = useOnyx(ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE); const [session] = useOnyx(ONYXKEYS.SESSION); - const reportAttributesDerived = useReportAttributes(); + const reportAttributesDerived = useAllReportAttributes(); const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`); const privateIsArchived = useReportIsArchived(reportID); const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); diff --git a/src/pages/iou/DynamicSplitExpenseEditPage.tsx b/src/pages/iou/DynamicSplitExpenseEditPage.tsx index 637e05e0ceb8..c6a77f63b9d2 100644 --- a/src/pages/iou/DynamicSplitExpenseEditPage.tsx +++ b/src/pages/iou/DynamicSplitExpenseEditPage.tsx @@ -26,7 +26,7 @@ import {useAllPersonalDetails} from '@hooks/usePersonalDetails'; import usePersonalPolicy from '@hooks/usePersonalPolicy'; import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; import usePrevious from '@hooks/usePrevious'; -import {useDerivedReportNameByReportID} from '@hooks/useReportAttributes'; +import {useReportName} from '@hooks/useReportAttributes'; import useReportOrReportDraft from '@hooks/useReportOrReportDraft'; import useSplitEffectivePolicy from '@hooks/useSplitEffectivePolicy'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -228,7 +228,7 @@ function DynamicSplitExpenseEditPage({route}: DynamicSplitExpenseEditPageProps) const isCategoryRequired = !!effectivePolicy?.requiresCategory && !isSelfDMSplit; const categoryValue = getDecodedLeafCategoryName(splitExpenseDraftTransactionDetails?.category ?? ''); - const derivedCurrentReportName = useDerivedReportNameByReportID(currentReport?.reportID); + const derivedCurrentReportName = useReportName(currentReport?.reportID); const reportName = getReportName(currentReport, derivedCurrentReportName) || parentReport?.reportName; const isDescriptionRequired = isCategoryDescriptionRequired(policyCategories, splitExpenseDraftTransactionDetails?.category, arePolicyRulesEnabled(effectivePolicy, policyCategories)); diff --git a/src/pages/iou/request/ParticipantSearchResults.tsx b/src/pages/iou/request/ParticipantSearchResults.tsx index 4a2ea7a9576b..d289ea763981 100644 --- a/src/pages/iou/request/ParticipantSearchResults.tsx +++ b/src/pages/iou/request/ParticipantSearchResults.tsx @@ -17,7 +17,7 @@ import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePreferredPolicy from '@hooks/usePreferredPolicy'; import usePrivateIsArchivedMap from '@hooks/usePrivateIsArchivedMap'; -import useReportAttributes from '@hooks/useReportAttributes'; +import {useAllReportAttributes} from '@hooks/useReportAttributes'; import useScreenWrapperTransitionStatus from '@hooks/useScreenWrapperTransitionStatus'; import useSearchSelector from '@hooks/useSearchSelector'; import useSelectedExpenseReports from '@hooks/useSelectedExpenseReports'; @@ -174,7 +174,7 @@ function ParticipantSearchResults({ const currentUserEmail = currentUserPersonalDetails.email ?? ''; const currentUserAccountID = currentUserPersonalDetails.accountID; const currentUserLogin = currentUserPersonalDetails.login; - const reportAttributesDerived = useReportAttributes(); + const reportAttributesDerived = useAllReportAttributes(); const privateIsArchivedMap = usePrivateIsArchivedMap(); // When the surrounding ScreenWrapper runs in edge-to-edge mode (e.g. the new manual expense flow's ParticipantPicker), diff --git a/src/pages/iou/request/step/AmountSubmitDataSync.tsx b/src/pages/iou/request/step/AmountSubmitDataSync.tsx index 2a1a4b396838..cf75700e8e2f 100644 --- a/src/pages/iou/request/step/AmountSubmitDataSync.tsx +++ b/src/pages/iou/request/step/AmountSubmitDataSync.tsx @@ -5,6 +5,7 @@ import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import {useAllPersonalDetails} from '@hooks/usePersonalDetails'; import usePersonalPolicy from '@hooks/usePersonalPolicy'; +import {useAllReportAttributes} from '@hooks/useReportAttributes'; import useSelfDMReport from '@hooks/useSelfDMReport'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; @@ -41,7 +42,7 @@ type AmountSubmitData = Pick< | 'userBillingGracePeriodEnds' | 'duplicateTransactions' | 'duplicateTransactionViolations' - | 'reportAttributesDerivedValue' + | 'reportAttributes' | 'isASAPSubmitBetaEnabled' | 'quickAction' | 'onboarding' @@ -93,7 +94,7 @@ function AmountSubmitDataSync({report, transaction, transactionID, policyID, isE const [storedTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(existingTransactionID)}`); const reportIDToCheck = isMoneyRequestReport(report) ? report?.chatReportID : report?.reportID; const [isDraftChatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_DRAFT}${reportIDToCheck}`, {selector: isDraftReportSelector}); - const [reportAttributesDerivedValue] = useOnyx(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES); + const reportAttributes = useAllReportAttributes(); const {isBetaEnabled} = usePermissions(); const [quickAction] = useOnyx(ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE); const [onboarding] = useOnyx(ONYXKEYS.NVP_ONBOARDING); @@ -130,7 +131,7 @@ function AmountSubmitDataSync({report, transaction, transactionID, policyID, isE userBillingGracePeriodEnds, duplicateTransactions, duplicateTransactionViolations, - reportAttributesDerivedValue, + reportAttributes, isASAPSubmitBetaEnabled: isBetaEnabled(CONST.BETAS.ASAP_SUBMIT), quickAction, onboarding, @@ -161,7 +162,7 @@ function AmountSubmitDataSync({report, transaction, transactionID, policyID, isE userBillingGracePeriodEnds, duplicateTransactions, duplicateTransactionViolations, - reportAttributesDerivedValue, + reportAttributes, isBetaEnabled, quickAction, onboarding, diff --git a/src/pages/iou/request/step/DynamicIOURequestStepDistance.tsx b/src/pages/iou/request/step/DynamicIOURequestStepDistance.tsx index 20e970b91010..920e8e7307d5 100644 --- a/src/pages/iou/request/step/DynamicIOURequestStepDistance.tsx +++ b/src/pages/iou/request/step/DynamicIOURequestStepDistance.tsx @@ -24,7 +24,7 @@ import usePolicy from '@hooks/usePolicy'; import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; import usePreMountDestination from '@hooks/usePreMountDestination'; import usePrevious from '@hooks/usePrevious'; -import useReportAttributes from '@hooks/useReportAttributes'; +import {useAllReportAttributes} from '@hooks/useReportAttributes'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useSelfDMReport from '@hooks/useSelfDMReport'; import useShowNotFoundPageInIOUStep from '@hooks/useShowNotFoundPageInIOUStep'; @@ -150,7 +150,7 @@ function DynamicIOURequestStepDistance({ } : transactionWaypoints); - const reportAttributesDerived = useReportAttributes(); + const reportAttributesDerived = useAllReportAttributes(); let transactionState: TransactionStateType = CONST.TRANSACTION.STATE.CURRENT; if (isEditingSplit) { diff --git a/src/pages/iou/request/step/DynamicIOURequestStepDistanceManual.tsx b/src/pages/iou/request/step/DynamicIOURequestStepDistanceManual.tsx index 1a66c3b064e2..efb44a6051ce 100644 --- a/src/pages/iou/request/step/DynamicIOURequestStepDistanceManual.tsx +++ b/src/pages/iou/request/step/DynamicIOURequestStepDistanceManual.tsx @@ -22,7 +22,7 @@ import {useAllPersonalDetails} from '@hooks/usePersonalDetails'; import usePersonalPolicy from '@hooks/usePersonalPolicy'; import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; import usePolicyForTransaction from '@hooks/usePolicyForTransaction'; -import useReportAttributes from '@hooks/useReportAttributes'; +import {useAllReportAttributes} from '@hooks/useReportAttributes'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useReportTransactions from '@hooks/useReportTransactions'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; @@ -104,7 +104,7 @@ function DynamicIOURequestStepDistanceManual({ const personalPolicy = usePersonalPolicy(); const defaultExpensePolicy = useDefaultExpensePolicy(); const {policyForMovingExpenses} = usePolicyForMovingExpenses(); - const reportAttributesDerived = useReportAttributes(); + const reportAttributesDerived = useAllReportAttributes(); const [selectedTab] = useOnyx(`${ONYXKEYS.COLLECTION.SELECTED_TAB}${CONST.TAB.DISTANCE_REQUEST_TYPE}`); const distanceOriginalPolicy = useDistanceRateOriginalPolicy(transaction?.comment?.customUnit?.customUnitRateID); diff --git a/src/pages/iou/request/step/IOURequestStepAmount.tsx b/src/pages/iou/request/step/IOURequestStepAmount.tsx index ac2c5970e814..8093bf583a53 100644 --- a/src/pages/iou/request/step/IOURequestStepAmount.tsx +++ b/src/pages/iou/request/step/IOURequestStepAmount.tsx @@ -12,7 +12,7 @@ import usePermissions from '@hooks/usePermissions'; import {useAllPersonalDetails} from '@hooks/usePersonalDetails'; import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; import usePreMountDestination from '@hooks/usePreMountDestination'; -import useReportAttributes from '@hooks/useReportAttributes'; +import {useAllReportAttributes} from '@hooks/useReportAttributes'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useReportOrReportDraft from '@hooks/useReportOrReportDraft'; import useShowNotFoundPageInIOUStep from '@hooks/useShowNotFoundPageInIOUStep'; @@ -218,7 +218,7 @@ function IOURequestStepAmount({ const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const reportIDToCheck = isMoneyRequestReport(report) ? report?.chatReportID : report?.reportID; const [reportDraft] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_DRAFT}${reportIDToCheck}`); - const reportAttributesDerived = useReportAttributes(); + const reportAttributesDerived = useAllReportAttributes(); const selectedParticipants = getMoneyRequestParticipantsFromReport(report, currentUserPersonalDetails.accountID); const participants = selectedParticipants.map((participant) => { diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation/index.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation/index.tsx index cb606c8bc56e..0ac6f951beac 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation/index.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation/index.tsx @@ -23,7 +23,7 @@ import useOnyx from '@hooks/useOnyx'; import useParticipantsPolicies from '@hooks/useParticipantsPolicies'; import usePolicyForTransaction from '@hooks/usePolicyForTransaction'; import usePrivateIsArchivedMap from '@hooks/usePrivateIsArchivedMap'; -import useReportAttributes from '@hooks/useReportAttributes'; +import {useAllReportAttributes} from '@hooks/useReportAttributes'; import useReportOrReportDraft from '@hooks/useReportOrReportDraft'; import useStableArrayReference from '@hooks/useStableArrayReference'; import useTheme from '@hooks/useTheme'; @@ -236,7 +236,7 @@ function IOURequestStepConfirmationContent({ selector: validTransactionDraftIDsSelector, }); - const reportAttributesDerived = useReportAttributes(); + const reportAttributesDerived = useAllReportAttributes(); const policyCategories = isDraftPolicy && draftPolicyID ? policyCategoriesDraft : policyCategoriesReal; diff --git a/src/pages/iou/request/step/IOURequestStepDistanceGPS/index.native.tsx b/src/pages/iou/request/step/IOURequestStepDistanceGPS/index.native.tsx index b12d760f4fe5..129100ad63e6 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceGPS/index.native.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceGPS/index.native.tsx @@ -16,7 +16,7 @@ import {useAllPersonalDetails} from '@hooks/usePersonalDetails'; import usePersonalPolicy from '@hooks/usePersonalPolicy'; import usePolicy from '@hooks/usePolicy'; import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; -import useReportAttributes from '@hooks/useReportAttributes'; +import {useAllReportAttributes} from '@hooks/useReportAttributes'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useReportTransactions from '@hooks/useReportTransactions'; import useSelfDMReport from '@hooks/useSelfDMReport'; @@ -77,7 +77,7 @@ function IOURequestStepDistanceGPS({ const [gpsDraftDetails] = useOnyx(ONYXKEYS.GPS_DRAFT_DETAILS); const [skipConfirmation] = useOnyx(`${ONYXKEYS.COLLECTION.SKIP_CONFIRMATION}${transactionID}`); const [personalDetails] = useAllPersonalDetails(); - const reportAttributesDerived = useReportAttributes(); + const reportAttributesDerived = useAllReportAttributes(); const [quickAction] = useOnyx(ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE); const [policyRecentlyUsedCurrencies] = useOnyx(ONYXKEYS.RECENTLY_USED_CURRENCIES); const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); diff --git a/src/pages/iou/request/step/IOURequestStepDistanceMap.tsx b/src/pages/iou/request/step/IOURequestStepDistanceMap.tsx index 50e81fd1b1c4..9b097a1db828 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceMap.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceMap.tsx @@ -15,7 +15,7 @@ import usePersonalPolicy from '@hooks/usePersonalPolicy'; import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; import usePolicyForTransaction from '@hooks/usePolicyForTransaction'; import usePrevious from '@hooks/usePrevious'; -import useReportAttributes from '@hooks/useReportAttributes'; +import {useAllReportAttributes} from '@hooks/useReportAttributes'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useSelfDMReport from '@hooks/useSelfDMReport'; import useShowNotFoundPageInIOUStep from '@hooks/useShowNotFoundPageInIOUStep'; @@ -114,7 +114,7 @@ function IOURequestStepDistanceMap({ waypoint1: {keyForList: 'stop_waypoint'}, } : transactionWaypoints); - const reportAttributesDerived = useReportAttributes(); + const reportAttributesDerived = useAllReportAttributes(); const transactionState: TransactionStateType = shouldUseTransactionDraft(action) ? CONST.TRANSACTION.STATE.DRAFT : CONST.TRANSACTION.STATE.CURRENT; const {shouldFetchRoute, validatedWaypoints} = useFetchRoute(transaction, waypoints, action, transactionState, policy); diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index 597987fef50e..9dbb88e3e4e2 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -26,7 +26,7 @@ import {useAllPersonalDetails} from '@hooks/usePersonalDetails'; import usePersonalPolicy from '@hooks/usePersonalPolicy'; import usePolicy from '@hooks/usePolicy'; import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; -import useReportAttributes from '@hooks/useReportAttributes'; +import {useAllReportAttributes} from '@hooks/useReportAttributes'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useRestartOnOdometerImagesFailure from '@hooks/useRestartOnOdometerImagesFailure'; @@ -128,7 +128,7 @@ function IOURequestStepDistanceOdometer({ const isArchived = useReportIsArchived(report?.reportID); const [personalDetails] = useAllPersonalDetails(); - const reportAttributesDerived = useReportAttributes(); + const reportAttributesDerived = useAllReportAttributes(); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [skipConfirmation] = useOnyx(`${ONYXKEYS.COLLECTION.SKIP_CONFIRMATION}${transactionID}`); const [splitDraftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`); diff --git a/src/pages/iou/request/step/IOURequestStepReuseRoute.tsx b/src/pages/iou/request/step/IOURequestStepReuseRoute.tsx index 358c0f20cf84..66bf303c852c 100644 --- a/src/pages/iou/request/step/IOURequestStepReuseRoute.tsx +++ b/src/pages/iou/request/step/IOURequestStepReuseRoute.tsx @@ -13,7 +13,7 @@ import {useAllPersonalDetails} from '@hooks/usePersonalDetails'; import usePersonalPolicy from '@hooks/usePersonalPolicy'; import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; import usePolicyForTransaction from '@hooks/usePolicyForTransaction'; -import useReportAttributes from '@hooks/useReportAttributes'; +import {useAllReportAttributes} from '@hooks/useReportAttributes'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useSelfDMReport from '@hooks/useSelfDMReport'; import useShowNotFoundPageInIOUStep from '@hooks/useShowNotFoundPageInIOUStep'; @@ -70,7 +70,7 @@ function IOURequestStepReuseRoute({ const selfDMReport = useSelfDMReport(); const [personalDetails] = useAllPersonalDetails(); const defaultExpensePolicy = useDefaultExpensePolicy(); - const reportAttributesDerived = useReportAttributes(); + const reportAttributesDerived = useAllReportAttributes(); const [recentWaypoints] = useOnyx(ONYXKEYS.NVP_RECENT_WAYPOINTS); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [skipConfirmation] = useOnyx(`${ONYXKEYS.COLLECTION.SKIP_CONFIRMATION}${transactionID}`); diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/ScanSkipConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/ScanSkipConfirmation.tsx index 0582fc4ec332..0ff87c7ecf2a 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/components/ScanSkipConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/components/ScanSkipConfirmation.tsx @@ -16,7 +16,7 @@ import {useAllPersonalDetails} from '@hooks/usePersonalDetails'; import usePolicy from '@hooks/usePolicy'; import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; import usePreMountDestination from '@hooks/usePreMountDestination'; -import useReportAttributes from '@hooks/useReportAttributes'; +import {useAllReportAttributes} from '@hooks/useReportAttributes'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useSelfDMReport from '@hooks/useSelfDMReport'; @@ -80,7 +80,7 @@ function ScanSkipConfirmation({report, action, iouType, reportID, transactionID, const {policyForMovingExpenses} = usePolicyForMovingExpenses(); const isArchived = useReportIsArchived(report?.reportID); const selfDMReport = useSelfDMReport(); - const reportAttributesDerived = useReportAttributes(); + const reportAttributesDerived = useAllReportAttributes(); const {isBetaEnabled, isBetaEnabledOrUnknown} = usePermissions(); const isVendorMatchingBetaEnabled = isBetaEnabledOrUnknown(CONST.BETAS.VENDOR_MATCHING); const delegateAccountID = useDelegateAccountID(); diff --git a/src/pages/media/AttachmentModalScreen/routes/report/ReportAvatarModalContent.tsx b/src/pages/media/AttachmentModalScreen/routes/report/ReportAvatarModalContent.tsx index fd8b6cb54f45..e6ab89933100 100644 --- a/src/pages/media/AttachmentModalScreen/routes/report/ReportAvatarModalContent.tsx +++ b/src/pages/media/AttachmentModalScreen/routes/report/ReportAvatarModalContent.tsx @@ -1,7 +1,7 @@ import useDefaultAvatars from '@hooks/useDefaultAvatars'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; -import {useDerivedReportNameByReportID} from '@hooks/useReportAttributes'; +import {useReportName} from '@hooks/useReportAttributes'; import {getReportName} from '@libs/ReportNameUtils'; import {getDefaultGroupAvatar, getPolicyName, getWorkspaceIcon, isGroupChat, isThread, isUserCreatedPolicyRoom} from '@libs/ReportUtils'; @@ -25,7 +25,7 @@ function ReportAvatarModalContent({navigation, route}: AttachmentModalScreenProp const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); const [isLoadingApp = true] = useOnyx(ONYXKEYS.IS_LOADING_APP); - const derivedReportName = useDerivedReportNameByReportID(reportID); + const derivedReportName = useReportName(reportID); let attachment: AttachmentModalBaseContentProps; if (isGroupChat(report) && !isThread(report)) { diff --git a/src/pages/tasks/DynamicNewTaskPage.tsx b/src/pages/tasks/DynamicNewTaskPage.tsx index 12e62fcb9053..788678802dc4 100644 --- a/src/pages/tasks/DynamicNewTaskPage.tsx +++ b/src/pages/tasks/DynamicNewTaskPage.tsx @@ -22,7 +22,7 @@ import useOnyx from '@hooks/useOnyx'; import {useAllPersonalDetails} from '@hooks/usePersonalDetails'; import usePolicy from '@hooks/usePolicy'; import usePressLoading from '@hooks/usePressLoading'; -import {useDerivedReportNameByReportID} from '@hooks/useReportAttributes'; +import {useReportName} from '@hooks/useReportAttributes'; import useSafeAreaPaddings from '@hooks/useSafeAreaPaddings'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -80,7 +80,7 @@ function DynamicNewTaskPage() { const [quickAction] = useOnyx(ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); - const derivedSharedDestinationReportName = useDerivedReportNameByReportID(parentReport?.reportID); + const derivedSharedDestinationReportName = useReportName(parentReport?.reportID); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const delegateAccountID = useDelegateAccountID(); const taskCreatorAndAssigneeDetails = getPersonalDetailsListByIDs([currentUserPersonalDetails.accountID, task?.assigneeAccountID], personalDetails); diff --git a/src/pages/workspace/rooms/WorkspaceRoomsPage.tsx b/src/pages/workspace/rooms/WorkspaceRoomsPage.tsx index 83b192e83b22..2da1e8d15451 100644 --- a/src/pages/workspace/rooms/WorkspaceRoomsPage.tsx +++ b/src/pages/workspace/rooms/WorkspaceRoomsPage.tsx @@ -14,7 +14,7 @@ import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePolicy from '@hooks/usePolicy'; -import {useDerivedReportNamesByReportIDs} from '@hooks/useReportAttributes'; +import {useReportNames} from '@hooks/useReportAttributes'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import useWorkspaceDocumentTitle from '@hooks/useWorkspaceDocumentTitle'; @@ -102,7 +102,7 @@ function WorkspaceRoomsPage({route}: WorkspaceRoomsPageProps) { }, }); const policyReportIDs = (policyReports ?? []).map((report) => report.reportID); - const derivedNames = useDerivedReportNamesByReportIDs(policyReportIDs); + const derivedNames = useReportNames(policyReportIDs); // The newly created room reportID is stored in Onyx right before navigating back here so its row can play the highlight animation. // It is cleared by the create page once the navigation transition ends (see WorkspaceNewRoomPage), so the animation doesn't replay on a later visit. diff --git a/src/selectors/ReportAttributes.ts b/src/selectors/ReportAttributes.ts index ac784f1e0271..b94c4311ddaa 100644 --- a/src/selectors/ReportAttributes.ts +++ b/src/selectors/ReportAttributes.ts @@ -1,28 +1,10 @@ import type {ReportAttributesDerivedValue} from '@src/types/onyx'; -import type {ReportAttributes, ReportNamesDerivedValue} from '@src/types/onyx/DerivedValues'; +import type {ReportNamesDerivedValue} from '@src/types/onyx/DerivedValues'; -import type {OnyxEntry} from 'react-native-onyx'; - -const reportByIDsSelector = (reportIDs: string[]) => (attributes: OnyxEntry) => - reportIDs.reduce( - (acc, reportID) => { - const reportAttributes = attributes?.reports?.[reportID]; - if (reportAttributes) { - acc[reportID] = reportAttributes; - } - return acc; - }, - {} as Record, - ); - -const reportNameSelector = (attributes: OnyxEntry, reportID: string | undefined) => (reportID ? attributes?.reports?.[reportID]?.reportName : undefined); - -const reportIsEmptySelector = (attributes: OnyxEntry, reportID: string | undefined) => (reportID ? attributes?.reports?.[reportID]?.isEmpty : undefined); - -const reportNamesByIDsSelector = (attributes: OnyxEntry, reportIDs: Array): ReportNamesDerivedValue => { +const reportNamesByIDsSelector = (attributes: ReportAttributesDerivedValue | undefined, reportIDs: Array): ReportNamesDerivedValue => { const reportNames: ReportNamesDerivedValue = {}; for (const reportID of reportIDs) { - const reportName = reportNameSelector(attributes, reportID); + const reportName = reportID ? attributes?.reports?.[reportID]?.reportName : undefined; if (reportID && reportName !== undefined) { reportNames[reportID] = {reportName}; } @@ -30,5 +12,4 @@ const reportNamesByIDsSelector = (attributes: OnyxEntry ({ + navigate: jest.fn(), + isActiveRoute: jest.fn(), + getTopmostReportId: jest.fn(), + getActiveRoute: jest.fn(), + getTopmostReportActionId: jest.fn(), + isNavigationReady: jest.fn(() => Promise.resolve()), + isDisplayedInModal: jest.fn(() => false), + getActiveRouteWithoutParams: jest.fn(() => ''), +})); +jest.mock('../../src/libs/Navigation/navigationRef', () => ({ + getState: () => ({ + routes: [{name: 'Report'}], + }), + getRootState: () => ({ + routes: [], + }), + addListener: () => () => {}, + isReady: () => true, +})); + +jest.mock('@react-navigation/native'); + +const WRITES_PER_SCENARIO = 10; +const PARTICIPANT_ACCOUNT_ID = 2; +const UNRELATED_TRANSACTION_ID = '888888'; + +function buildReports(count: number): ReportCollectionDataSet { + return Object.fromEntries( + Array.from({length: count}, (_, index) => { + const report = { + ...LHNTestUtils.getFakeReport([1, PARTICIPANT_ACCOUNT_ID], 1, true), + lastMessageText: 'hey', + lastVisibleActionCreated: `2025-01-01 00:00:${String(index % 60).padStart(2, '0')}.${String(index % 1000).padStart(3, '0')}`, + }; + return [`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, report]; + }), + ); +} + +describe.each([500, 5000])('Report attributes in the LHN with %i reports', (reportCount) => { + const reports = buildReports(reportCount); + const visibleReportID = Object.values(reports).at(-1)?.reportID; + const visibleReportKey = `${ONYXKEYS.COLLECTION.REPORT}${visibleReportID}` as const; + const visibleReportActionsKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${visibleReportID}` as const; + + beforeAll(async () => { + Onyx.init({ + keys: ONYXKEYS, + evictableKeys: [ONYXKEYS.COLLECTION.REPORT_ACTIONS], + }); + // Required lazily so LHNTestUtils registers its @react-navigation/native mock before the engine's imports load it + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + require('@userActions/OnyxDerived').default(); + await IntlStore.load(CONST.LOCALES.EN); + }); + + beforeEach(async () => { + global.fetch = TestHelper.getGlobalFetchMock(); + wrapOnyxWithWaitForBatchedUpdates(Onyx); + setHasRadio(true); + await TestHelper.signInWithTestUser(1, 'email1@test.com', undefined, undefined, 'One'); + await waitForBatchedUpdates(); + + // Given many DMs with the same participant and translations that finished loading + await Onyx.multiSet({ + [ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails, + [ONYXKEYS.BETAS]: [CONST.BETAS.DEFAULT_ROOMS], + [ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.DEFAULT, + [ONYXKEYS.IS_LOADING_REPORT_DATA]: false, + [ONYXKEYS.RAM_ONLY_ARE_TRANSLATIONS_LOADING]: false, + ...reports, + }); + await waitForBatchedUpdates(); + }); + + afterEach(async () => { + await Onyx.clear(); + await IntlStore.load(CONST.LOCALES.EN); + await waitForBatchedUpdates(); + }); + + test('should mount the LHN', async () => { + // When the LHN mounts, which is where report attributes are first read after startup + const scenario = async () => { + await screen.findByTestId('lhn-options-list'); + }; + + // Then reassure measures the mount + await measureRenders(, {scenario}); + }); + + test('should count LHN renders when a visible report changes', async () => { + let run = 0; + const scenario = async () => { + await screen.findByTestId('lhn-options-list'); + + // When a visible report gets a field update that does not change the order + for (let index = 0; index < WRITES_PER_SCENARIO; index++) { + await Onyx.merge(visibleReportKey, {lastMessageText: `hey ${run}-${index}`}); + await waitForBatchedUpdates(); + } + run++; + }; + + // Then reassure counts how often the LHN renders for those writes + await measureRenders(, {scenario}); + }); + + test('should take wall time to write one visible report', async () => { + render(); + await screen.findByTestId('lhn-options-list'); + let run = 0; + + // When a visible report gets a field update, the attributes recompute that one report + await measureAsyncFunction(async () => { + for (let index = 0; index < WRITES_PER_SCENARIO; index++) { + await Onyx.merge(visibleReportKey, {lastMessageText: `hey ${run}-${index}`}); + await waitForBatchedUpdates(); + } + run++; + }); + }); + + test('should take wall time to add a comment to a visible report', async () => { + render(); + await screen.findByTestId('lhn-options-list'); + let run = 0; + + // When comments land in a visible report + await measureAsyncFunction(async () => { + for (let index = 0; index < WRITES_PER_SCENARIO; index++) { + const reportActionID = `${run}0${index}`; + await Onyx.merge(visibleReportActionsKey, { + [reportActionID]: { + reportActionID, + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + actorAccountID: PARTICIPANT_ACCOUNT_ID, + created: `2025-02-01 00:00:00.${String(index).padStart(3, '0')}`, + message: [{type: 'COMMENT', html: 'hi', text: 'hi'}], + }, + }); + await waitForBatchedUpdates(); + } + run++; + }); + }); + + test('should take wall time to rename the participant of every report', async () => { + render(); + await screen.findByTestId('lhn-options-list'); + let run = 0; + + // When the person in every DM changes their display name, every report name is recomputed + await measureAsyncFunction(async () => { + for (let index = 0; index < WRITES_PER_SCENARIO; index++) { + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, {[PARTICIPANT_ACCOUNT_ID]: {displayName: `Two ${run}-${index}`}}); + await waitForBatchedUpdates(); + } + run++; + }); + }); + + test('should take wall time to write a violation on an expense outside the list', async () => { + render(); + await screen.findByTestId('lhn-options-list'); + + // When a violation lands on an expense no listed report holds, the attributes have nothing to recompute + await measureAsyncFunction(async () => { + for (let index = 0; index < WRITES_PER_SCENARIO; index++) { + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${UNRELATED_TRANSACTION_ID}`, [ + {name: CONST.VIOLATIONS.MISSING_CATEGORY, type: CONST.VIOLATION_TYPES.VIOLATION, showInReview: index % 2 === 0}, + ]); + await waitForBatchedUpdates(); + } + }); + }); + + test('should take wall time to switch the locale', async () => { + render(); + await screen.findByTestId('lhn-options-list'); + + // When the user switches language twice, every report name is recomputed each time + await measureAsyncFunction(async () => { + await IntlStore.load(CONST.LOCALES.ES); + await waitForBatchedUpdates(); + await IntlStore.load(CONST.LOCALES.EN); + await waitForBatchedUpdates(); + }); + }); +}); diff --git a/tests/ui/AuthScreensInitHandlerTest.tsx b/tests/ui/AuthScreensInitHandlerTest.tsx index bf024aebff8c..5d0f3339784c 100644 --- a/tests/ui/AuthScreensInitHandlerTest.tsx +++ b/tests/ui/AuthScreensInitHandlerTest.tsx @@ -19,13 +19,11 @@ import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {ReportAttributesDerivedValue} from '@src/types/onyx'; import React from 'react'; import {View} from 'react-native'; import Onyx from 'react-native-onyx'; -import createMock from '../utils/createMock'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; import wrapOnyxWithWaitForBatchedUpdates from '../utils/wrapOnyxWithWaitForBatchedUpdates'; @@ -121,6 +119,7 @@ jest.mock('@components/InitialURLContextProvider', () => ({ })); jest.mock('@selectors/Onboarding', () => ({ + ...jest.requireActual>('@selectors/Onboarding'), hasSeenTourSelector: () => false, })); @@ -196,10 +195,15 @@ describe('AuthScreensInitHandler', () => { }); it('getter passed to subscribeToUserEvents returns report attributes when available', async () => { - const mockReports = createMock({testReport: {reportName: 'Test Report'}}); - + // A policy room is named after its stored reportName + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}testReport`, { + reportID: 'testReport', + reportName: 'Test Report', + type: CONST.REPORT.TYPE.CHAT, + chatType: CONST.REPORT.CHAT_TYPE.POLICY_ROOM, + }); await Onyx.merge(ONYXKEYS.SESSION, {accountID: TEST_ACCOUNT_ID, email: 'test@test.com'}); - await Onyx.merge(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, {reports: mockReports}); + await Onyx.set(ONYXKEYS.RAM_ONLY_ARE_TRANSLATIONS_LOADING, false); await waitForBatchedUpdates(); renderAuthScreensInitHandler(); @@ -213,12 +217,12 @@ describe('AuthScreensInitHandler', () => { if (!getter) { throw new Error('Expected report attributes getter to be provided'); } - expect(getter()).toEqual(mockReports); + expect(getter()?.testReport?.reportName).toBe('Test Report'); }); - it('getter passed to subscribeToUserEvents returns undefined when report attributes not yet loaded', async () => { + it('getter passed to subscribeToUserEvents returns no attributes when no reports are loaded yet', async () => { await Onyx.merge(ONYXKEYS.SESSION, {accountID: TEST_ACCOUNT_ID, email: 'test@test.com'}); - // Intentionally do not set ONYXKEYS.DERIVED.REPORT_ATTRIBUTES + // Intentionally do not load any report await waitForBatchedUpdates(); renderAuthScreensInitHandler(); @@ -232,7 +236,7 @@ describe('AuthScreensInitHandler', () => { if (!getter) { throw new Error('Expected report attributes getter to be provided'); } - expect(getter()).toBeUndefined(); + expect(getter()).toEqual({}); }); it.each([ diff --git a/tests/ui/InSelectorTest.tsx b/tests/ui/InSelectorTest.tsx index 67409b7bf5aa..c458085e7d50 100644 --- a/tests/ui/InSelectorTest.tsx +++ b/tests/ui/InSelectorTest.tsx @@ -25,7 +25,10 @@ jest.mock('@hooks/useDebouncedState', () => jest.fn(() => [mockSearchTerm, mockS jest.mock('@hooks/useFilteredOptions', () => jest.fn(() => ({options: {recentReports: [], personalDetails: []}, isLoading: false}))); jest.mock('@hooks/useCurrentUserPersonalDetails', () => jest.fn(() => ({email: 'me@expensify.com', accountID: 999}))); jest.mock('@hooks/useSortedReportActionsData', () => jest.fn(() => ({}))); -jest.mock('@hooks/useReportAttributes', () => jest.fn(() => ({}))); +jest.mock('@hooks/useReportAttributes', () => ({ + ...jest.requireActual>('@hooks/useReportAttributes'), + useAllReportAttributes: jest.fn(() => ({})), +})); jest.mock('@hooks/usePrivateIsArchivedMap', () => jest.fn(() => ({}))); jest.mock('@hooks/useLocalize', () => jest.fn(() => ({ diff --git a/tests/ui/SearchMoneyRequestReportPageTest.tsx b/tests/ui/SearchMoneyRequestReportPageTest.tsx index 8042db632b0d..c4b917a33e2e 100644 --- a/tests/ui/SearchMoneyRequestReportPageTest.tsx +++ b/tests/ui/SearchMoneyRequestReportPageTest.tsx @@ -10,7 +10,7 @@ import useIsReportReadyToDisplay from '@hooks/useIsReportReadyToDisplay'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useParentReportAction from '@hooks/useParentReportAction'; -import {useDerivedReportNameByReportID} from '@hooks/useReportAttributes'; +import {useReportName} from '@hooks/useReportAttributes'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSubmitToDestinationVisible from '@hooks/useSubmitToDestinationVisible'; @@ -55,7 +55,7 @@ jest.mock('@hooks/useReportIsArchived', () => jest.fn()); jest.mock('@hooks/useIsReportReadyToDisplay', () => jest.fn()); jest.mock('@hooks/useParentReportAction', () => jest.fn()); jest.mock('@components/WideRHPContextProvider/useRHPWidth', () => jest.fn()); -jest.mock('@hooks/useReportAttributes', () => ({useDerivedReportNameByReportID: jest.fn()})); +jest.mock('@hooks/useReportAttributes', () => ({useReportName: jest.fn()})); jest.mock('@hooks/useDocumentTitle', () => jest.fn()); jest.mock('@hooks/useResponsiveLayout', () => jest.fn()); // useThemeStyles is consumed directly in the JSX (e.g. styles.mtAuto), so it needs to return @@ -96,7 +96,7 @@ const mockUseSubmitToDestinationVisible = useSubmitToDestinationVisible as jest. const mockUseReportIsArchived = useReportIsArchived as jest.MockedFunction; const mockUseIsReportReadyToDisplay = useIsReportReadyToDisplay as jest.MockedFunction; const mockUseParentReportAction = useParentReportAction as jest.MockedFunction; -const mockUseDerivedReportNameByReportID = useDerivedReportNameByReportID as jest.MockedFunction; +const mockUseReportName = useReportName as jest.MockedFunction; const mockUseResponsiveLayout = useResponsiveLayout as jest.MockedFunction; const mockUseClearReportActionDraftsOnReportChange = useClearReportActionDraftsOnReportChange as jest.MockedFunction; const mockUseTransactionsAndViolationsForReport = useTransactionsAndViolationsForReport as jest.MockedFunction; @@ -235,7 +235,7 @@ describe('SearchMoneyRequestReportPage (legacy transaction self-heal)', () => { mockUseReportIsArchived.mockReturnValue(false); mockUseIsReportReadyToDisplay.mockReturnValue({isEditingDisabled: false, isCurrentReportLoadedFromOnyx: true}); mockUseParentReportAction.mockReturnValue(undefined); - mockUseDerivedReportNameByReportID.mockReturnValue(undefined); + mockUseReportName.mockReturnValue(undefined); mockUseResponsiveLayout.mockReturnValue({ shouldUseNarrowLayout: false, isSmallScreenWidth: false, diff --git a/tests/ui/SubmitDetailsPageTest.tsx b/tests/ui/SubmitDetailsPageTest.tsx index 827434ab96b5..c86fdaf4f24b 100644 --- a/tests/ui/SubmitDetailsPageTest.tsx +++ b/tests/ui/SubmitDetailsPageTest.tsx @@ -90,7 +90,10 @@ jest.mock('@hooks/usePersonalPolicy', () => jest.fn(() => undefined)); jest.mock('@hooks/usePrivateIsArchivedMap', () => jest.fn(() => ({}))); -jest.mock('@hooks/useReportAttributes', () => jest.fn(() => ({}))); +jest.mock('@hooks/useReportAttributes', () => ({ + ...jest.requireActual>('@hooks/useReportAttributes'), + useAllReportAttributes: jest.fn(() => ({})), +})); jest.mock('@hooks/useReportIsArchived', () => jest.fn(() => false)); diff --git a/tests/unit/IOUAmountSubmissionTest.ts b/tests/unit/IOUAmountSubmissionTest.ts index 805b9d57b74a..a1874405ae92 100644 --- a/tests/unit/IOUAmountSubmissionTest.ts +++ b/tests/unit/IOUAmountSubmissionTest.ts @@ -212,7 +212,7 @@ describe('AmountSubmission', () => { userBillingGracePeriodEnds: {}, duplicateTransactions: {}, duplicateTransactionViolations: {}, - reportAttributesDerivedValue: undefined, + reportAttributes: undefined, isASAPSubmitBetaEnabled: false, quickAction: undefined, onboarding: undefined, diff --git a/tests/unit/LhnSearchPreviewParityTest.ts b/tests/unit/LhnSearchPreviewParityTest.ts index 965bfbf5ebc6..f22304284c20 100644 --- a/tests/unit/LhnSearchPreviewParityTest.ts +++ b/tests/unit/LhnSearchPreviewParityTest.ts @@ -23,6 +23,7 @@ import Onyx from 'react-native-onyx'; import createRandomPolicy from '../utils/collections/policies'; import getOnyxValue from '../utils/getOnyxValue'; +import readReportAttributes from '../utils/readReportAttributes'; import {convertToDisplayString, convertToDisplayStringWithoutCurrency, formatPhoneNumber, localeCompare, translateLocal} from '../utils/TestHelper'; import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; @@ -324,7 +325,7 @@ async function computeBothSurfaces({ } const sortedData = await getOnyxValue(ONYXKEYS.DERIVED.RAM_ONLY_SORTED_REPORT_ACTIONS); - const reportAttributesValue = await getOnyxValue(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES); + const reportAttributesValue = await readReportAttributes(); const reportAttributesDerived = reportAttributesValue?.reports; const canWrite = canUserPerformWriteAction(report, isReportArchived); diff --git a/tests/unit/OnyxDerivedSharedDeltaTest.ts b/tests/unit/OnyxDerivedSharedDeltaTest.ts index 1247ef8c8062..0e4eaee8c297 100644 --- a/tests/unit/OnyxDerivedSharedDeltaTest.ts +++ b/tests/unit/OnyxDerivedSharedDeltaTest.ts @@ -2,8 +2,6 @@ import type reportTransactionsAndViolationsConfig from '@libs/actions/OnyxDerive import initOnyxDerivedValues from '@userActions/OnyxDerived'; -import CONST from '@src/CONST'; -import IntlStore from '@src/languages/IntlStore'; import ONYXKEYS from '@src/ONYXKEYS'; import type {OnyxMultiSetInput} from 'react-native-onyx'; @@ -67,9 +65,6 @@ describe('OnyxDerived shared collection delta', () => { beforeAll(async () => { Onyx.init({keys: ONYXKEYS}); initOnyxDerivedValues(); - // reportAttributes also depends on the locale, which only reports once translations have loaded. - await IntlStore.load(CONST.LOCALES.EN); - await Onyx.set(ONYXKEYS.RAM_ONLY_ARE_TRANSLATIONS_LOADING, false); await waitForBatchedUpdates(); }); @@ -91,7 +86,7 @@ describe('OnyxDerived shared collection delta', () => { }); it('scans the transactions collection once per write, however many derived values depend on it', async () => { - // Given reportAttributes, reportTransactionsAndViolations and spendDataSignature all depend on transactions + // Given reportTransactionsAndViolations and spendDataSignature both depend on transactions const signatureBefore = await getOnyxValue(ONYXKEYS.DERIVED.SPEND_DATA_SIGNATURE); // When one expense is edited diff --git a/tests/unit/OnyxDerivedTest.tsx b/tests/unit/OnyxDerivedTest.tsx index 629837bb5080..5680f4527a62 100644 --- a/tests/unit/OnyxDerivedTest.tsx +++ b/tests/unit/OnyxDerivedTest.tsx @@ -2,7 +2,6 @@ import {act, renderHook} from '@testing-library/react-native'; import useOnyx from '@hooks/useOnyx'; -import reportAttributes from '@libs/actions/OnyxDerived/configs/reportAttributes'; import {isReportActionVisible} from '@libs/ReportActionsUtils'; import * as ActiveSpans from '@libs/telemetry/activeSpans'; @@ -13,9 +12,9 @@ import * as OnyxDerivedUtils from '@userActions/OnyxDerived/utils'; import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {PersonalDetailsList, Report, Transaction, TransactionViolation, ReportAction, ReportActions} from '@src/types/onyx'; +import type {PersonalDetailsList, Transaction, TransactionViolation, ReportAction} from '@src/types/onyx'; -import type {OnyxCollection, OnyxUpdate} from 'react-native-onyx'; +import type {OnyxUpdate} from 'react-native-onyx'; import type {TupleToUnion} from 'type-fest'; /* eslint-disable @typescript-eslint/naming-convention */ @@ -195,633 +194,55 @@ describe('OnyxDerived', () => { }); }); - describe('reportAttributes', () => { - beforeAll(async () => { - await IntlStore.load(CONST.LOCALES.EN); - await waitForBatchedUpdates(); - }); - - beforeEach(async () => { - await Onyx.set(ONYXKEYS.RAM_ONLY_ARE_TRANSLATIONS_LOADING, false); - await waitForBatchedUpdates(); - }); - - const mockReport: Report = { - reportID: `test_1`, - reportName: 'Test Report', - type: 'chat', - chatType: CONST.REPORT.CHAT_TYPE.POLICY_ROOM, - lastVisibleActionCreated: '2023-01-01T00:00:00.000Z', - lastMessageText: 'Test message', - lastActorAccountID: 1, - lastMessageHtml: '

Test message

', - policyID: '123', - ownerAccountID: 1, - stateNum: CONST.REPORT.STATE_NUM.OPEN, - statusNum: CONST.REPORT.STATUS_NUM.OPEN, - }; - - it('returns empty reports when dependencies are not set', async () => { - await waitForBatchedUpdates(); - const derivedReportAttributes = await OnyxUtils.get(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES); - expect(derivedReportAttributes).toMatchObject({ - reports: {}, - }); - }); - - it('computes report attributes when reports are set', async () => { - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${mockReport.reportID}`, mockReport); - await waitForBatchedUpdates(); - - const derivedReportAttributes = await OnyxUtils.get(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES); - - expect(derivedReportAttributes).toMatchObject({ - reports: { - [mockReport.reportID]: { - reportName: mockReport.reportName, - }, - }, - }); - }); - - it('should clear the report attributes when the report is cleared', async () => { - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${mockReport.reportID}`, mockReport); - await waitForBatchedUpdates(); - - let derivedReportAttributes = await OnyxUtils.get(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES); - - expect(derivedReportAttributes).toMatchObject({ - reports: { - [mockReport.reportID]: { - reportName: mockReport.reportName, - }, - }, - }); - - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${mockReport.reportID}`, null); - - derivedReportAttributes = await OnyxUtils.get(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES); - - expect(derivedReportAttributes).toMatchObject({ - reports: {}, - }); - }); + describe('coalescing', () => { + // Each flush writes the derived value exactly once via setDerivedValue, so counting its calls for a key + // counts how many times that value was recomputed. + const countRecomputes = (spy: jest.SpyInstance, derivedKey: string) => spy.mock.calls.filter(([calledKey]) => calledKey === derivedKey).length; - it('updates when locale changes', async () => { - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${mockReport.reportID}`, mockReport); - await IntlStore.load(CONST.LOCALES.ES); - // Derived recomputes are coalesced onto a microtask; pump it so the locale change is applied. + it('coalesces the dependency changes from a single Onyx.update batch (without dropping any)', async () => { + // Given the derived value is populated and its connections are warm + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}1`, {...createRandomTransaction(1), transactionID: '1', reportID: 'r1'}); await waitForBatchedUpdates(); + const setDerivedValueSpy = jest.spyOn(OnyxDerivedUtils, 'setDerivedValue'); - const derivedReportAttributes = await OnyxUtils.get(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES); - - expect(derivedReportAttributes).toMatchObject({ - locale: 'es', - }); - }); - - describe('coalescing', () => { - // Each flush/recompute writes the derived value exactly once via setDerivedValue, so counting - // its calls for a given derived key = counting how many times that value was recomputed. - const countRecomputes = (spy: jest.SpyInstance, derivedKey: string) => spy.mock.calls.filter(([calledKey]) => calledKey === derivedKey).length; - - it('coalesces the dependency changes from a single Onyx.update batch (without dropping any)', async () => { - // Prime so the derived value is populated and connections are warm. - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${mockReport.reportID}`, mockReport); - await waitForBatchedUpdates(); - - const setDerivedValueSpy = jest.spyOn(OnyxDerivedUtils, 'setDerivedValue'); - - // One logical update touching 3 different reportAttributes dependencies at once. - const updates: Array> = [ - { - onyxMethod: Onyx.METHOD.MERGE_COLLECTION, - key: ONYXKEYS.COLLECTION.REPORT, - value: {[`${ONYXKEYS.COLLECTION.REPORT}${mockReport.reportID}`]: {reportName: 'Renamed report'}}, - }, - {onyxMethod: Onyx.METHOD.MERGE_COLLECTION, key: ONYXKEYS.COLLECTION.TRANSACTION, value: {[`${ONYXKEYS.COLLECTION.TRANSACTION}1`]: createRandomTransaction(1)}}, - { - onyxMethod: Onyx.METHOD.MERGE_COLLECTION, - key: ONYXKEYS.COLLECTION.REPORT_METADATA, - value: {[`${ONYXKEYS.COLLECTION.REPORT_METADATA}${mockReport.reportID}`]: {isOptimisticReport: false}}, - }, - ]; - await Onyx.update(updates); - await waitForBatchedUpdates(); - - // Microtask flushing coalesces the changes delivered within a synchronous broadcast burst, so - // this recomputes fewer times than one-per-changed-dependency (3). We assert it's reduced rather - // than a brittle exact count, because Onyx.update can span a couple of microtask turns and a - // microtask flush does not batch across Onyx's async pipeline. - expect(countRecomputes(setDerivedValueSpy, ONYXKEYS.DERIVED.REPORT_ATTRIBUTES)).toBeLessThan(3); - - // And the coalesced compute must not drop any of the batched changes. - const derived = await OnyxUtils.get(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES); - expect(derived?.reports?.[mockReport.reportID]?.reportName).toBe('Renamed report'); - - setDerivedValueSpy.mockRestore(); - }); - - it('applies every change from separate merges in the same tick without dropping any', async () => { - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${mockReport.reportID}`, mockReport); - await waitForBatchedUpdates(); - - // Separate merges to different dependencies, fired synchronously (not awaited between). Microtask - // flushing intentionally does NOT batch these into a single recompute — batching across ticks would - // require deferring the flush past render, reintroducing stale-frame reads. The invariant that must - // hold is that no update is dropped: every change is reflected in the final derived value. - Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${mockReport.reportID}`, {reportName: 'Renamed again'}); - Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}1`, createRandomTransaction(1)); - Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${mockReport.reportID}`, {isOptimisticReport: false}); - await waitForBatchedUpdates(); - - const derived = await OnyxUtils.get(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES); - expect(derived?.reports?.[mockReport.reportID]?.reportName).toBe('Renamed again'); - }); - }); - - it('should contain both report attributes update when there are report and transaction updates', async () => { - await waitForBatchedUpdates(); - // Given 2 reports and 1 transaction - const reportID1 = '0'; - const reportID2 = '1'; - const reports: OnyxCollection = { - [`${ONYXKEYS.COLLECTION.REPORT}${reportID1}`]: createRandomReport(Number(reportID1), undefined), - [`${ONYXKEYS.COLLECTION.REPORT}${reportID2}`]: createRandomReport(Number(reportID2), undefined), - }; - const transaction = createRandomTransaction(1); - - // When the report attributes are recomputed with both report and transaction updates - reportAttributes.compute( - [ - reports, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - ], - {}, - ); - const reportAttributesComputedValue = reportAttributes.compute( - [ - reports, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - ], + // When one logical update touches both of its dependencies at once + await Onyx.update([ { - sourceValues: { - [ONYXKEYS.COLLECTION.REPORT]: { - [`${ONYXKEYS.COLLECTION.REPORT}${reportID1}`]: reports[`${ONYXKEYS.COLLECTION.REPORT}${reportID1}`], - }, - [ONYXKEYS.COLLECTION.TRANSACTION]: { - [`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`]: transaction, - }, - }, - }, - ).reports; - - // Then the computed report attributes should contain both reports - expect(Object.keys(reportAttributesComputedValue)).toEqual([reportID1, reportID2]); - }); - - it('should not recompute reportAttributes when personalDetailsList changes without displayName change', async () => { - // Set up initial state with report and personalDetailsList - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${mockReport.reportID}`, mockReport); - await Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, { - '1': { - accountID: 1, - displayName: 'John Doe', - login: 'john.doe@example.com', - firstName: 'John', - lastName: 'Doe', + onyxMethod: Onyx.METHOD.MERGE_COLLECTION, + key: ONYXKEYS.COLLECTION.TRANSACTION, + value: {[`${ONYXKEYS.COLLECTION.TRANSACTION}2`]: {...createRandomTransaction(2), transactionID: '2', reportID: 'r1'}}, }, - }); - await waitForBatchedUpdates(); - - // Get initial computed value - const initialDerivedReportAttributes = await OnyxUtils.get(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES); - - // Spy on generateReportAttributes - this function should NOT be called - // when the optimization kicks in and skips the computation - const generateReportAttributesSpy = jest.spyOn(require('@libs/ReportUtils'), 'generateReportAttributes'); - - // Change only the login (not displayName) - this should trigger the optimization - await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, { - '1': { - login: 'john.newemail@example.com', + { + onyxMethod: Onyx.METHOD.MERGE_COLLECTION, + key: ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS, + value: {[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}1`]: [{name: CONST.VIOLATIONS.MISSING_CATEGORY, type: CONST.VIOLATION_TYPES.VIOLATION}]}, }, - }); + ]); await waitForBatchedUpdates(); - // The generateReportAttributes function should not have been called - // because the optimization should have returned early - expect(generateReportAttributesSpy).not.toHaveBeenCalled(); - - // Get the computed value after login change - const derivedReportAttributesAfterLoginChange = await OnyxUtils.get(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES); - - // And the values should be preserved correctly - expect(derivedReportAttributesAfterLoginChange).toEqual(initialDerivedReportAttributes); - - generateReportAttributesSpy.mockRestore(); + // Then it recomputes fewer times than once per changed dependency, without dropping either change. + // Onyx.update can span a couple of microtask turns, so the count is bounded rather than exact. + expect(countRecomputes(setDerivedValueSpy, ONYXKEYS.DERIVED.REPORT_TRANSACTIONS_AND_VIOLATIONS)).toBeLessThan(2); + const derived = await OnyxUtils.get(ONYXKEYS.DERIVED.REPORT_TRANSACTIONS_AND_VIOLATIONS); + expect(Object.keys(derived?.r1?.transactions ?? {})).toHaveLength(2); + expect(derived?.r1?.violations?.[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}1`]).toHaveLength(1); + setDerivedValueSpy.mockRestore(); }); - it('should recompute reportAttributes when personalDetailsList displayName changes', async () => { - // Set up initial state with report and personalDetailsList - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${mockReport.reportID}`, mockReport); - await Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, { - '1': { - accountID: 1, - displayName: 'John Doe', - login: 'john.doe@example.com', - firstName: 'John', - lastName: 'Doe', - }, - }); + it('applies every change from separate merges in the same tick without dropping any', async () => { + // Given the derived value is populated + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}1`, {...createRandomTransaction(1), transactionID: '1', reportID: 'r1'}); await waitForBatchedUpdates(); - // Get initial computed value reference - const initialDerivedReportAttributes = await OnyxUtils.get(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES); - - // Change the displayName - this should trigger full recomputation - await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, { - '1': { - displayName: 'Jane Doe', - firstName: 'Jane', - }, - }); + // When separate merges to both dependencies fire in the same tick, without awaiting in between + Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}2`, {...createRandomTransaction(2), transactionID: '2', reportID: 'r1'}); + Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}1`, [{name: CONST.VIOLATIONS.MISSING_CATEGORY, type: CONST.VIOLATION_TYPES.VIOLATION}]); await waitForBatchedUpdates(); - // Get the computed value after displayName change - const derivedReportAttributesAfterDisplayNameChange = await OnyxUtils.get(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES); - - // The computed value should not be the same object (new computation happened) - expect(derivedReportAttributesAfterDisplayNameChange).not.toBe(initialDerivedReportAttributes); - }); - - it('should only recompute reports that reference the changed accountID when a display name changes', () => { - // Two reports: one owned by account 2, the other owned by account 3. - const reports: OnyxCollection = { - [`${ONYXKEYS.COLLECTION.REPORT}ref_report`]: {...mockReport, reportID: 'ref_report', ownerAccountID: 2}, - [`${ONYXKEYS.COLLECTION.REPORT}unrelated_report`]: {...mockReport, reportID: 'unrelated_report', policyID: '456', ownerAccountID: 3}, - }; - const personalDetails = { - '2': {accountID: 2, displayName: 'Alice', login: 'alice@example.com'}, - '3': {accountID: 3, displayName: 'Bob', login: 'bob@example.com'}, - }; - const changedPersonalDetails = {...personalDetails, '2': {accountID: 2, displayName: 'Alice Smith', login: 'alice@example.com'}}; - - // PERSONAL_DETAILS_LIST is a valid runtime source key (hasKeyTriggeredCompute reads it), but the - // sourceValues type only models collection keys, so there's no representable type for this marker. - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- non-collection source keys aren't modeled by DerivedSourceValues - const personalDetailsSource = { - sourceValues: {[ONYXKEYS.PERSONAL_DETAILS_LIST]: true}, - triggeredKeys: new Set([ONYXKEYS.PERSONAL_DETAILS_LIST]), - } as unknown as Parameters[1]; - - // Reset the module-level diff baseline (no sourceValues clears it) so the seed below is deterministic. - reportAttributes.compute( - [ - reports, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - personalDetails, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - ], - {}, - ); - // First personal-details compute computes both reports (no prior currentValue) and seeds the baseline. - const initial = reportAttributes.compute( - [ - reports, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - personalDetails, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - ], - personalDetailsSource, - ); - // Changing only account 2's display name should recompute just its report, carrying the other by reference. - const afterChange = reportAttributes.compute( - [ - reports, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - changedPersonalDetails, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - ], - {...personalDetailsSource, currentValue: initial}, - ); - - // A recomputed report gets a brand-new object; an untouched report is carried over by reference. - expect(afterChange.reports.ref_report).not.toBe(initial.reports.ref_report); - expect(afterChange.reports.unrelated_report).toBe(initial.reports.unrelated_report); - }); - - describe('reportErrors', () => { - it('returns empty errors when no errors exist', async () => { - const report = createRandomReport(1, undefined); - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, report); - await waitForBatchedUpdates(); - - const derivedReportAttributes = await OnyxUtils.get(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES); - expect(derivedReportAttributes?.reports[report.reportID].reportErrors).toEqual({}); - }); - - it('combines report error fields with report action errors', async () => { - const report = { - ...createRandomReport(1, undefined), - errorFields: { - field1: { - '1234567890': 'Error message 1', - }, - }, - }; - - const reportActions: ReportActions = { - '1': { - reportActionID: '1', - actionName: 'ADDCOMMENT', - created: '2024-01-01', - message: [{html: 'some content', text: 'some content', type: 'text'}], - errors: { - field2: { - '1234567891': 'Error message 2', - }, - }, - }, - }; - - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, report); - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, reportActions); - - await waitForBatchedUpdates(); - - const derivedReportAttributes = await OnyxUtils.get(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES); - - await waitForBatchedUpdates(); - - expect(derivedReportAttributes?.reports[report.reportID].reportErrors).toEqual({ - '1234567890': 'Error message 1', - '1234567891': 'Error message 2', - }); - }); - - it('handles multiple error sources', async () => { - const report = { - ...createRandomReport(1, undefined), - errorFields: { - field1: { - '1234567890': 'Error message 1', - }, - field2: { - '1234567891': 'Error message 2', - }, - }, - }; - - const reportActions: ReportActions = { - '1': { - reportActionID: '1', - actionName: 'ADDCOMMENT', - created: '2024-01-01', - message: [{html: 'some content', text: 'some content', type: 'text'}], - errors: { - field3: { - '1234567892': 'Error message 3', - }, - }, - }, - '2': { - reportActionID: '2', - actionName: 'ADDCOMMENT', - created: '2024-01-01', - message: [{html: 'some content', text: 'some content', type: 'text'}], - errors: { - field4: { - '1234567893': 'Error message 4', - }, - }, - }, - }; - - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, report); - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, reportActions); - await waitForBatchedUpdates(); - - const derivedReportAttributes = await OnyxUtils.get(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES); - expect(derivedReportAttributes?.reports[report.reportID].reportErrors).toEqual({ - '1234567890': 'Error message 1', - '1234567891': 'Error message 2', - '1234567892': 'Error message 3', - '1234567893': 'Error message 4', - }); - }); - - it('handles empty error objects in sources', async () => { - const report = { - ...createRandomReport(1, undefined), - errorFields: { - field1: {}, - field2: { - '1234567890': 'Error message 1', - }, - }, - }; - - const reportActions: ReportActions = { - '1': { - reportActionID: '1', - actionName: 'ADDCOMMENT', - created: '2024-01-01', - message: [{html: 'some content', text: 'some content', type: 'text'}], - errors: {}, - }, - '2': { - reportActionID: '2', - actionName: 'ADDCOMMENT', - created: '2024-01-01', - message: [{html: 'some content', text: 'some content', type: 'text'}], - errors: { - field3: { - '1234567891': 'Error message 2', - }, - }, - }, - }; - - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, report); - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, reportActions); - await waitForBatchedUpdates(); - - const derivedReportAttributes = await OnyxUtils.get(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES); - expect(derivedReportAttributes?.reports[report.reportID].reportErrors).toEqual({ - '1234567890': 'Error message 1', - '1234567891': 'Error message 2', - }); - }); - }); - - describe('RBR handling for support tickets', () => { - it('does not show a Fix badge for a support ticket error', async () => { - const supportTicket = { - ...createRandomReport(1, undefined), - type: CONST.REPORT.TYPE.SUPPORT_TICKET, - errorFields: { - createReport: { - '1234567890': 'Support ticket error', - }, - }, - }; - - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${supportTicket.reportID}`, supportTicket); - await waitForBatchedUpdates(); - - const derivedReportAttributes = await OnyxUtils.get(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES); - expect(derivedReportAttributes?.reports[supportTicket.reportID]).toMatchObject({ - reportErrors: {'1234567890': 'Support ticket error'}, - }); - expect(derivedReportAttributes?.reports[supportTicket.reportID].brickRoadStatus).toBeUndefined(); - expect(derivedReportAttributes?.reports[supportTicket.reportID].actionBadge).toBeUndefined(); - }); - }); - - describe('RBR propagation for IOU reports', () => { - it('should correctly propagate and resolve RBR for IOU reports', async () => { - const parentReport = createRandomReport(2, undefined); - const iouReport = { - ...createRandomReport(2, undefined), - chatReportID: parentReport.reportID, - ownerAccountID: 1, - type: CONST.REPORT.TYPE.IOU, - errorFields: { - generic: { - '1234567890': 'Generic error', - }, - }, - }; - - // --- Setup --- - // Set the reports in Onyx. - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${parentReport.reportID}`, parentReport); - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`, iouReport); - await waitForBatchedUpdates(); - - // --- Assertion 1: Propagation Works --- - // The parent report should have an error RBR because the child IOU report has an error. - let derivedReportAttributes = await OnyxUtils.get(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES); - expect(derivedReportAttributes?.reports[parentReport.reportID].brickRoadStatus).toBe(CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR); - - // --- Action: Resolve Error --- - // Remove the error from the IOU report. This will trigger a partial update. - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`, {errorFields: null}); - await waitForBatchedUpdates(); - - // --- Assertion 2: RBR is Cleared --- - // The parent report's RBR should be cleared now that the child's error is gone. - derivedReportAttributes = await OnyxUtils.get(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES); - expect(derivedReportAttributes?.reports[parentReport.reportID].brickRoadStatus).toBeUndefined(); - }); - - it('should not propagate RBR from an IOU report whose preview action in the chat was deleted', async () => { - // Given a chat and an IOU report with an error, whose preview action in the chat was deleted - const parentReport = {...createRandomReport(3, undefined), reportID: '700', type: CONST.REPORT.TYPE.CHAT}; - const previewAction = getFakeReportAction(701, {reportActionID: '701', actionName: CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW, childReportID: '702'}); - const deletedPreviewAction = {...previewAction, message: [{type: 'COMMENT', html: '', text: '', deleted: '2024-01-01 10:00:00'}]}; - const iouReport = { - ...createRandomReport(4, undefined), - reportID: '702', - chatReportID: parentReport.reportID, - parentReportID: parentReport.reportID, - parentReportActionID: previewAction.reportActionID, - ownerAccountID: 1, - type: CONST.REPORT.TYPE.IOU, - errorFields: {generic: {'1234567890': 'Generic error'}}, - }; - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReport.reportID}`, {[deletedPreviewAction.reportActionID]: deletedPreviewAction} as ReportActions); - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${parentReport.reportID}`, parentReport); - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`, iouReport); - await waitForBatchedUpdates(); - - // Then the chat does not show the error, since it has no surface left to fix it from - let derivedReportAttributes = await OnyxUtils.get(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES); - expect(derivedReportAttributes?.reports[parentReport.reportID].brickRoadStatus).toBeUndefined(); - - // When the preview action is restored - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReport.reportID}`, {[previewAction.reportActionID]: previewAction} as ReportActions); - await waitForBatchedUpdates(); - - // Then the chat shows the child's error - derivedReportAttributes = await OnyxUtils.get(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES); - expect(derivedReportAttributes?.reports[parentReport.reportID].brickRoadStatus).toBe(CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR); - }); + // Then the final value reflects both, even if they took separate recomputes + const derived = await OnyxUtils.get(ONYXKEYS.DERIVED.REPORT_TRANSACTIONS_AND_VIOLATIONS); + expect(Object.keys(derived?.r1?.transactions ?? {})).toHaveLength(2); + expect(derived?.r1?.violations?.[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}1`]).toHaveLength(1); }); }); diff --git a/tests/unit/RemoveReportAttributesDerivedValueTest.ts b/tests/unit/RemoveReportAttributesDerivedValueTest.ts new file mode 100644 index 000000000000..eb5669f16492 --- /dev/null +++ b/tests/unit/RemoveReportAttributesDerivedValueTest.ts @@ -0,0 +1,38 @@ +/* eslint-disable @typescript-eslint/no-deprecated -- exercising the migration means writing the old REPORT_ATTRIBUTES value */ +import RemoveReportAttributesDerivedValue from '@libs/migrations/RemoveReportAttributesDerivedValue'; + +import ONYXKEYS from '@src/ONYXKEYS'; + +import Onyx from 'react-native-onyx'; + +import getOnyxValue from '../utils/getOnyxValue'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +describe('RemoveReportAttributesDerivedValue', () => { + beforeAll(() => Onyx.init({keys: ONYXKEYS})); + beforeEach(() => Onyx.clear().then(waitForBatchedUpdates)); + + it('should delete the stored report attributes', async () => { + // Given a device that still holds the value the derived key used to write + await Onyx.set(ONYXKEYS.LEGACY_REPORT_ATTRIBUTES, {reports: {}, locale: 'en'}); + + // When the migration runs at startup + await RemoveReportAttributesDerivedValue(); + await waitForBatchedUpdates(); + + // Then nothing of it is left on the device + expect(await getOnyxValue(ONYXKEYS.LEGACY_REPORT_ATTRIBUTES)).toBeUndefined(); + }); + + it('should write nothing when there is nothing to delete', async () => { + // Given a device that never stored the old value + const multiSetSpy = jest.spyOn(Onyx, 'multiSet'); + + // When the migration runs + await RemoveReportAttributesDerivedValue(); + + // Then it does not write, so startup does no needless storage work + expect(multiSetSpy).not.toHaveBeenCalled(); + multiSetSpy.mockRestore(); + }); +}); diff --git a/tests/unit/ReportAttributesCacheTest.tsx b/tests/unit/ReportAttributesCacheTest.tsx new file mode 100644 index 000000000000..00e50de3a024 --- /dev/null +++ b/tests/unit/ReportAttributesCacheTest.tsx @@ -0,0 +1,618 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +/** + * What the report attributes hold for given Onyx data: names, errors, RBR/GBR and their propagation to parent chats. + */ + +import createReportAttributesComputer from '@libs/computeReportAttributes'; + +import CONST from '@src/CONST'; +import IntlStore from '@src/languages/IntlStore'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Report, ReportActions} from '@src/types/onyx'; + +import type {OnyxCollection} from 'react-native-onyx'; + +import Onyx from 'react-native-onyx'; + +import {createRandomReport} from '../utils/collections/reports'; +import createRandomTransaction from '../utils/collections/transaction'; +import readReportAttributes from '../utils/readReportAttributes'; +import {getFakeReportAction} from '../utils/ReportTestUtils'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); +}); + +beforeEach(async () => { + await Onyx.clear(); +}); + +describe('ReportAttributesCache', () => { + beforeAll(async () => { + await IntlStore.load(CONST.LOCALES.EN); + await waitForBatchedUpdates(); + }); + + beforeEach(async () => { + await Onyx.set(ONYXKEYS.RAM_ONLY_ARE_TRANSLATIONS_LOADING, false); + await waitForBatchedUpdates(); + }); + + const mockReport: Report = { + reportID: `test_1`, + reportName: 'Test Report', + type: 'chat', + chatType: CONST.REPORT.CHAT_TYPE.POLICY_ROOM, + lastVisibleActionCreated: '2023-01-01T00:00:00.000Z', + lastMessageText: 'Test message', + lastActorAccountID: 1, + lastMessageHtml: '

Test message

', + policyID: '123', + ownerAccountID: 1, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + }; + + // A DM has no stored name, so its name is built from the other participant's display name. + const mockDMReport: Report = { + reportID: 'dm_1', + type: CONST.REPORT.TYPE.CHAT, + lastVisibleActionCreated: '2023-01-01T00:00:00.000Z', + lastMessageText: 'Test message', + ownerAccountID: 1, + participants: {'1': {notificationPreference: 'always'}}, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + }; + + it('returns empty reports when dependencies are not set', async () => { + await waitForBatchedUpdates(); + const derivedReportAttributes = await readReportAttributes(); + expect(derivedReportAttributes).toMatchObject({ + reports: {}, + }); + }); + + it('computes report attributes when reports are set', async () => { + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${mockReport.reportID}`, mockReport); + await waitForBatchedUpdates(); + + const derivedReportAttributes = await readReportAttributes(); + + expect(derivedReportAttributes).toMatchObject({ + reports: { + [mockReport.reportID]: { + reportName: mockReport.reportName, + }, + }, + }); + }); + + it('should clear the report attributes when the report is cleared', async () => { + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${mockReport.reportID}`, mockReport); + await waitForBatchedUpdates(); + + let derivedReportAttributes = await readReportAttributes(); + + expect(derivedReportAttributes).toMatchObject({ + reports: { + [mockReport.reportID]: { + reportName: mockReport.reportName, + }, + }, + }); + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${mockReport.reportID}`, null); + + derivedReportAttributes = await readReportAttributes(); + + expect(derivedReportAttributes).toMatchObject({ + reports: {}, + }); + }); + + it('updates when locale changes', async () => { + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${mockReport.reportID}`, mockReport); + await IntlStore.load(CONST.LOCALES.ES); + // Derived recomputes are coalesced onto a microtask; pump it so the locale change is applied. + await waitForBatchedUpdates(); + + const derivedReportAttributes = await readReportAttributes(); + + expect(derivedReportAttributes).toMatchObject({ + locale: 'es', + }); + }); + + it('should contain both report attributes update when there are report and transaction updates', async () => { + // One compute keeps its baselines between calls, like the cache does between writes. + const computeReportAttributes = createReportAttributesComputer(); + await waitForBatchedUpdates(); + // Given 2 reports and 1 transaction + const reportID1 = '0'; + const reportID2 = '1'; + const reports: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.REPORT}${reportID1}`]: createRandomReport(Number(reportID1), undefined), + [`${ONYXKEYS.COLLECTION.REPORT}${reportID2}`]: createRandomReport(Number(reportID2), undefined), + }; + const transaction = createRandomTransaction(1); + + // When the report attributes are recomputed with both report and transaction updates + computeReportAttributes( + [ + reports, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + ], + {}, + ); + const reportAttributesComputedValue = computeReportAttributes( + [ + reports, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + ], + { + sourceValues: { + [ONYXKEYS.COLLECTION.REPORT]: { + [`${ONYXKEYS.COLLECTION.REPORT}${reportID1}`]: reports[`${ONYXKEYS.COLLECTION.REPORT}${reportID1}`], + }, + [ONYXKEYS.COLLECTION.TRANSACTION]: { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`]: transaction, + }, + }, + }, + ).reports; + + // Then the computed report attributes should contain both reports + expect(Object.keys(reportAttributesComputedValue)).toEqual([reportID1, reportID2]); + }); + + it('should not recompute reportAttributes when personalDetailsList changes without displayName change', async () => { + // Set up initial state with report and personalDetailsList + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${mockReport.reportID}`, mockReport); + await Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, { + '1': { + accountID: 1, + displayName: 'John Doe', + login: 'john.doe@example.com', + firstName: 'John', + lastName: 'Doe', + }, + }); + await waitForBatchedUpdates(); + + // Get initial computed value + const initialDerivedReportAttributes = await readReportAttributes(); + + // Spy on generateReportAttributes - this function should NOT be called + // when the optimization kicks in and skips the computation + const generateReportAttributesSpy = jest.spyOn(require('@libs/ReportUtils'), 'generateReportAttributes'); + + // Change only the login (not displayName) - this should trigger the optimization + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, { + '1': { + login: 'john.newemail@example.com', + }, + }); + await waitForBatchedUpdates(); + + // The generateReportAttributes function should not have been called + // because the optimization should have returned early + expect(generateReportAttributesSpy).not.toHaveBeenCalled(); + + // Get the computed value after login change + const derivedReportAttributesAfterLoginChange = await readReportAttributes(); + + // And the values should be preserved correctly + expect(derivedReportAttributesAfterLoginChange).toEqual(initialDerivedReportAttributes); + + generateReportAttributesSpy.mockRestore(); + }); + + it('should recompute reportAttributes when personalDetailsList displayName changes', async () => { + // Given a DM whose name comes from the other participant's display name + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${mockDMReport.reportID}`, mockDMReport); + await Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, { + '1': { + accountID: 1, + displayName: 'John Doe', + login: 'john.doe@example.com', + firstName: 'John', + lastName: 'Doe', + }, + }); + await waitForBatchedUpdates(); + + // Get initial computed value reference + const initialDerivedReportAttributes = await readReportAttributes(); + + // Change the displayName - this should trigger full recomputation + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, { + '1': { + displayName: 'Jane Doe', + firstName: 'Jane', + }, + }); + await waitForBatchedUpdates(); + + // Get the computed value after displayName change + const derivedReportAttributesAfterDisplayNameChange = await readReportAttributes(); + + // Then the DM name follows the new display name + expect(derivedReportAttributesAfterDisplayNameChange).not.toBe(initialDerivedReportAttributes); + expect(derivedReportAttributesAfterDisplayNameChange.reports[mockDMReport.reportID]?.reportName).toContain('Jane'); + expect(initialDerivedReportAttributes.reports[mockDMReport.reportID]?.reportName).toContain('John'); + }); + + it('should only recompute reports that reference the changed accountID when a display name changes', () => { + // One compute keeps its baselines between calls, like the cache does between writes. + const computeReportAttributes = createReportAttributesComputer(); + // Two reports: one owned by account 2, the other owned by account 3. + const reports: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.REPORT}ref_report`]: {...mockDMReport, reportID: 'ref_report', ownerAccountID: 2, participants: {'2': {notificationPreference: 'always'}}}, + [`${ONYXKEYS.COLLECTION.REPORT}unrelated_report`]: {...mockReport, reportID: 'unrelated_report', policyID: '456', ownerAccountID: 3}, + }; + const personalDetails = { + '2': {accountID: 2, displayName: 'Alice', login: 'alice@example.com'}, + '3': {accountID: 3, displayName: 'Bob', login: 'bob@example.com'}, + }; + const changedPersonalDetails = {...personalDetails, '2': {accountID: 2, displayName: 'Alice Smith', login: 'alice@example.com'}}; + + // PERSONAL_DETAILS_LIST is a valid runtime source key (hasKeyTriggeredCompute reads it), but the + // sourceValues type only models collection keys, so there's no representable type for this marker. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- non-collection source keys aren't modeled by DerivedSourceValues + const personalDetailsSource = { + sourceValues: {[ONYXKEYS.PERSONAL_DETAILS_LIST]: true}, + triggeredKeys: new Set([ONYXKEYS.PERSONAL_DETAILS_LIST]), + } as unknown as Parameters>[1]; + + // Reset the module-level diff baseline (no sourceValues clears it) so the seed below is deterministic. + computeReportAttributes( + [ + reports, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + personalDetails, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + ], + {}, + ); + // First personal-details compute computes both reports (no prior currentValue) and seeds the baseline. + const initial = computeReportAttributes( + [ + reports, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + personalDetails, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + ], + personalDetailsSource, + ); + // Changing only account 2's display name should recompute just its report, carrying the other by reference. + const afterChange = computeReportAttributes( + [ + reports, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + changedPersonalDetails, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + ], + {...personalDetailsSource, currentValue: initial}, + ); + + // A recomputed report whose name changed gets a new object; an untouched report is carried over by reference. + expect(afterChange.reports.ref_report).not.toBe(initial.reports.ref_report); + expect(afterChange.reports.ref_report?.reportName).toContain('Alice Smith'); + expect(afterChange.reports.unrelated_report).toBe(initial.reports.unrelated_report); + }); + + describe('reportErrors', () => { + it('returns empty errors when no errors exist', async () => { + const report = createRandomReport(1, undefined); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, report); + await waitForBatchedUpdates(); + + const derivedReportAttributes = await readReportAttributes(); + expect(derivedReportAttributes?.reports[report.reportID].reportErrors).toEqual({}); + }); + + it('combines report error fields with report action errors', async () => { + const report = { + ...createRandomReport(1, undefined), + errorFields: { + field1: { + '1234567890': 'Error message 1', + }, + }, + }; + + const reportActions: ReportActions = { + '1': { + reportActionID: '1', + actionName: 'ADDCOMMENT', + created: '2024-01-01', + message: [{html: 'some content', text: 'some content', type: 'text'}], + errors: { + field2: { + '1234567891': 'Error message 2', + }, + }, + }, + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, report); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, reportActions); + + await waitForBatchedUpdates(); + + const derivedReportAttributes = await readReportAttributes(); + + await waitForBatchedUpdates(); + + expect(derivedReportAttributes?.reports[report.reportID].reportErrors).toEqual({ + '1234567890': 'Error message 1', + '1234567891': 'Error message 2', + }); + }); + + it('handles multiple error sources', async () => { + const report = { + ...createRandomReport(1, undefined), + errorFields: { + field1: { + '1234567890': 'Error message 1', + }, + field2: { + '1234567891': 'Error message 2', + }, + }, + }; + + const reportActions: ReportActions = { + '1': { + reportActionID: '1', + actionName: 'ADDCOMMENT', + created: '2024-01-01', + message: [{html: 'some content', text: 'some content', type: 'text'}], + errors: { + field3: { + '1234567892': 'Error message 3', + }, + }, + }, + '2': { + reportActionID: '2', + actionName: 'ADDCOMMENT', + created: '2024-01-01', + message: [{html: 'some content', text: 'some content', type: 'text'}], + errors: { + field4: { + '1234567893': 'Error message 4', + }, + }, + }, + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, report); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, reportActions); + await waitForBatchedUpdates(); + + const derivedReportAttributes = await readReportAttributes(); + expect(derivedReportAttributes?.reports[report.reportID].reportErrors).toEqual({ + '1234567890': 'Error message 1', + '1234567891': 'Error message 2', + '1234567892': 'Error message 3', + '1234567893': 'Error message 4', + }); + }); + + it('handles empty error objects in sources', async () => { + const report = { + ...createRandomReport(1, undefined), + errorFields: { + field1: {}, + field2: { + '1234567890': 'Error message 1', + }, + }, + }; + + const reportActions: ReportActions = { + '1': { + reportActionID: '1', + actionName: 'ADDCOMMENT', + created: '2024-01-01', + message: [{html: 'some content', text: 'some content', type: 'text'}], + errors: {}, + }, + '2': { + reportActionID: '2', + actionName: 'ADDCOMMENT', + created: '2024-01-01', + message: [{html: 'some content', text: 'some content', type: 'text'}], + errors: { + field3: { + '1234567891': 'Error message 2', + }, + }, + }, + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, report); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, reportActions); + await waitForBatchedUpdates(); + + const derivedReportAttributes = await readReportAttributes(); + expect(derivedReportAttributes?.reports[report.reportID].reportErrors).toEqual({ + '1234567890': 'Error message 1', + '1234567891': 'Error message 2', + }); + }); + }); + + describe('RBR handling for support tickets', () => { + it('does not show a Fix badge for a support ticket error', async () => { + const supportTicket = { + ...createRandomReport(1, undefined), + type: CONST.REPORT.TYPE.SUPPORT_TICKET, + errorFields: { + createReport: { + '1234567890': 'Support ticket error', + }, + }, + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${supportTicket.reportID}`, supportTicket); + await waitForBatchedUpdates(); + + const derivedReportAttributes = await readReportAttributes(); + expect(derivedReportAttributes?.reports[supportTicket.reportID]).toMatchObject({ + reportErrors: {'1234567890': 'Support ticket error'}, + }); + expect(derivedReportAttributes?.reports[supportTicket.reportID].brickRoadStatus).toBeUndefined(); + expect(derivedReportAttributes?.reports[supportTicket.reportID].actionBadge).toBeUndefined(); + }); + }); + + describe('RBR propagation for IOU reports', () => { + it('should correctly propagate and resolve RBR for IOU reports', async () => { + const parentReport = createRandomReport(2, undefined); + const iouReport = { + ...createRandomReport(2, undefined), + chatReportID: parentReport.reportID, + ownerAccountID: 1, + type: CONST.REPORT.TYPE.IOU, + errorFields: { + generic: { + '1234567890': 'Generic error', + }, + }, + }; + + // --- Setup --- + // Set the reports in Onyx. + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${parentReport.reportID}`, parentReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`, iouReport); + await waitForBatchedUpdates(); + + // --- Assertion 1: Propagation Works --- + // The parent report should have an error RBR because the child IOU report has an error. + let derivedReportAttributes = await readReportAttributes(); + expect(derivedReportAttributes?.reports[parentReport.reportID].brickRoadStatus).toBe(CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR); + + // --- Action: Resolve Error --- + // Remove the error from the IOU report. This will trigger a partial update. + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`, {errorFields: null}); + await waitForBatchedUpdates(); + + // --- Assertion 2: RBR is Cleared --- + // The parent report's RBR should be cleared now that the child's error is gone. + derivedReportAttributes = await readReportAttributes(); + expect(derivedReportAttributes?.reports[parentReport.reportID].brickRoadStatus).toBeUndefined(); + }); + + it('should not propagate RBR from an IOU report whose preview action in the chat was deleted', async () => { + // Given a chat and an IOU report with an error, whose preview action in the chat was deleted + const parentReport = {...createRandomReport(3, undefined), reportID: '700', type: CONST.REPORT.TYPE.CHAT}; + const previewAction = getFakeReportAction(701, {reportActionID: '701', actionName: CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW, childReportID: '702'}); + const deletedPreviewAction = {...previewAction, message: [{type: 'COMMENT', html: '', text: '', deleted: '2024-01-01 10:00:00'}]}; + const iouReport = { + ...createRandomReport(4, undefined), + reportID: '702', + chatReportID: parentReport.reportID, + parentReportID: parentReport.reportID, + parentReportActionID: previewAction.reportActionID, + ownerAccountID: 1, + type: CONST.REPORT.TYPE.IOU, + errorFields: {generic: {'1234567890': 'Generic error'}}, + }; + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReport.reportID}`, {[deletedPreviewAction.reportActionID]: deletedPreviewAction}); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${parentReport.reportID}`, parentReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`, iouReport); + await waitForBatchedUpdates(); + + // Then the chat does not show the error, since it has no surface left to fix it from + let derivedReportAttributes = await readReportAttributes(); + expect(derivedReportAttributes?.reports[parentReport.reportID].brickRoadStatus).toBeUndefined(); + + // When the preview action is restored + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReport.reportID}`, {[previewAction.reportActionID]: previewAction}); + await waitForBatchedUpdates(); + + // Then the chat shows the child's error + derivedReportAttributes = await readReportAttributes(); + expect(derivedReportAttributes?.reports[parentReport.reportID].brickRoadStatus).toBe(CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR); + }); + }); +}); diff --git a/tests/unit/ReportAttributesSelectorTest.ts b/tests/unit/ReportAttributesSelectorTest.ts deleted file mode 100644 index 74fcbb517840..000000000000 --- a/tests/unit/ReportAttributesSelectorTest.ts +++ /dev/null @@ -1,78 +0,0 @@ -import type {ReportAttributes, ReportAttributesDerivedValue} from '@src/types/onyx/DerivedValues'; - -import type {OnyxEntry} from 'react-native-onyx'; - -import reportByIDsSelector, {reportNameSelector} from '@selectors/ReportAttributes'; - -describe('ReportAttributesSelector', () => { - describe('reportByIDsSelector', () => { - const reportID = '1'; - const reportAttributes: ReportAttributes = { - reportName: 'Test Report', - isEmpty: false, - brickRoadStatus: undefined, - requiresAttention: false, - reportErrors: {}, - }; - - const attributes: OnyxEntry = { - reports: { - [reportID]: reportAttributes, - }, - locale: 'en', - }; - - it('should return report attributes for matching report IDs', () => { - const result = reportByIDsSelector([reportID])(attributes); - expect(result).toMatchObject({[reportID]: {reportName: 'Test Report'}}); - }); - - it('should return empty object when attributes is undefined', () => { - const result = reportByIDsSelector([reportID])(undefined); - expect(result).toEqual({}); - }); - - it('should skip report IDs that do not exist in attributes', () => { - const result = reportByIDsSelector([reportID, '999'])(attributes); - expect(result).toMatchObject({[reportID]: {reportName: 'Test Report'}}); - }); - }); - - describe('reportNameSelector', () => { - const reportID = '1'; - const reportAttributes: ReportAttributes = { - reportName: 'Test Report', - isEmpty: false, - brickRoadStatus: undefined, - requiresAttention: false, - reportErrors: {}, - }; - - const attributes: OnyxEntry = { - reports: { - [reportID]: reportAttributes, - }, - locale: 'en', - }; - - it('should return the report name for the matching report ID', () => { - const result = reportNameSelector(attributes, reportID); - expect(result).toBe('Test Report'); - }); - - it('should return undefined when the report ID is undefined', () => { - const result = reportNameSelector(attributes, undefined); - expect(result).toBeUndefined(); - }); - - it('should return undefined when the report ID does not exist in attributes', () => { - const result = reportNameSelector(attributes, '999'); - expect(result).toBeUndefined(); - }); - - it('should return undefined when attributes is undefined', () => { - const result = reportNameSelector(undefined, reportID); - expect(result).toBeUndefined(); - }); - }); -}); diff --git a/tests/unit/Search/useSearchSnapshotTest.ts b/tests/unit/Search/useSearchSnapshotTest.ts index 35532a1c065a..7dd6eb82f80f 100644 --- a/tests/unit/Search/useSearchSnapshotTest.ts +++ b/tests/unit/Search/useSearchSnapshotTest.ts @@ -48,8 +48,7 @@ jest.mock('@hooks/useActionLoadingReportIDs', () => ({ default: () => mockActionLoadingSet, })); jest.mock('@hooks/useReportAttributes', () => ({ - __esModule: true, - default: () => undefined, + useAllReportAttributes: () => ({}), })); jest.mock('@hooks/usePolicyForMovingExpenses', () => ({ __esModule: true, diff --git a/tests/unit/SearchAutocompleteListTest.tsx b/tests/unit/SearchAutocompleteListTest.tsx index b068422ede12..1a60e505e7d5 100644 --- a/tests/unit/SearchAutocompleteListTest.tsx +++ b/tests/unit/SearchAutocompleteListTest.tsx @@ -33,11 +33,11 @@ import type ReactNative from 'react-native'; import React from 'react'; import {StyleSheet} from 'react-native'; import Onyx from 'react-native-onyx'; -import OnyxUtils from 'react-native-onyx/dist/OnyxUtils'; import createCollection from '../utils/collections/createCollection'; import createPersonalDetails from '../utils/collections/personalDetails'; import {createRandomReport} from '../utils/collections/reports'; +import readReportAttributes from '../utils/readReportAttributes'; import * as TestHelper from '../utils/TestHelper'; import {formatPhoneNumber} from '../utils/TestHelper'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; @@ -474,7 +474,7 @@ describe('SearchAutocompleteList', () => { await flushAllUpdates(); // The row label is the derived report name, so the options have to be built from the derived attributes - const reportAttributes = await OnyxUtils.get(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES); + const reportAttributes = await readReportAttributes(); expect(reportAttributes?.reports?.[threadReportID]?.reportName).toBeTruthy(); mockUseFilteredOptions.mockReturnValue({ options: createFilteredOptionList( @@ -570,7 +570,7 @@ describe('SearchAutocompleteList', () => { await Onyx.set(ONYXKEYS.SESSION, {accountID: CURRENT_USER_ACCOUNT_ID, email: 'test@test.com'}); await flushAllUpdates(); - const reportAttributes = await OnyxUtils.get(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES); + const reportAttributes = await readReportAttributes(); expect(reportAttributes?.reports?.[threadReportID]?.reportName).toBeTruthy(); mockUseFilteredOptions.mockReturnValue({ options: createFilteredOptionList( diff --git a/tests/unit/WorkspacesSettingsUtilsTest.ts b/tests/unit/WorkspacesSettingsUtilsTest.ts index 9a554fa328d4..9a6d6625d874 100644 --- a/tests/unit/WorkspacesSettingsUtilsTest.ts +++ b/tests/unit/WorkspacesSettingsUtilsTest.ts @@ -13,9 +13,9 @@ import type {ReportCollectionDataSet} from '@src/types/onyx/Report'; import type {OnyxCollection} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; -import OnyxUtils from 'react-native-onyx/dist/OnyxUtils'; import createMock from '../utils/createMock'; +import readReportAttributes from '../utils/readReportAttributes'; import * as TestHelper from '../utils/TestHelper'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; import mockData from './WorkspacesSettingsUtilsTest.json'; @@ -92,7 +92,7 @@ describe('WorkspacesSettingsUtils', () => { }); await waitForBatchedUpdates(); - const reportAttributes = await OnyxUtils.get(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES); + const reportAttributes = await readReportAttributes(); // eslint-disable-next-line rulesdir/no-default-id-values const result = getBrickRoadForPolicy(report?.reportID ?? '', reportAttributes?.reports); @@ -112,7 +112,7 @@ describe('WorkspacesSettingsUtils', () => { }); await waitForBatchedUpdates(); - const reportAttributes = await OnyxUtils.get(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES); + const reportAttributes = await readReportAttributes(); // eslint-disable-next-line rulesdir/no-default-id-values const result = getBrickRoadForPolicy(report?.reportID ?? '', reportAttributes?.reports); @@ -138,7 +138,7 @@ describe('WorkspacesSettingsUtils', () => { const reportIDs = Object.values(reports).map((report) => report.reportID); await waitForBatchedUpdates(); - const reportAttributes = await OnyxUtils.get(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES); + const reportAttributes = await readReportAttributes(); const result = getChatTabBrickRoadReportID(reportIDs, reportAttributes?.reports); @@ -158,7 +158,7 @@ describe('WorkspacesSettingsUtils', () => { const reportIDs = Object.values(reports).map((report) => report.reportID); await waitForBatchedUpdates(); - const reportAttributes = await OnyxUtils.get(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES); + const reportAttributes = await readReportAttributes(); const result = getChatTabBrickRoadReportID(reportIDs, reportAttributes?.reports); @@ -183,7 +183,7 @@ describe('WorkspacesSettingsUtils', () => { const reportIDs = Object.values(reports).map((report) => report.reportID); await waitForBatchedUpdates(); - const reportAttributes = await OnyxUtils.get(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES); + const reportAttributes = await readReportAttributes(); const result = getChatTabBrickRoad(reportIDs, reportAttributes?.reports); @@ -203,7 +203,7 @@ describe('WorkspacesSettingsUtils', () => { const reportIDs = Object.values(reports).map((report) => report.reportID); await waitForBatchedUpdates(); - const reportAttributes = await OnyxUtils.get(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES); + const reportAttributes = await readReportAttributes(); const result = getChatTabBrickRoad(reportIDs, reportAttributes?.reports); diff --git a/tests/unit/detectOnyxDerivedLoopTest.ts b/tests/unit/detectOnyxDerivedLoopTest.ts index ecf3f3211eed..3147117d95dd 100644 --- a/tests/unit/detectOnyxDerivedLoopTest.ts +++ b/tests/unit/detectOnyxDerivedLoopTest.ts @@ -10,7 +10,7 @@ jest.mock('@sentry/react-native', () => ({captureMessage: jest.fn()})); const captureMessage = jest.mocked(Sentry.captureMessage); -const DERIVED_KEY = ONYXKEYS.DERIVED.REPORT_ATTRIBUTES; +const DERIVED_KEY = ONYXKEYS.DERIVED.REPORT_TRANSACTIONS_AND_VIOLATIONS; function recompute(times: number, triggeredKey: OnyxKey = ONYXKEYS.COLLECTION.REPORT_ACTIONS) { for (let i = 0; i < times; i++) { diff --git a/tests/unit/pages/DynamicReportDetailsPage/useReportDetailsReportNameTest.ts b/tests/unit/pages/DynamicReportDetailsPage/useReportDetailsReportNameTest.ts index 7737993501e2..c914db468c7b 100644 --- a/tests/unit/pages/DynamicReportDetailsPage/useReportDetailsReportNameTest.ts +++ b/tests/unit/pages/DynamicReportDetailsPage/useReportDetailsReportNameTest.ts @@ -3,8 +3,9 @@ import {act, renderHook} from '@testing-library/react-native'; import useReportDetailsReportName from '@pages/DynamicReportDetailsPage/useReportDetailsReportName'; import CONST from '@src/CONST'; +import IntlStore from '@src/languages/IntlStore'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Report, ReportAction, ReportAttributesDerivedValue} from '@src/types/onyx'; +import type {Report, ReportAction} from '@src/types/onyx'; import Onyx from 'react-native-onyx'; @@ -30,22 +31,25 @@ function buildParentReportAction(actionName: ReportAction['actionName']): Report } as ReportAction; } +// A policy room is named after its stored reportName, so writing rooms gives each report the computed name we want. async function setDerivedReportNames(namesByReportID: Record) { - const reports: ReportAttributesDerivedValue['reports'] = {}; + const rooms: Record<`${typeof ONYXKEYS.COLLECTION.REPORT}${string}`, Report> = {}; for (const [reportID, reportName] of Object.entries(namesByReportID)) { - reports[reportID] = {reportName, isEmpty: false, brickRoadStatus: undefined, requiresAttention: false, reportErrors: {}}; + rooms[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`] = {reportID, reportName, type: CONST.REPORT.TYPE.CHAT, chatType: CONST.REPORT.CHAT_TYPE.POLICY_ROOM}; } - await Onyx.set(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, {reports, locale: 'en'}); + await Onyx.multiSet(rooms); await waitForBatchedUpdates(); } describe('useReportDetailsReportName', () => { - beforeAll(() => { + beforeAll(async () => { Onyx.init({keys: ONYXKEYS}); + await IntlStore.load(CONST.LOCALES.EN); }); beforeEach(async () => { await Onyx.clear(); + await Onyx.set(ONYXKEYS.RAM_ONLY_ARE_TRANSLATIONS_LOADING, false); await waitForBatchedUpdates(); }); @@ -64,7 +68,7 @@ describe('useReportDetailsReportName', () => { }); it('should fall back to the stored report name when no derived name exists', async () => { - // Given the derived value has no entry for the report + // Given no computed name exists for the report await setDerivedReportNames({}); const report = buildReport(); @@ -143,12 +147,12 @@ describe('useReportDetailsReportName', () => { const {result} = renderHook(() => useReportDetailsReportName(report, undefined, undefined)); expect(result.current.reportName).toBe('Old name'); - // When the derived value recomputes with a new name + // When the report is renamed await act(async () => { await setDerivedReportNames({[REPORT_ID]: 'New name'}); }); - // Then the hook returns the new name, because it owns the REPORT_ATTRIBUTES subscription + // Then the hook returns the new name, because it owns the report name subscription expect(result.current.reportName).toBe('New name'); }); }); diff --git a/tests/unit/reportAttributesTest.ts b/tests/unit/reportAttributesTest.ts index fbddba0a7351..df2242e1ced4 100644 --- a/tests/unit/reportAttributesTest.ts +++ b/tests/unit/reportAttributesTest.ts @@ -1,6 +1,7 @@ import type ReportNameUtils = require('@libs/ReportNameUtils'); -import type reportAttributesModuleDefault from '@userActions/OnyxDerived/configs/reportAttributes'; -import {getOldestPreviewActionID, hasPolicyRelevantFieldChanged} from '@userActions/OnyxDerived/configs/reportAttributes'; +import type ComputeReportAttributesModule = require('@libs/computeReportAttributes'); +import {hasPolicyRelevantFieldChanged} from '@libs/computeReportAttributes'; +import {getOldestPreviewActionID} from '@libs/ReportAttributesComputeUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -14,7 +15,10 @@ import {createRandomReport} from '../utils/collections/reports'; import createRandomTransaction from '../utils/collections/transaction'; import createMock from '../utils/createMock'; -type ReportAttributesConfig = typeof reportAttributesModuleDefault; +type ReportAttributesCompute = ReturnType; + +/** One compute with its own baselines; `reset` swaps in a new one, which is how callers drop the baselines on Onyx clear. */ +type ReportAttributesComputer = {compute: ReportAttributesCompute; reset: () => void}; jest.mock('@libs/ReportUtils', () => ({ generateReportAttributes: jest.fn(() => ({ @@ -235,7 +239,7 @@ describe('getOldestPreviewActionID', () => { }); describe('reportAttributes compute — policy change code flow', () => { - let config: ReportAttributesConfig; + let computer: ReportAttributesComputer; const report1 = createMock({ reportID: 'r1', @@ -267,11 +271,18 @@ describe('reportAttributes compute — policy change code flow', () => { beforeEach(() => { jest.resetModules(); - config = jest.requireActual<{default: ReportAttributesConfig}>('@userActions/OnyxDerived/configs/reportAttributes').default; + const {default: createReportAttributesComputer} = jest.requireActual('@libs/computeReportAttributes'); + let compute = createReportAttributesComputer(); + computer = { + compute: (dependencyValues, context) => compute(dependencyValues, context), + reset: () => { + compute = createReportAttributesComputer(); + }, + }; }); const buildArgs = (overridePolicies?: OnyxCollection, overrideReports?: OnyxCollection, transactionsUpdate?: OnyxCollection) => { - const args: Parameters[0] = [ + const args: Parameters[0] = [ overrideReports ?? reports, // reports undefined, // preferredLocale undefined, // countryCodeByIP @@ -295,7 +306,7 @@ describe('reportAttributes compute — policy change code flow', () => { }; it('computes every report on a cold start (no currentValue) when policies load', () => { - const result = config.compute(buildArgs(), { + const result = computer.compute(buildArgs(), { currentValue: undefined, sourceValues: {[ONYXKEYS.COLLECTION.POLICY]: policies}, triggeredKeys: new Set([ONYXKEYS.COLLECTION.POLICY]), @@ -323,7 +334,7 @@ describe('reportAttributes compute — policy change code flow', () => { locale: null, }; - const result = config.compute(buildArgs(policies, reportsWithUnrelated), { + const result = computer.compute(buildArgs(policies, reportsWithUnrelated), { currentValue: existingValue, sourceValues: {[ONYXKEYS.COLLECTION.POLICY]: policies}, triggeredKeys: new Set([ONYXKEYS.COLLECTION.POLICY]), @@ -358,7 +369,7 @@ describe('reportAttributes compute — policy change code flow', () => { }; // Seed previousPolicies with just the sender policy, as if it arrived in an earlier batch. - config.compute(buildArgs({[`${ONYXKEYS.COLLECTION.POLICY}senderPolicy`]: senderPolicy}, invoiceReports), { + computer.compute(buildArgs({[`${ONYXKEYS.COLLECTION.POLICY}senderPolicy`]: senderPolicy}, invoiceReports), { currentValue: undefined, sourceValues: {[ONYXKEYS.COLLECTION.POLICY]: {[`${ONYXKEYS.COLLECTION.POLICY}senderPolicy`]: senderPolicy}}, triggeredKeys: new Set([ONYXKEYS.COLLECTION.POLICY]), @@ -377,7 +388,7 @@ describe('reportAttributes compute — policy change code flow', () => { [`${ONYXKEYS.COLLECTION.POLICY}senderPolicy`]: senderPolicy, [`${ONYXKEYS.COLLECTION.POLICY}receiverPolicy`]: receiverPolicy, }; - const result = config.compute(buildArgs(bothPolicies, invoiceReports), { + const result = computer.compute(buildArgs(bothPolicies, invoiceReports), { currentValue: existingValue, sourceValues: {[ONYXKEYS.COLLECTION.POLICY]: {[`${ONYXKEYS.COLLECTION.POLICY}receiverPolicy`]: receiverPolicy}}, triggeredKeys: new Set([ONYXKEYS.COLLECTION.POLICY]), @@ -390,7 +401,7 @@ describe('reportAttributes compute — policy change code flow', () => { it('keeps report names cached when only a policy badge field changes (no name recompute)', () => { // Seed previousPolicies by doing an initial compute - config.compute(buildArgs(), { + computer.compute(buildArgs(), { currentValue: undefined, sourceValues: {[ONYXKEYS.COLLECTION.POLICY]: policies}, triggeredKeys: new Set([ONYXKEYS.COLLECTION.POLICY]), @@ -414,7 +425,7 @@ describe('reportAttributes compute — policy change code flow', () => { const computeReportNameMock = jest.mocked(jest.requireMock('@libs/ReportNameUtils').computeReportName); computeReportNameMock.mockClear(); - const result = config.compute(buildArgs(updatedPolicies), { + const result = computer.compute(buildArgs(updatedPolicies), { currentValue: existingValue, sourceValues: {[ONYXKEYS.COLLECTION.POLICY]: {[`${ONYXKEYS.COLLECTION.POLICY}policy1`]: policy1Changed}}, triggeredKeys: new Set([ONYXKEYS.COLLECTION.POLICY]), @@ -430,7 +441,7 @@ describe('reportAttributes compute — policy change code flow', () => { it('recomputes the name when a policy name changes', () => { // Seed previousPolicies by doing an initial compute - config.compute(buildArgs(), { + computer.compute(buildArgs(), { currentValue: undefined, sourceValues: {[ONYXKEYS.COLLECTION.POLICY]: policies}, triggeredKeys: new Set([ONYXKEYS.COLLECTION.POLICY]), @@ -454,7 +465,7 @@ describe('reportAttributes compute — policy change code flow', () => { const computeReportNameMock = jest.mocked(jest.requireMock('@libs/ReportNameUtils').computeReportName); computeReportNameMock.mockReturnValue('New Name'); - const result = config.compute(buildArgs(updatedPolicies), { + const result = computer.compute(buildArgs(updatedPolicies), { currentValue: existingValue, sourceValues: {[ONYXKEYS.COLLECTION.POLICY]: {[`${ONYXKEYS.COLLECTION.POLICY}policy1`]: policy1Changed}}, triggeredKeys: new Set([ONYXKEYS.COLLECTION.POLICY]), @@ -483,7 +494,7 @@ describe('reportAttributes compute — policy change code flow', () => { const reportsWithThread: OnyxCollection = {...reports, [`${ONYXKEYS.COLLECTION.REPORT}thread1`]: thread}; // Seed previousPolicies by doing an initial compute - config.compute(buildArgs(undefined, reportsWithThread), { + computer.compute(buildArgs(undefined, reportsWithThread), { currentValue: undefined, sourceValues: {[ONYXKEYS.COLLECTION.POLICY]: policies}, triggeredKeys: new Set([ONYXKEYS.COLLECTION.POLICY]), @@ -504,7 +515,7 @@ describe('reportAttributes compute — policy change code flow', () => { locale: null, }; - const result = config.compute(buildArgs(updatedPolicies, reportsWithThread), { + const result = computer.compute(buildArgs(updatedPolicies, reportsWithThread), { currentValue: existingValue, sourceValues: {[ONYXKEYS.COLLECTION.POLICY]: {[`${ONYXKEYS.COLLECTION.POLICY}policy1`]: policy1Changed}}, triggeredKeys: new Set([ONYXKEYS.COLLECTION.POLICY]), @@ -533,7 +544,7 @@ describe('reportAttributes compute — policy change code flow', () => { const reportsWithExpense: OnyxCollection = {...reports, [`${ONYXKEYS.COLLECTION.REPORT}exp1`]: emptyNameExpense}; // Seed previousPolicies by doing an initial compute (basePolicy has no fieldList → empty). - config.compute(buildArgs(undefined, reportsWithExpense), { + computer.compute(buildArgs(undefined, reportsWithExpense), { currentValue: undefined, sourceValues: {[ONYXKEYS.COLLECTION.POLICY]: policies}, triggeredKeys: new Set([ONYXKEYS.COLLECTION.POLICY]), @@ -555,7 +566,7 @@ describe('reportAttributes compute — policy change code flow', () => { locale: null, }; - const result = config.compute(buildArgs(updatedPolicies, reportsWithExpense), { + const result = computer.compute(buildArgs(updatedPolicies, reportsWithExpense), { currentValue: existingValue, sourceValues: {[ONYXKEYS.COLLECTION.POLICY]: {[`${ONYXKEYS.COLLECTION.POLICY}policy1`]: policy1Changed}}, triggeredKeys: new Set([ONYXKEYS.COLLECTION.POLICY]), @@ -591,7 +602,7 @@ describe('reportAttributes compute — policy change code flow', () => { }; // Seed previousPolicies with both policies. - config.compute(buildArgs(bothPolicies, invoiceReports), { + computer.compute(buildArgs(bothPolicies, invoiceReports), { currentValue: undefined, sourceValues: {[ONYXKEYS.COLLECTION.POLICY]: bothPolicies}, triggeredKeys: new Set([ONYXKEYS.COLLECTION.POLICY]), @@ -606,7 +617,7 @@ describe('reportAttributes compute — policy change code flow', () => { // Receiver policy reimbursementChoice changes — a tracked badge field that no name reads. const receiverPolicyChanged: Policy = {...receiverPolicy, reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO}; - const result = config.compute(buildArgs({...bothPolicies, [`${ONYXKEYS.COLLECTION.POLICY}receiverPolicy`]: receiverPolicyChanged}, invoiceReports), { + const result = computer.compute(buildArgs({...bothPolicies, [`${ONYXKEYS.COLLECTION.POLICY}receiverPolicy`]: receiverPolicyChanged}, invoiceReports), { currentValue: existingValue, sourceValues: {[ONYXKEYS.COLLECTION.POLICY]: {[`${ONYXKEYS.COLLECTION.POLICY}receiverPolicy`]: receiverPolicyChanged}}, triggeredKeys: new Set([ONYXKEYS.COLLECTION.POLICY]), @@ -623,7 +634,7 @@ describe('reportAttributes compute — policy change code flow', () => { it('recomputes the name when a badge-only policy change coalesces with a transaction update on the same report', () => { // Seed previousPolicies. - config.compute(buildArgs(), { + computer.compute(buildArgs(), { currentValue: undefined, sourceValues: {[ONYXKEYS.COLLECTION.POLICY]: policies}, triggeredKeys: new Set([ONYXKEYS.COLLECTION.POLICY]), @@ -647,7 +658,7 @@ describe('reportAttributes compute — policy change code flow', () => { }; // One coalesced flush: policy1 badge change + a transaction update whose report is also r1. - const result = config.compute(buildArgs(updatedPolicies, undefined, transactionsUpdate), { + const result = computer.compute(buildArgs(updatedPolicies, undefined, transactionsUpdate), { currentValue: existingValue, sourceValues: { [ONYXKEYS.COLLECTION.POLICY]: {[`${ONYXKEYS.COLLECTION.POLICY}policy1`]: policy1Changed}, @@ -664,7 +675,7 @@ describe('reportAttributes compute — policy change code flow', () => { it('skips recompute when a non-tracked policy field changes', () => { // Seed previousPolicies - config.compute(buildArgs(), { + computer.compute(buildArgs(), { currentValue: undefined, sourceValues: {[ONYXKEYS.COLLECTION.POLICY]: policies}, triggeredKeys: new Set([ONYXKEYS.COLLECTION.POLICY]), @@ -684,7 +695,7 @@ describe('reportAttributes compute — policy change code flow', () => { locale: null, }; - const result = config.compute(buildArgs(updatedPolicies), { + const result = computer.compute(buildArgs(updatedPolicies), { currentValue: existingValue, sourceValues: {[ONYXKEYS.COLLECTION.POLICY]: {[`${ONYXKEYS.COLLECTION.POLICY}policy1`]: policy1WithUntrackedChange}}, triggeredKeys: new Set([ONYXKEYS.COLLECTION.POLICY]), @@ -694,16 +705,16 @@ describe('reportAttributes compute — policy change code flow', () => { expect(result).toEqual(existingValue); }); - it('onReset drops the policy baseline so the next compute recomputes referencing reports (cache-clear lifecycle)', () => { + it('a reset drops the policy baseline so the next compute recomputes referencing reports (cache-clear lifecycle)', () => { // Seed previousPolicies so an unchanged policy would normally narrow to nothing. - config.compute(buildArgs(), { + computer.compute(buildArgs(), { currentValue: undefined, sourceValues: {[ONYXKEYS.COLLECTION.POLICY]: policies}, triggeredKeys: new Set([ONYXKEYS.COLLECTION.POLICY]), }); - // Simulate the engine's clear reset. - config.onReset?.(); + // Simulate the reset that runs on Onyx clear. + computer.reset(); const existingValue: ReportAttributesDerivedValue = { reports: { @@ -713,10 +724,10 @@ describe('reportAttributes compute — policy change code flow', () => { locale: null, }; - // Same (unchanged) policies as the seed. Without onReset, previousPolicies would match → no recompute → - // stale names kept. After onReset the baseline is gone, so hasPolicyRelevantFieldChanged(undefined, policy) + // Same (unchanged) policies as the seed. Without the reset, previousPolicies would match → no recompute → + // stale names kept. After the reset the baseline is gone, so hasPolicyRelevantFieldChanged(undefined, policy) // is true and the referencing reports recompute. - const result = config.compute(buildArgs(), { + const result = computer.compute(buildArgs(), { currentValue: existingValue, sourceValues: {[ONYXKEYS.COLLECTION.POLICY]: policies}, triggeredKeys: new Set([ONYXKEYS.COLLECTION.POLICY]), @@ -739,7 +750,7 @@ describe('reportAttributes compute — policy change code flow', () => { }; // A non-POLICY compute (e.g. reports loading) seeds previousPolicies from the disk-restored policies. - config.compute(buildArgs(), { + computer.compute(buildArgs(), { currentValue: existingValue, sourceValues: {[ONYXKEYS.COLLECTION.REPORT]: reports}, triggeredKeys: new Set([ONYXKEYS.COLLECTION.REPORT]), @@ -751,7 +762,7 @@ describe('reportAttributes compute — policy change code flow', () => { [`${ONYXKEYS.COLLECTION.POLICY}policy2`]: {...policy2}, }; - const result = config.compute(buildArgs(samePoliciesNewRefs), { + const result = computer.compute(buildArgs(samePoliciesNewRefs), { currentValue: existingValue, sourceValues: {[ONYXKEYS.COLLECTION.POLICY]: samePoliciesNewRefs}, triggeredKeys: new Set([ONYXKEYS.COLLECTION.POLICY]), @@ -784,7 +795,7 @@ describe('reportAttributes compute — policy change code flow', () => { }; const args = buildArgs(undefined, reportsWithChat, transactionsUpdate); - const result = config.compute(args, { + const result = computer.compute(args, { currentValue: existingValue, sourceValues: {[ONYXKEYS.COLLECTION.TRANSACTION]: transactionsUpdate}, }); @@ -806,7 +817,7 @@ describe('reportAttributes compute — policy change code flow', () => { const args = buildArgs(policies, singleReport); args[13] = {[`${ONYXKEYS.COLLECTION.REPORT_METADATA}r1`]: reportMetadata}; - config.compute(args, { + computer.compute(args, { currentValue: undefined, sourceValues: {[ONYXKEYS.COLLECTION.POLICY]: policies}, triggeredKeys: new Set([ONYXKEYS.COLLECTION.POLICY]), @@ -870,14 +881,14 @@ describe('reportAttributes compute — policy change code flow', () => { const liveFraudCardList: CardList = {[CARD_ID]: cardWithFraud}; const initialArgs = buildArgs(policies, cardReports); initialArgs[16] = liveFraudCardList; - config.compute(initialArgs, {currentValue: undefined, sourceValues: undefined}); + computer.compute(initialArgs, {currentValue: undefined, sourceValues: undefined}); generateReportAttributes.mockClear(); // When the backend clears possibleFraud, which also drops the card's fraudAlertReportID const clearedFraudCardList: CardList = {[CARD_ID]: createRandomExpensifyCard(CARD_ID, {state: CONST.EXPENSIFY_CARD.STATE.OPEN})}; const args = buildArgs(policies, cardReports); args[16] = clearedFraudCardList; - config.compute(args, { + computer.compute(args, { currentValue: seededValue, triggeredKeys: new Set([ONYXKEYS.CARD_LIST]), }); @@ -893,11 +904,11 @@ describe('reportAttributes compute — policy change code flow', () => { // Given a first compute that saw the card's live fraud pointing at the fraud report const initialArgs = buildArgs(policies, cardReports); initialArgs[16] = {[CARD_ID]: cardWithFraud}; - config.compute(initialArgs, {currentValue: undefined, sourceValues: undefined}); + computer.compute(initialArgs, {currentValue: undefined, sourceValues: undefined}); generateReportAttributes.mockClear(); // When the card list is cleared, a cleared non-collection dependency carries no source value - config.compute(buildArgs(policies, cardReports), { + computer.compute(buildArgs(policies, cardReports), { currentValue: seededValue, sourceValues: undefined, triggeredKeys: new Set([ONYXKEYS.CARD_LIST]), @@ -922,7 +933,7 @@ describe('reportAttributes compute — policy change code flow', () => { args[16] = cardList; // When the first compute after app start runs without source values - const result = config.compute(args, {currentValue: seededValue, sourceValues: undefined}); + const result = computer.compute(args, {currentValue: seededValue, sourceValues: undefined}); // Then only the fraud report is recomputed, so app start stays cheap for approvers with many green dots expect(generateReportAttributes).toHaveBeenCalledTimes(1); @@ -946,7 +957,7 @@ describe('reportAttributes compute — policy change code flow', () => { args[16] = {[CARD_ID]: cardWithFraud}; // When a full recompute runs in the same compute as the card change - const result = config.compute(args, {currentValue: seededValue, triggeredKeys: new Set([triggeredKey, ONYXKEYS.CARD_LIST])}); + const result = computer.compute(args, {currentValue: seededValue, triggeredKeys: new Set([triggeredKey, ONYXKEYS.CARD_LIST])}); // Then the fraud report gets a fresh name like every other report, since a full recompute can change any name expect(computeReportNameMock).toHaveBeenCalledWith(expect.objectContaining({report: fraudChatReport})); @@ -962,17 +973,56 @@ describe('reportAttributes compute — policy change code flow', () => { const reportsWithParent: OnyxCollection = {...cardReports, [`${ONYXKEYS.COLLECTION.REPORT}${FRAUD_REPORT_ID}`]: childFraudReport}; const initialArgs = buildArgs(policies, reportsWithParent); initialArgs[16] = {[CARD_ID]: cardWithFraud}; - config.compute(initialArgs, {currentValue: undefined, sourceValues: undefined}); + computer.compute(initialArgs, {currentValue: undefined, sourceValues: undefined}); generateReportAttributes.mockClear(); // When the backend clears possibleFraud on the card const args = buildArgs(policies, reportsWithParent); args[16] = {[CARD_ID]: createRandomExpensifyCard(CARD_ID, {state: CONST.EXPENSIFY_CARD.STATE.OPEN})}; - config.compute(args, {currentValue: seededValue, triggeredKeys: new Set([ONYXKEYS.CARD_LIST])}); + computer.compute(args, {currentValue: seededValue, triggeredKeys: new Set([ONYXKEYS.CARD_LIST])}); // Then only the fraud report is recomputed, since the fraud alert dot doesn't feed the parent chat expect(generateReportAttributes).toHaveBeenCalledTimes(1); expect(generateReportAttributes).toHaveBeenCalledWith(expect.objectContaining({report: childFraudReport})); }); }); + + describe('reference reuse', () => { + const computeFirst = () => computer.compute(buildArgs(), {currentValue: undefined, sourceValues: undefined}); + + const writeReport = (previousValue: ReportAttributesDerivedValue) => { + const updatedReport1 = {...report1, lastReadTime: '2026-01-01 00:00:00.000'}; + return computer.compute(buildArgs(policies, {...reports, [`${ONYXKEYS.COLLECTION.REPORT}r1`]: updatedReport1}), { + currentValue: previousValue, + sourceValues: {[ONYXKEYS.COLLECTION.REPORT]: {[`${ONYXKEYS.COLLECTION.REPORT}r1`]: updatedReport1}}, + triggeredKeys: new Set([ONYXKEYS.COLLECTION.REPORT]), + }); + }; + + it('should return the previous value when a report write leaves every attribute unchanged', () => { + // Given attributes computed for both reports + const previousValue = computeFirst(); + + // When r1 gets a write that doesn't feed any attribute, like a read receipt + const result = writeReport(previousValue); + + // Then the previous value comes back, so consumers can skip comparing the whole map + expect(result).toBe(previousValue); + }); + + it('should keep the references of unchanged entries when one entry changes', () => { + // Given attributes computed for both reports + const previousValue = computeFirst(); + const computeReportNameMock = jest.mocked(jest.requireMock('@libs/ReportNameUtils').computeReportName); + computeReportNameMock.mockImplementation(({report}) => (report?.reportID === 'r1' ? 'Renamed' : 'Test Report')); + + // When a write to r1 changes its name + const result = writeReport(previousValue); + + // Then the map is new because r1 changed, while r2 keeps its entry object so its consumers don't re-render + expect(result).not.toBe(previousValue); + expect(result.reports.r1?.reportName).toBe('Renamed'); + expect(result.reports.r2).toBe(previousValue.reports.r2); + }); + }); }); diff --git a/tests/unit/useAllReportAttributesTest.tsx b/tests/unit/useAllReportAttributesTest.tsx new file mode 100644 index 000000000000..1da62f7182a6 --- /dev/null +++ b/tests/unit/useAllReportAttributesTest.tsx @@ -0,0 +1,316 @@ +import {renderHook} from '@testing-library/react-native'; + +import {SearchQueryContext} from '@components/Search/SearchContext'; +import {SearchScopeProvider} from '@components/Search/SearchScopeProvider'; +import type {SearchQueryContextValue} from '@components/Search/types'; + +import {useAllReportAttributes, useIsEmptyReport, useReportName, useReportNames, useReportStatus} from '@hooks/useReportAttributes'; + +import {flushReportAttributesSnapshot} from '@libs/ReportAttributesCache'; + +import CONST from '@src/CONST'; +import IntlStore from '@src/languages/IntlStore'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Policy, Report, Transaction} from '@src/types/onyx'; + +import type {PropsWithChildren} from 'react'; + +import Onyx from 'react-native-onyx'; + +import createMock from '../utils/createMock'; +import getOnyxValue from '../utils/getOnyxValue'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +const CURRENT_USER_ACCOUNT_ID = 1; +const POLICY_ID = 'P1'; +const WORKSPACE_CHAT_ID = '10'; +const EXPENSE_REPORT_ID = '11'; +const DM_ID = '12'; +const TRANSACTION_ID = '50'; +const SEARCH_HASH = 12345; +const OTHER_ACCOUNT_ID = 2; + +const policy = { + id: POLICY_ID, + name: 'Workspace', + type: CONST.POLICY.TYPE.TEAM, + role: CONST.POLICY.ROLE.ADMIN, + owner: 'owner@test.com', + outputCurrency: CONST.CURRENCY.USD, + approvalMode: CONST.POLICY.APPROVAL_MODE.BASIC, +} as Policy; + +const workspaceChat: Report = { + reportID: WORKSPACE_CHAT_ID, + type: CONST.REPORT.TYPE.CHAT, + chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT, + policyID: POLICY_ID, + ownerAccountID: CURRENT_USER_ACCOUNT_ID, + isOwnPolicyExpenseChat: true, + participants: {[CURRENT_USER_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}}, +}; + +const expenseReport: Report = { + reportID: EXPENSE_REPORT_ID, + type: CONST.REPORT.TYPE.EXPENSE, + chatReportID: WORKSPACE_CHAT_ID, + parentReportID: WORKSPACE_CHAT_ID, + policyID: POLICY_ID, + ownerAccountID: CURRENT_USER_ACCOUNT_ID, + managerID: CURRENT_USER_ACCOUNT_ID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + total: -1000, + currency: CONST.CURRENCY.USD, +}; + +const dm: Report = { + reportID: DM_ID, + type: CONST.REPORT.TYPE.CHAT, + participants: { + [CURRENT_USER_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + [OTHER_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, +}; + +const transaction = {transactionID: TRANSACTION_ID, reportID: EXPENSE_REPORT_ID, amount: -100, currency: CONST.CURRENCY.USD, merchant: 'Coffee', created: '2024-01-01'} as Transaction; + +async function seedAccount() { + await Onyx.multiSet({ + [ONYXKEYS.SESSION]: {accountID: CURRENT_USER_ACCOUNT_ID, email: 'user1@test.com'}, + [ONYXKEYS.PERSONAL_DETAILS_LIST]: { + [CURRENT_USER_ACCOUNT_ID]: {accountID: CURRENT_USER_ACCOUNT_ID, login: 'user1@test.com', displayName: 'User One'}, + [OTHER_ACCOUNT_ID]: {accountID: OTHER_ACCOUNT_ID, login: 'user2@test.com', displayName: 'User Two'}, + }, + [`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}` as const]: policy, + [`${ONYXKEYS.COLLECTION.REPORT}${WORKSPACE_CHAT_ID}` as const]: workspaceChat, + [`${ONYXKEYS.COLLECTION.REPORT}${EXPENSE_REPORT_ID}` as const]: expenseReport, + [`${ONYXKEYS.COLLECTION.REPORT}${DM_ID}` as const]: dm, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${TRANSACTION_ID}` as const]: transaction, + }); + await waitForBatchedUpdatesWithAct(); +} + +describe('useAllReportAttributes', () => { + beforeAll(async () => { + Onyx.init({keys: ONYXKEYS}); + await IntlStore.load(CONST.LOCALES.EN); + await Onyx.set(ONYXKEYS.RAM_ONLY_ARE_TRANSLATIONS_LOADING, false); + }); + + beforeEach(async () => { + await seedAccount(); + }); + + afterEach(async () => { + await Onyx.clear(); + await IntlStore.load(CONST.LOCALES.EN); + await waitForBatchedUpdatesWithAct(); + }); + + it('should keep the same reference when a dependency changes without touching any report', async () => { + // Given the attributes of the seeded account + const {result} = renderHook(() => useAllReportAttributes()); + await waitForBatchedUpdatesWithAct(); + const initialAttributes = result.current; + expect(initialAttributes[DM_ID]?.reportName).toBe('User Two'); + + // When a personal detail changes in a field no report name reads + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, {[OTHER_ACCOUNT_ID]: {avatar: 'https://example.com/avatar.png'}}); + await waitForBatchedUpdatesWithAct(); + + // Then consumers get the same map back, so they don't re-render + expect(result.current).toBe(initialAttributes); + }); + + it('should mark the workspace chat with an error when an expense on it gets a violation', async () => { + // Given a workspace chat with an open expense report and no violations + const {result} = renderHook(() => useAllReportAttributes()); + await waitForBatchedUpdatesWithAct(); + expect(result.current[WORKSPACE_CHAT_ID]?.brickRoadStatus).toBeUndefined(); + + // When the expense on that report gets a violation the submitter has to fix + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${TRANSACTION_ID}`, [ + {name: CONST.VIOLATIONS.AUTO_REPORTED_REJECTED_EXPENSE, type: CONST.VIOLATION_TYPES.VIOLATION, showInReview: true}, + ]); + await waitForBatchedUpdatesWithAct(); + + // Then the workspace chat shows the RBR with a Fix badge + expect(result.current[WORKSPACE_CHAT_ID]?.brickRoadStatus).toBe(CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR); + expect(result.current[WORKSPACE_CHAT_ID]?.actionBadge).toBe(CONST.REPORT.ACTION_BADGE.FIX); + }); + + it('should rename every report when the locale changes', async () => { + // Given an expense report whose name is built from translated copy + const {result} = renderHook(() => useAllReportAttributes()); + await waitForBatchedUpdatesWithAct(); + const englishName = result.current[EXPENSE_REPORT_ID]?.reportName; + + // When the user switches to Spanish and its translations finish loading + await IntlStore.load(CONST.LOCALES.ES); + await waitForBatchedUpdatesWithAct(); + + // Then the name is rebuilt in Spanish + expect(result.current[EXPENSE_REPORT_ID]?.reportName).not.toBe(englishName); + expect(result.current[EXPENSE_REPORT_ID]?.reportName).toContain('US$'); + }); + + it('should drop the reports of the previous session when Onyx is cleared while nothing reads the attributes', async () => { + // Given the attributes were computed once and their only reader unmounted + const firstRender = renderHook(() => useAllReportAttributes()); + await waitForBatchedUpdatesWithAct(); + expect(firstRender.result.current[DM_ID]).toBeDefined(); + firstRender.unmount(); + + // When the user signs out and another account with different reports signs in + await Onyx.clear(); + await Onyx.multiSet({ + [ONYXKEYS.SESSION]: {accountID: CURRENT_USER_ACCOUNT_ID, email: 'user1@test.com'}, + [`${ONYXKEYS.COLLECTION.REPORT}99` as const]: {...dm, reportID: '99'}, + }); + await waitForBatchedUpdatesWithAct(); + const {result} = renderHook(() => useAllReportAttributes()); + await waitForBatchedUpdatesWithAct(); + + // Then only the new account's reports have attributes + expect(Object.keys(result.current)).toEqual(['99']); + }); + + it('should read the live report name inside the Search scope', async () => { + // Given a reader rendered inside the Search page, where useOnyx reads Search snapshot data + const wrapper = ({children}: PropsWithChildren) => ( + + ({currentSearchHash: SEARCH_HASH})}>{children} + + ); + await Onyx.merge(`${ONYXKEYS.COLLECTION.SNAPSHOT}${SEARCH_HASH}`, {data: {[`${ONYXKEYS.COLLECTION.REPORT}${DM_ID}` as const]: {...dm, reportName: 'Snapshot name'}}}); + const {result} = renderHook(() => useReportName(DM_ID), {wrapper}); + await waitForBatchedUpdatesWithAct(); + + // When the live report changes + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, {[OTHER_ACCOUNT_ID]: {displayName: 'Renamed Person'}}); + await waitForBatchedUpdatesWithAct(); + + // Then the name follows the live data, as the derived value always did + expect(result.current).toBe('Renamed Person'); + }); + + it('should not re-render a single-name reader when another report changes', async () => { + // Given a component reading one report's name + let renderCount = 0; + const {result} = renderHook(() => { + renderCount++; + return useReportName(DM_ID); + }); + await waitForBatchedUpdatesWithAct(); + const rendersAfterMount = renderCount; + + // When a different report changes + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${EXPENSE_REPORT_ID}`, {total: -5000}); + await waitForBatchedUpdatesWithAct(); + + // Then the reader keeps its name and does not render again + expect(result.current).toBe('User Two'); + expect(renderCount).toBe(rendersAfterMount); + }); + + it('should return the names of the requested reports only', async () => { + // Given a component that shows the names of a DM and a report that does not exist + const {result} = renderHook(() => useReportNames([DM_ID, 'missing', undefined])); + await waitForBatchedUpdatesWithAct(); + + // Then it gets one entry per given reportID, with no name for the missing report + expect(result.current).toEqual({[DM_ID]: 'User Two', missing: undefined}); + }); + + it('should flip isEmpty when the first message lands in a report', async () => { + // Given a DM without any message + const {result} = renderHook(() => useIsEmptyReport(DM_ID)); + await waitForBatchedUpdatesWithAct(); + expect(result.current).toBe(true); + + // When a message arrives + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${DM_ID}`, {lastMessageText: 'Hello'}); + await waitForBatchedUpdatesWithAct(); + + // Then the DM is no longer empty + expect(result.current).toBe(false); + }); + + it('should return only the badge and RBR fields of one report', async () => { + // Given a report screen reading the status of the workspace chat + const {result} = renderHook(() => useReportStatus(WORKSPACE_CHAT_ID)); + await waitForBatchedUpdatesWithAct(); + + // When an expense on that chat gets a violation + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${TRANSACTION_ID}`, [ + {name: CONST.VIOLATIONS.AUTO_REPORTED_REJECTED_EXPENSE, type: CONST.VIOLATION_TYPES.VIOLATION, showInReview: true}, + ]); + await waitForBatchedUpdatesWithAct(); + + // Then the screen gets the Fix badge and the RBR, and nothing else + expect(result.current).toEqual({actionBadge: CONST.REPORT.ACTION_BADGE.FIX, actionTargetReportActionID: undefined, brickRoadStatus: CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR}); + }); + + describe('snapshot', () => { + it('should save the attributes for the next app start', async () => { + // Given the attributes were computed + const {result} = renderHook(() => useAllReportAttributes()); + await waitForBatchedUpdatesWithAct(); + + // When the app goes to the background before the idle save ran + flushReportAttributesSnapshot(); + await waitForBatchedUpdatesWithAct(); + + // Then the snapshot holds the same attributes, tagged with the account and locale + const snapshot = await getOnyxValue(ONYXKEYS.REPORT_ATTRIBUTES_SNAPSHOT); + expect(snapshot?.reports).toEqual(result.current); + expect(snapshot?.accountID).toBe(CURRENT_USER_ACCOUNT_ID); + expect(snapshot?.locale).toBe(CONST.LOCALES.EN); + }); + + it('should start from the snapshot and compute only the reports it lacks', async () => { + // Given a fresh start with a snapshot of this account that names the DM differently and lacks the expense report + await Onyx.clear(); + await seedAccount(); + await Onyx.set(ONYXKEYS.REPORT_ATTRIBUTES_SNAPSHOT, { + accountID: CURRENT_USER_ACCOUNT_ID, + locale: CONST.LOCALES.EN, + reports: { + [DM_ID]: {reportName: 'Saved name', isEmpty: true, brickRoadStatus: undefined, requiresAttention: false, reportErrors: {}}, + [WORKSPACE_CHAT_ID]: {reportName: 'Saved chat', isEmpty: true, brickRoadStatus: undefined, requiresAttention: false, reportErrors: {}}, + gone: {reportName: 'Deleted report', isEmpty: true, brickRoadStatus: undefined, requiresAttention: false, reportErrors: {}}, + }, + }); + await waitForBatchedUpdatesWithAct(); + + // When the attributes are first read + const {result} = renderHook(() => useAllReportAttributes()); + await waitForBatchedUpdatesWithAct(); + + // Then the saved names are trusted, the missing report is computed and the deleted one is dropped + expect(result.current[DM_ID]?.reportName).toBe('Saved name'); + expect(result.current[EXPENSE_REPORT_ID]?.reportName).toBeTruthy(); + expect(result.current.gone).toBeUndefined(); + }); + + it('should ignore a snapshot of another account', async () => { + // Given a fresh start with a snapshot saved for a different account + await Onyx.clear(); + await seedAccount(); + await Onyx.set(ONYXKEYS.REPORT_ATTRIBUTES_SNAPSHOT, { + accountID: 999, + locale: CONST.LOCALES.EN, + reports: {[DM_ID]: {reportName: 'Other account', isEmpty: true, brickRoadStatus: undefined, requiresAttention: false, reportErrors: {}}}, + }); + await waitForBatchedUpdatesWithAct(); + + // When the attributes are first read + const {result} = renderHook(() => useAllReportAttributes()); + await waitForBatchedUpdatesWithAct(); + + // Then everything is computed from this account's data + expect(result.current[DM_ID]?.reportName).toBe('User Two'); + }); + }); +}); diff --git a/tests/unit/useReportAttributesTest.ts b/tests/unit/useReportAttributesTest.ts deleted file mode 100644 index c9e4fbc6b98e..000000000000 --- a/tests/unit/useReportAttributesTest.ts +++ /dev/null @@ -1,370 +0,0 @@ -import {renderHook} from '@testing-library/react-native'; - -import useReportAttributes, {useDerivedIsEmptyReport, useDerivedReportNameByReportID, useDerivedReportNamesByReportIDs} from '@hooks/useReportAttributes'; - -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import type {ReportAttributesDerivedValue} from '@src/types/onyx'; -import type {ReportAttributes} from '@src/types/onyx/DerivedValues'; - -import {reportIsEmptySelector} from '@selectors/ReportAttributes'; -import Onyx from 'react-native-onyx'; - -import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; - -const REPORT_ID_1 = 'reportID1'; -const REPORT_ID_2 = 'reportID2'; -const REPORT_ID_3 = 'reportID3'; - -function createMockReport(overrides: Partial = {}): ReportAttributes { - return { - reportName: 'Default Report', - isEmpty: false, - brickRoadStatus: undefined, - requiresAttention: false, - reportErrors: {}, - ...overrides, - }; -} - -function createDerivedValue(reports: ReportAttributesDerivedValue['reports'], locale = 'en'): ReportAttributesDerivedValue { - return {reports, locale}; -} - -const MOCK_REPORTS: ReportAttributesDerivedValue['reports'] = { - [REPORT_ID_1]: createMockReport({reportName: 'Report 1'}), - [REPORT_ID_2]: createMockReport({ - reportName: 'Report 2', - isEmpty: true, - brickRoadStatus: CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR, - requiresAttention: true, - reportErrors: {error: 'Something went wrong'}, - }), -}; - -describe('useReportAttributes', () => { - beforeAll(() => { - Onyx.init({ - keys: ONYXKEYS, - }); - }); - - beforeEach(async () => { - await Onyx.clear(); - }); - - it('should return undefined when the derived value is not set', async () => { - await waitForBatchedUpdates(); - - const {result} = renderHook(() => useReportAttributes()); - - expect(result.current).toBeUndefined(); - }); - - it('should return the reports from the derived value', async () => { - await Onyx.set(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, createDerivedValue(MOCK_REPORTS)); - - await waitForBatchedUpdates(); - - const {result} = renderHook(() => useReportAttributes()); - - expect(result.current).toEqual(MOCK_REPORTS); - }); - - it('should return reports with correct attributes', async () => { - await Onyx.set(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, createDerivedValue(MOCK_REPORTS)); - - await waitForBatchedUpdates(); - - const {result} = renderHook(() => useReportAttributes()); - - expect(result.current?.[REPORT_ID_1]?.reportName).toBe('Report 1'); - expect(result.current?.[REPORT_ID_1]?.isEmpty).toBe(false); - expect(result.current?.[REPORT_ID_1]?.brickRoadStatus).toBeUndefined(); - expect(result.current?.[REPORT_ID_2]?.brickRoadStatus).toBe(CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR); - expect(result.current?.[REPORT_ID_2]?.requiresAttention).toBe(true); - }); - - it('should return an empty object when reports is empty', async () => { - await Onyx.set(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, createDerivedValue({})); - - await waitForBatchedUpdates(); - - const {result} = renderHook(() => useReportAttributes()); - - expect(result.current).toEqual({}); - }); - - it('should update when the derived value changes', async () => { - await Onyx.set(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, createDerivedValue(MOCK_REPORTS)); - - await waitForBatchedUpdates(); - - const {result, rerender} = renderHook(() => useReportAttributes()); - - expect(Object.keys(result.current ?? {})).toHaveLength(2); - - const updatedReports: ReportAttributesDerivedValue['reports'] = { - [REPORT_ID_3]: createMockReport({ - reportName: 'Report 3', - brickRoadStatus: CONST.BRICK_ROAD_INDICATOR_STATUS.INFO, - requiresAttention: true, - }), - }; - - await Onyx.set(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, createDerivedValue(updatedReports)); - - await waitForBatchedUpdates(); - rerender(undefined); - - expect(Object.keys(result.current ?? {})).toHaveLength(1); - expect(result.current?.[REPORT_ID_3]?.reportName).toBe('Report 3'); - }); -}); - -describe('useDerivedReportNameByReportID', () => { - beforeAll(() => { - Onyx.init({ - keys: ONYXKEYS, - }); - }); - - beforeEach(async () => { - await Onyx.clear(); - }); - - it('should return undefined when the derived value is not set', async () => { - await waitForBatchedUpdates(); - - const {result} = renderHook(() => useDerivedReportNameByReportID(REPORT_ID_1)); - - expect(result.current).toBeUndefined(); - }); - - it("should return the report's name for a matching reportID", async () => { - await Onyx.set(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, createDerivedValue(MOCK_REPORTS)); - - await waitForBatchedUpdates(); - - const {result} = renderHook(() => useDerivedReportNameByReportID(REPORT_ID_1)); - - expect(result.current).toBe('Report 1'); - }); - - it('should return undefined when the reportID is undefined', async () => { - await Onyx.set(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, createDerivedValue(MOCK_REPORTS)); - - await waitForBatchedUpdates(); - - const {result} = renderHook(() => useDerivedReportNameByReportID(undefined)); - - expect(result.current).toBeUndefined(); - }); - - it('should return undefined when the reportID does not exist in the derived value', async () => { - await Onyx.set(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, createDerivedValue(MOCK_REPORTS)); - - await waitForBatchedUpdates(); - - const {result} = renderHook(() => useDerivedReportNameByReportID('nonExistentReportID')); - - expect(result.current).toBeUndefined(); - }); - - it("should update when that report's name changes", async () => { - await Onyx.set(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, createDerivedValue(MOCK_REPORTS)); - - await waitForBatchedUpdates(); - - const {result, rerender} = renderHook(() => useDerivedReportNameByReportID(REPORT_ID_1)); - - expect(result.current).toBe('Report 1'); - - await Onyx.set(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, createDerivedValue({[REPORT_ID_1]: createMockReport({reportName: 'Renamed Report 1'})})); - - await waitForBatchedUpdates(); - rerender(undefined); - - expect(result.current).toBe('Renamed Report 1'); - }); -}); - -describe('useDerivedReportNamesByReportIDs', () => { - beforeAll(() => { - Onyx.init({ - keys: ONYXKEYS, - }); - }); - - beforeEach(async () => { - await Onyx.clear(); - }); - - it('should return a name for each matching reportID', async () => { - await Onyx.set(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, createDerivedValue(MOCK_REPORTS)); - - await waitForBatchedUpdates(); - - const {result} = renderHook(() => useDerivedReportNamesByReportIDs([REPORT_ID_1, REPORT_ID_2])); - - expect(result.current).toEqual({ - [REPORT_ID_1]: 'Report 1', - [REPORT_ID_2]: 'Report 2', - }); - }); - - it('should return an empty object for an empty array of reportIDs', async () => { - await Onyx.set(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, createDerivedValue(MOCK_REPORTS)); - - await waitForBatchedUpdates(); - - const {result} = renderHook(() => useDerivedReportNamesByReportIDs([])); - - expect(result.current).toEqual({}); - }); - - it('should skip undefined reportIDs', async () => { - await Onyx.set(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, createDerivedValue(MOCK_REPORTS)); - - await waitForBatchedUpdates(); - - const {result} = renderHook(() => useDerivedReportNamesByReportIDs([REPORT_ID_1, undefined])); - - expect(Object.keys(result.current ?? {})).toEqual([REPORT_ID_1]); - expect(result.current?.[REPORT_ID_1]).toBe('Report 1'); - }); - - it('should map an undefined name for a reportID missing from the derived value', async () => { - await Onyx.set(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, createDerivedValue(MOCK_REPORTS)); - - await waitForBatchedUpdates(); - - const {result} = renderHook(() => useDerivedReportNamesByReportIDs([REPORT_ID_1, 'nonExistentReportID'])); - - expect(result.current?.[REPORT_ID_1]).toBe('Report 1'); - expect(result.current?.nonExistentReportID).toBeUndefined(); - }); - - it('should update when one of the requested report names changes', async () => { - await Onyx.set(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, createDerivedValue(MOCK_REPORTS)); - - await waitForBatchedUpdates(); - - const {result, rerender} = renderHook(() => useDerivedReportNamesByReportIDs([REPORT_ID_1, REPORT_ID_2])); - - expect(result.current?.[REPORT_ID_1]).toBe('Report 1'); - - await Onyx.set( - ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, - createDerivedValue({ - ...MOCK_REPORTS, - [REPORT_ID_1]: createMockReport({reportName: 'Renamed Report 1'}), - }), - ); - - await waitForBatchedUpdates(); - rerender(undefined); - - expect(result.current?.[REPORT_ID_1]).toBe('Renamed Report 1'); - expect(result.current?.[REPORT_ID_2]).toBe('Report 2'); - }); -}); - -describe('useDerivedIsEmptyReport', () => { - beforeAll(() => { - Onyx.init({keys: ONYXKEYS}); - }); - - beforeEach(async () => { - await Onyx.clear(); - }); - - it('should return undefined when the derived value is not set', async () => { - await waitForBatchedUpdates(); - - const {result} = renderHook(() => useDerivedIsEmptyReport(REPORT_ID_1)); - - expect(result.current).toBeUndefined(); - }); - - it('should return false for a non-empty report', async () => { - await Onyx.set(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, createDerivedValue(MOCK_REPORTS)); - - await waitForBatchedUpdates(); - - const {result} = renderHook(() => useDerivedIsEmptyReport(REPORT_ID_1)); - - expect(result.current).toBe(false); - }); - - it('should return true for an empty report', async () => { - await Onyx.set(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, createDerivedValue(MOCK_REPORTS)); - - await waitForBatchedUpdates(); - - const {result} = renderHook(() => useDerivedIsEmptyReport(REPORT_ID_2)); - - expect(result.current).toBe(true); - }); - - it('should return undefined when reportID is undefined', async () => { - await Onyx.set(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, createDerivedValue(MOCK_REPORTS)); - - await waitForBatchedUpdates(); - - const {result} = renderHook(() => useDerivedIsEmptyReport(undefined)); - - expect(result.current).toBeUndefined(); - }); - - it('should return undefined when the reportID does not exist', async () => { - await Onyx.set(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, createDerivedValue(MOCK_REPORTS)); - - await waitForBatchedUpdates(); - - const {result} = renderHook(() => useDerivedIsEmptyReport('nonExistentReportID')); - - expect(result.current).toBeUndefined(); - }); - - it('should update when the isEmpty flag changes', async () => { - await Onyx.set(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, createDerivedValue(MOCK_REPORTS)); - - await waitForBatchedUpdates(); - - const {result, rerender} = renderHook(() => useDerivedIsEmptyReport(REPORT_ID_1)); - - expect(result.current).toBe(false); - - await Onyx.set(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, createDerivedValue({[REPORT_ID_1]: createMockReport({isEmpty: true})})); - - await waitForBatchedUpdates(); - rerender(undefined); - - expect(result.current).toBe(true); - }); -}); - -describe('reportIsEmptySelector', () => { - const derivedValue = createDerivedValue(MOCK_REPORTS); - - it('should return false for a non-empty report', () => { - expect(reportIsEmptySelector(derivedValue, REPORT_ID_1)).toBe(false); - }); - - it('should return true for an empty report', () => { - expect(reportIsEmptySelector(derivedValue, REPORT_ID_2)).toBe(true); - }); - - it('should return undefined when reportID is undefined', () => { - expect(reportIsEmptySelector(derivedValue, undefined)).toBeUndefined(); - }); - - it('should return undefined when reportID does not exist', () => { - expect(reportIsEmptySelector(derivedValue, 'nonExistentReportID')).toBeUndefined(); - }); - - it('should return undefined when attributes is undefined', () => { - expect(reportIsEmptySelector(undefined, REPORT_ID_1)).toBeUndefined(); - }); -}); diff --git a/tests/unit/useSidebarOrderedReportsTest.tsx b/tests/unit/useSidebarOrderedReportsTest.tsx index 006d9540b713..cf7b6ccdf046 100644 --- a/tests/unit/useSidebarOrderedReportsTest.tsx +++ b/tests/unit/useSidebarOrderedReportsTest.tsx @@ -37,6 +37,31 @@ jest.mock('@libs/ReportUtils', () => ({ getReportIDFromLink: jest.fn(() => ''), })); +// The attributes are an input of the hook under test, so the tests hand them in directly instead of computing them. +let mockReportAttributes: ReportAttributesDerivedValue['reports'] = {}; +const mockReportAttributesListeners = new Set<() => void>(); +jest.mock('@hooks/useReportAttributes', () => { + const {useSyncExternalStore} = jest.requireActual('react'); + return { + ...jest.requireActual>('@hooks/useReportAttributes'), + useAllReportAttributes: () => + useSyncExternalStore( + (listener: () => void) => { + mockReportAttributesListeners.add(listener); + return () => mockReportAttributesListeners.delete(listener); + }, + () => mockReportAttributes, + ), + }; +}); + +function setReportAttributes(reports: ReportAttributesDerivedValue['reports']) { + mockReportAttributes = reports; + for (const listener of mockReportAttributesListeners) { + listener(); + } +} + const mockSidebarUtils = jest.mocked(SidebarUtils); describe('useSidebarOrderedReports', () => { @@ -55,6 +80,7 @@ describe('useSidebarOrderedReports', () => { beforeEach(async () => { jest.clearAllMocks(); + setReportAttributes({}); await act(async () => { await Onyx.clear(); @@ -73,7 +99,6 @@ describe('useSidebarOrderedReports', () => { [ONYXKEYS.COLLECTION.TRANSACTION]: {}, [ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS]: {}, [ONYXKEYS.BETAS]: [], - [ONYXKEYS.DERIVED.REPORT_ATTRIBUTES]: {reports: {}}, [ONYXKEYS.DERIVED.GUIDE_ACCOUNT_IDS]: [], } satisfies OnyxMultiSetInput); }); @@ -456,7 +481,7 @@ describe('useSidebarOrderedReports', () => { await act(async () => { await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}1`, displayedReports['1']); await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}2`, threadReport); - await Onyx.set(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, {reports: {[threadReport.reportID]: createThreadAttributes(true)}, locale: null}); + setReportAttributes({[threadReport.reportID]: createThreadAttributes(true)}); }); renderHook(() => useSidebarOrderedReports(), { @@ -467,10 +492,9 @@ describe('useSidebarOrderedReports', () => { mockSidebarUtils.updateReportsToDisplayInLHN.mockClear(); - // When the derived attributes land a render after the report change that caused them (e.g. a first reply in the thread), - // so the reports collection itself does not change in this render + // When the attributes change without the reports collection changing in the same render (e.g. a first reply in the thread) await act(async () => { - await Onyx.set(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, {reports: {[threadReport.reportID]: createThreadAttributes(false)}, locale: null}); + setReportAttributes({[threadReport.reportID]: createThreadAttributes(false)}); }); await waitForBatchedUpdatesWithAct(); @@ -512,12 +536,9 @@ describe('useSidebarOrderedReports', () => { await act(async () => { await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}1`, displayedReports['1']); await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}2`, report2); - await Onyx.set(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, { - reports: { - [displayedReports['1'].reportID]: unchangedAttributes, - [report2.reportID]: createReport2Attributes(true), - }, - locale: null, + setReportAttributes({ + [displayedReports['1'].reportID]: unchangedAttributes, + [report2.reportID]: createReport2Attributes(true), }); }); @@ -529,12 +550,9 @@ describe('useSidebarOrderedReports', () => { // When only report 2's derived attributes change, while report 1 keeps the same input object reference await act(async () => { - await Onyx.set(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, { - reports: { - [displayedReports['1'].reportID]: unchangedAttributes, - [report2.reportID]: createReport2Attributes(false), - }, - locale: null, + setReportAttributes({ + [displayedReports['1'].reportID]: unchangedAttributes, + [report2.reportID]: createReport2Attributes(false), }); }); await waitForBatchedUpdatesWithAct(); @@ -568,7 +586,7 @@ describe('useSidebarOrderedReports', () => { await act(async () => { await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${displayedReportID}`, displayedReports[displayedReportID]); - await Onyx.set(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, {reports: {[displayedReportID]: createReportAttributes()}, locale: 'en'}); + setReportAttributes({[displayedReportID]: createReportAttributes()}); }); renderHook(() => useSidebarOrderedReports(), { @@ -591,10 +609,7 @@ describe('useSidebarOrderedReports', () => { // When the derived attributes later mark the DM as requiring attention, with no change to the report itself await act(async () => { - await Onyx.set(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, { - reports: {[displayedReportID]: createReportAttributes(), [newDMReportID]: createReportAttributes({requiresAttention: true})}, - locale: 'en', - }); + setReportAttributes({[displayedReportID]: createReportAttributes(), [newDMReportID]: createReportAttributes({requiresAttention: true})}); }); await waitForBatchedUpdatesWithAct(); diff --git a/tests/utils/readReportAttributes.ts b/tests/utils/readReportAttributes.ts new file mode 100644 index 000000000000..0f1740d98a20 --- /dev/null +++ b/tests/utils/readReportAttributes.ts @@ -0,0 +1,14 @@ +import {renderHook} from '@testing-library/react-native'; + +import {useReportAttributesSelector} from '@hooks/useReportAttributes'; + +import waitForBatchedUpdatesWithAct from './waitForBatchedUpdatesWithAct'; + +/** Reads the report attributes the way a component gets them right now, through a reader mounted only for this read. */ +export default async function readReportAttributes() { + const {result, unmount} = renderHook(() => useReportAttributesSelector((attributes) => attributes)); + await waitForBatchedUpdatesWithAct(); + const attributes = result.current; + unmount(); + return attributes; +}