diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 3bc77c33513e..51c58bb3548b 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -3029,6 +3029,13 @@ const CONST = { LOCALES, + COLLATOR_OPTIONS: { + usage: 'sort', + sensitivity: 'variant', + numeric: true, + caseFirst: 'upper', + } as Intl.CollatorOptions, + PRONOUNS_LIST: [ 'coCos', 'eEyEmEir', diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 97d06c697306..49f2c061a511 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -986,6 +986,7 @@ const ONYXKEYS = { REPORT_ATTRIBUTES: 'reportAttributes', REPORT_TRANSACTIONS_AND_VIOLATIONS: 'reportTransactionsAndViolations', OUTSTANDING_REPORTS_BY_POLICY_ID: 'outstandingReportsByPolicyID', + ORDERED_REPORTS_FOR_LHN: 'orderedReportsForLHN', }, /** Stores HybridApp specific state required to interoperate with OldDot */ @@ -1394,6 +1395,7 @@ 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.ORDERED_REPORTS_FOR_LHN]: OnyxTypes.OrderedReportsForLHNDerivedValue; }; type OnyxValues = OnyxValuesMapping & OnyxCollectionValuesMapping & OnyxFormValuesMapping & OnyxFormDraftValuesMapping & OnyxDerivedValuesMapping; diff --git a/src/components/LocaleContextProvider.tsx b/src/components/LocaleContextProvider.tsx index bc05e06eb449..abf36311dfd8 100644 --- a/src/components/LocaleContextProvider.tsx +++ b/src/components/LocaleContextProvider.tsx @@ -81,8 +81,6 @@ const LocaleContext = createContext({ preferredLocale: undefined, }); -const COLLATOR_OPTIONS: Intl.CollatorOptions = {usage: 'sort', sensitivity: 'variant', numeric: true, caseFirst: 'upper'}; - function LocaleContextProvider({children}: LocaleContextProviderProps) { const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const [areTranslationsLoading = true] = useOnyx(ONYXKEYS.ARE_TRANSLATIONS_LOADING, {initWithStoredValues: false, canBeMissing: true}); @@ -136,7 +134,7 @@ function LocaleContextProvider({children}: LocaleContextProviderProps) { const selectedTimezone = useMemo(() => currentUserPersonalDetails?.timezone?.selected, [currentUserPersonalDetails?.timezone?.selected]); - const collator = useMemo(() => new Intl.Collator(currentLocale, COLLATOR_OPTIONS), [currentLocale]); + const collator = useMemo(() => new Intl.Collator(currentLocale, CONST.COLLATOR_OPTIONS), [currentLocale]); const translate = useMemo( () => diff --git a/src/hooks/useSidebarOrderedReports.tsx b/src/hooks/useSidebarOrderedReports.tsx index 2f939b36d5d6..e26e4babcd4a 100644 --- a/src/hooks/useSidebarOrderedReports.tsx +++ b/src/hooks/useSidebarOrderedReports.tsx @@ -1,18 +1,14 @@ -import reportsSelector from '@selectors/Attributes'; import {createPoliciesSelector} from '@selectors/Policy'; import {deepEqual} from 'fast-equals'; -import React, {createContext, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; +import React, {createContext, useCallback, useContext, useEffect, useMemo, useRef} from 'react'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import Onyx from 'react-native-onyx'; import Log from '@libs/Log'; import {getPolicyEmployeeListByIdWithoutCurrentUser} from '@libs/PolicyUtils'; -import SidebarUtils from '@libs/SidebarUtils'; -import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; import useCurrentReportID from './useCurrentReportID'; import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; -import useDeepCompareRef from './useDeepCompareRef'; -import useLocalize from './useLocalize'; import useOnyx from './useOnyx'; import usePrevious from './usePrevious'; import useResponsiveLayout from './useResponsiveLayout'; @@ -66,177 +62,25 @@ function SidebarOrderedReportsContextProvider({ */ currentReportIDForTests, }: SidebarOrderedReportsContextProviderProps) { - const {localeCompare} = useLocalize(); - const [priorityMode = CONST.PRIORITY_MODE.DEFAULT] = useOnyx(ONYXKEYS.NVP_PRIORITY_MODE, {canBeMissing: true}); - const [chatReports, {sourceValue: reportUpdates}] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {canBeMissing: true}); - const [policies, {sourceValue: policiesUpdates}] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: policiesSelector, canBeMissing: true}); - const [transactions, {sourceValue: transactionsUpdates}] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION, {canBeMissing: true}); - const [transactionViolations, {sourceValue: transactionViolationsUpdates}] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS, {canBeMissing: true}); - const [reportNameValuePairs, {sourceValue: reportNameValuePairsUpdates}] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, {canBeMissing: true}); - const [reportsDrafts, {sourceValue: reportsDraftsUpdates}] = useOnyx(ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT, {canBeMissing: true}); - const [betas] = useOnyx(ONYXKEYS.BETAS, {canBeMissing: true}); - const [reportAttributes] = useOnyx(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, {selector: reportsSelector, canBeMissing: true}); - const [currentReportsToDisplay, setCurrentReportsToDisplay] = useState({}); + const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: policiesSelector, canBeMissing: true}); + const [chatReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {canBeMissing: true}); + // eslint-disable-next-line @typescript-eslint/prefer-destructuring, @typescript-eslint/no-unsafe-assignment + const [orderedReportsData] = useOnyx(ONYXKEYS.DERIVED.ORDERED_REPORTS_FOR_LHN, {canBeMissing: true}); const {shouldUseNarrowLayout} = useResponsiveLayout(); const {accountID} = useCurrentUserPersonalDetails(); const currentReportIDValue = useCurrentReportID(); const derivedCurrentReportID = currentReportIDForTests ?? currentReportIDValue?.currentReportID; - const prevDerivedCurrentReportID = usePrevious(derivedCurrentReportID); - - // we need to force reportsToDisplayInLHN to re-compute when we clear currentReportsToDisplay, but the way it currently works relies on not having currentReportsToDisplay as a memo dependency, so we just need something we can change to trigger it - // I don't like it either, but clearing the cache is only a hack for the debug modal and I will endeavor to make it better as I work to improve the cache correctness of the LHN more broadly - const [clearCacheDummyCounter, setClearCacheDummyCounter] = useState(0); const policyMemberAccountIDs = useMemo(() => getPolicyEmployeeListByIdWithoutCurrentUser(policies, undefined, accountID), [policies, accountID]); - const prevBetas = usePrevious(betas); - const prevPriorityMode = usePrevious(priorityMode); const perfRef = useRef<{hookDuration: number}>({ hookDuration: 0, }); + // eslint-disable-next-line react-hooks/purity const hookStartTime = useRef(performance.now()); - /** - * Find the reports that need to be updated in the LHN - */ - const getUpdatedReports = useCallback(() => { - const reportsToUpdate = new Set(); - - if (betas !== prevBetas || priorityMode !== prevPriorityMode) { - for (const key of Object.keys(chatReports ?? {})) { - reportsToUpdate.add(key); - } - } - if (reportUpdates) { - for (const key of Object.keys(reportUpdates ?? {})) { - reportsToUpdate.add(key); - } - } - if (reportNameValuePairsUpdates) { - for (const key of Object.keys(reportNameValuePairsUpdates ?? {}).map((reportKey) => reportKey.replace(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, ONYXKEYS.COLLECTION.REPORT))) { - reportsToUpdate.add(key); - } - } - if (transactionsUpdates) { - for (const key of Object.values(transactionsUpdates ?? {}).map((transaction) => `${ONYXKEYS.COLLECTION.REPORT}${transaction?.reportID}`)) { - reportsToUpdate.add(key); - } - } - if (transactionViolationsUpdates) { - for (const key of Object.keys(transactionViolationsUpdates ?? {}) - .map((violationKey) => violationKey.replace(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS, ONYXKEYS.COLLECTION.TRANSACTION)) - .map((transactionKey) => `${ONYXKEYS.COLLECTION.REPORT}${transactions?.[transactionKey]?.reportID}`)) { - reportsToUpdate.add(key); - } - } - if (reportsDraftsUpdates) { - for (const key of Object.keys(reportsDraftsUpdates).map((draftKey) => draftKey.replace(ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT, ONYXKEYS.COLLECTION.REPORT))) { - reportsToUpdate.add(key); - } - } - if (policiesUpdates) { - const updatedPolicies = new Set(Object.keys(policiesUpdates).map((policyKey) => policyKey.replace(ONYXKEYS.COLLECTION.POLICY, ''))); - for (const key of Object.entries(chatReports ?? {}) - .filter(([, value]) => { - if (!value?.policyID) { - return; - } - - return updatedPolicies.has(value.policyID); - }) - .map(([reportKey]) => reportKey)) { - reportsToUpdate.add(key); - } - } - - // Make sure the previous and current reports are always included in the updates when we switch reports. - if (prevDerivedCurrentReportID !== derivedCurrentReportID) { - reportsToUpdate.add(`${ONYXKEYS.COLLECTION.REPORT}${prevDerivedCurrentReportID}`); - reportsToUpdate.add(`${ONYXKEYS.COLLECTION.REPORT}${derivedCurrentReportID}`); - } - - return Array.from(reportsToUpdate); - }, [ - reportUpdates, - reportNameValuePairsUpdates, - transactionsUpdates, - transactionViolationsUpdates, - reportsDraftsUpdates, - policiesUpdates, - chatReports, - transactions, - betas, - priorityMode, - prevBetas, - prevPriorityMode, - prevDerivedCurrentReportID, - derivedCurrentReportID, - ]); - - const reportsToDisplayInLHN = useMemo(() => { - const updatedReports = getUpdatedReports(); - const shouldDoIncrementalUpdate = updatedReports.length > 0 && Object.keys(currentReportsToDisplay).length > 0; - let reportsToDisplay = {}; - if (shouldDoIncrementalUpdate) { - reportsToDisplay = SidebarUtils.updateReportsToDisplayInLHN({ - displayedReports: currentReportsToDisplay, - reports: chatReports, - updatedReportsKeys: updatedReports, - currentReportId: derivedCurrentReportID, - isInFocusMode: priorityMode === CONST.PRIORITY_MODE.GSD, - betas, - transactionViolations, - reportNameValuePairs, - reportAttributes, - draftComments: reportsDrafts, - }); - } else { - Log.info('[useSidebarOrderedReports] building reportsToDisplay from scratch'); - reportsToDisplay = SidebarUtils.getReportsToDisplayInLHN( - derivedCurrentReportID, - chatReports, - betas, - policies, - priorityMode, - reportsDrafts, - transactionViolations, - reportNameValuePairs, - reportAttributes, - ); - } - - return reportsToDisplay; - // Rule disabled intentionally — triggering a re-render on currentReportsToDisplay would cause an infinite loop - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - getUpdatedReports, - chatReports, - derivedCurrentReportID, - priorityMode, - betas, - policies, - transactionViolations, - reportNameValuePairs, - reportAttributes, - reportsDrafts, - clearCacheDummyCounter, - ]); - - const deepComparedReportsToDisplayInLHN = useDeepCompareRef(reportsToDisplayInLHN); - const deepComparedReportsDrafts = useDeepCompareRef(reportsDrafts); - - useEffect(() => { - setCurrentReportsToDisplay(reportsToDisplayInLHN); - }, [reportsToDisplayInLHN]); - - const getOrderedReportIDs = useCallback( - () => SidebarUtils.sortReportsToDisplayInLHN(deepComparedReportsToDisplayInLHN ?? {}, priorityMode, localeCompare, deepComparedReportsDrafts, reportNameValuePairs), - // Rule disabled intentionally - reports should be sorted only when the reportsToDisplayInLHN changes - // eslint-disable-next-line react-hooks/exhaustive-deps - [deepComparedReportsToDisplayInLHN, localeCompare, deepComparedReportsDrafts], - ); - - const orderedReportIDs = useMemo(() => getOrderedReportIDs(), [getOrderedReportIDs]); + // Get ordered report IDs from the derived value, holding a stable reference when it's empty + const orderedReportIDs = useMemo(() => orderedReportsData?.orderedReportIDs ?? [], [orderedReportsData?.orderedReportIDs]); // Get the actual reports based on the ordered IDs const getOrderedReports = useCallback( @@ -253,16 +97,15 @@ function SidebarOrderedReportsContextProvider({ const clearLHNCache = useCallback(() => { Log.info('[useSidebarOrderedReports] Clearing sidebar cache manually via debug modal'); - setCurrentReportsToDisplay({}); - setClearCacheDummyCounter((current) => current + 1); + // This is a debug function that clears the derived value cache + // eslint-disable-next-line rulesdir/prefer-actions-set-data + Onyx.set(ONYXKEYS.DERIVED.ORDERED_REPORTS_FOR_LHN, null); }, []); const contextValue: SidebarOrderedReportsContextValue = useMemo(() => { // We need to make sure the current report is in the list of reports, but we do not want - // to have to re-generate the list every time the currentReportID changes. To do that - // we first generate the list as if there was no current report, then we check if - // the current report is missing from the list, which should very rarely happen. In this - // case we re-generate the list a 2nd time with the current report included. + // to have to re-generate the list every time the currentReportID changes. To handle this, + // we add the current report to the list here in the hook if it's missing from the derived value. // We also execute the following logic if `shouldUseNarrowLayout` is false because this is // requirement for web. Consider a case, where we have report with expenses and we click on @@ -274,15 +117,17 @@ function SidebarOrderedReportsContextProvider({ derivedCurrentReportID !== '-1' && orderedReportIDs.indexOf(derivedCurrentReportID) === -1 ) { - const updatedReportIDs = getOrderedReportIDs(); - const updatedReports = getOrderedReports(updatedReportIDs); - return { - orderedReports: updatedReports, - orderedReportIDs: updatedReportIDs, - currentReportID: derivedCurrentReportID, - policyMemberAccountIDs, - clearLHNCache, - }; + // Current report is missing from the list, so we add it here at render time + const currentReport = chatReports?.[`${ONYXKEYS.COLLECTION.REPORT}${derivedCurrentReportID}`]; + if (currentReport) { + return { + orderedReports: [currentReport, ...orderedReports], + orderedReportIDs: [derivedCurrentReportID, ...orderedReportIDs], + currentReportID: derivedCurrentReportID, + policyMemberAccountIDs, + clearLHNCache, + }; + } } return { @@ -292,29 +137,15 @@ function SidebarOrderedReportsContextProvider({ policyMemberAccountIDs, clearLHNCache, }; - }, [getOrderedReportIDs, orderedReportIDs, derivedCurrentReportID, policyMemberAccountIDs, shouldUseNarrowLayout, getOrderedReports, orderedReports, clearLHNCache]); + }, [orderedReportIDs, derivedCurrentReportID, policyMemberAccountIDs, shouldUseNarrowLayout, orderedReports, clearLHNCache, chatReports]); const currentDeps = { - priorityMode, - chatReports, - policies, - transactions, - transactionViolations, - reportNameValuePairs, - betas, - reportAttributes, - currentReportsToDisplay, - shouldUseNarrowLayout, - accountID, - currentReportIDValue, - derivedCurrentReportID, - prevDerivedCurrentReportID, - policyMemberAccountIDs, - prevBetas, - prevPriorityMode, - reportsToDisplayInLHN, orderedReportIDs, orderedReports, + derivedCurrentReportID, + policyMemberAccountIDs, + shouldUseNarrowLayout, + accountID, }; const prevContextValue = usePrevious(contextValue); const previousDeps = usePrevious(currentDeps); diff --git a/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts b/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts index 0f9c467e518d..c5068dfa0225 100644 --- a/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts +++ b/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts @@ -1,5 +1,6 @@ import type {ValueOf} from 'type-fest'; import ONYXKEYS from '@src/ONYXKEYS'; +import orderedReportsForLHNConfig from './configs/orderedReportsForLHN'; import outstandingReportsByPolicyIDConfig from './configs/outstandingReportsByPolicyID'; import reportAttributesConfig from './configs/reportAttributes'; import reportTransactionsAndViolationsConfig from './configs/reportTransactionsAndViolations'; @@ -13,6 +14,7 @@ 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.ORDERED_REPORTS_FOR_LHN]: orderedReportsForLHNConfig, } as const satisfies { // eslint-disable-next-line @typescript-eslint/no-explicit-any [Key in ValueOf]: OnyxDerivedValueConfig; diff --git a/src/libs/actions/OnyxDerived/configs/orderedReportsForLHN.ts b/src/libs/actions/OnyxDerived/configs/orderedReportsForLHN.ts new file mode 100644 index 000000000000..2cbee5095211 --- /dev/null +++ b/src/libs/actions/OnyxDerived/configs/orderedReportsForLHN.ts @@ -0,0 +1,233 @@ +import type {OnyxCollection} from 'react-native-onyx'; +import type {PartialPolicyForSidebar, ReportsToDisplayInLHN} from '@hooks/useSidebarOrderedReports'; +import Log from '@libs/Log'; +import SidebarUtils from '@libs/SidebarUtils'; +import createOnyxDerivedValueConfig from '@userActions/OnyxDerived/createOnyxDerivedValueConfig'; +import {hasKeyTriggeredCompute} from '@userActions/OnyxDerived/utils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +let isFullyComputed = false; + +// Cache the collator to avoid creating a new one on every compute +let cachedCollator: Intl.Collator | null = null; +let cachedLocale: string | null = null; + +/** + * Helper to create locale comparison function from a locale string (because we can't call useLocalize in this file) + */ +function createLocaleCompare(locale: string | null | undefined): (a: string, b: string) => number { + const effectiveLocale = locale ?? CONST.LOCALES.DEFAULT; + + // Reuse cached collator if locale hasn't changed + if (cachedCollator && cachedLocale === effectiveLocale) { + const collator = cachedCollator; + return (a, b) => collator.compare(a, b); + } + + // Create and cache new collator + cachedCollator = new Intl.Collator(effectiveLocale, CONST.COLLATOR_OPTIONS); + cachedLocale = effectiveLocale; + + const collator = cachedCollator; + return (a, b) => collator.compare(a, b); +} + +/** + * This derived value computes the ordered reports for the LHN (Left Hand Navigation). + * It handles incremental updates and returns both the filtered reports and their sorted IDs. + */ +// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment +export default createOnyxDerivedValueConfig({ + key: ONYXKEYS.DERIVED.ORDERED_REPORTS_FOR_LHN, + dependencies: [ + ONYXKEYS.COLLECTION.REPORT, + ONYXKEYS.COLLECTION.POLICY, + ONYXKEYS.COLLECTION.TRANSACTION, + ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS, + ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, + ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT, + ONYXKEYS.BETAS, + ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, + ONYXKEYS.NVP_PRIORITY_MODE, + ONYXKEYS.NVP_PREFERRED_LOCALE, + ], + compute: ( + [reports, policies, transactions, transactionViolations, reportNameValuePairs, reportsDrafts, betas, reportAttributesData, priorityMode, preferredLocale], + {currentValue, sourceValues, areAllConnectionsSet}, + ) => { + if (!areAllConnectionsSet) { + return { + reportsToDisplay: {}, + orderedReportIDs: [], + currentReportID: undefined, + locale: null, + }; + } + + const reportAttributes = reportAttributesData?.reports; + + // Check if we need to recompute everything due to locale or beta changes + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + const prevLocale = currentValue?.locale; + const localeChanged = hasKeyTriggeredCompute(ONYXKEYS.NVP_PREFERRED_LOCALE, sourceValues) && preferredLocale !== prevLocale; + const betasChanged = hasKeyTriggeredCompute(ONYXKEYS.BETAS, sourceValues); + const priorityModeChanged = hasKeyTriggeredCompute(ONYXKEYS.NVP_PRIORITY_MODE, sourceValues); + + if (localeChanged || betasChanged || priorityModeChanged) { + isFullyComputed = false; + } + + // If we already computed and there are no updates, return current value + if ((isFullyComputed && !sourceValues) || !reports) { + return ( + currentValue ?? { + reportsToDisplay: {}, + orderedReportIDs: [], + currentReportID: undefined, + locale: preferredLocale ?? null, + } + ); + } + + const reportUpdates = sourceValues?.[ONYXKEYS.COLLECTION.REPORT] ?? {}; + const reportNameValuePairsUpdates = sourceValues?.[ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS] ?? {}; + const transactionsUpdates = sourceValues?.[ONYXKEYS.COLLECTION.TRANSACTION]; + const transactionViolationsUpdates = sourceValues?.[ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS]; + const reportsDraftsUpdates = sourceValues?.[ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT] ?? {}; + const policiesUpdates = sourceValues?.[ONYXKEYS.COLLECTION.POLICY] ?? {}; + + // Determine which reports need to be updated + const reportsToUpdate = new Set(); + + // If locale, betas, or priority mode changed, update all reports + if (localeChanged || betasChanged || priorityModeChanged) { + for (const key of Object.keys(reports ?? {})) { + reportsToUpdate.add(key); + } + } + + // Add directly updated reports + if (isFullyComputed && Object.keys(reportUpdates).length > 0) { + for (const key of Object.keys(reportUpdates)) { + reportsToUpdate.add(key); + } + } + + // Add reports affected by reportNameValuePairs updates + if (isFullyComputed && Object.keys(reportNameValuePairsUpdates).length > 0) { + for (const key of Object.keys(reportNameValuePairsUpdates).map((reportKey) => reportKey.replace(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, ONYXKEYS.COLLECTION.REPORT))) { + reportsToUpdate.add(key); + } + } + + // Add reports affected by transactions updates + if (isFullyComputed && transactionsUpdates) { + for (const key of Object.values(transactionsUpdates).map((transaction) => (transaction?.reportID ? `${ONYXKEYS.COLLECTION.REPORT}${transaction.reportID}` : undefined))) { + if (key && key !== `${ONYXKEYS.COLLECTION.REPORT}`) { + reportsToUpdate.add(key); + } + } + } + + // Add reports affected by transaction violations updates + if (isFullyComputed && transactionViolationsUpdates) { + for (const key of Object.keys(transactionViolationsUpdates) + .map((violationKey) => violationKey.replace(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS, ONYXKEYS.COLLECTION.TRANSACTION)) + .map((transactionKey) => (transactions?.[transactionKey]?.reportID ? `${ONYXKEYS.COLLECTION.REPORT}${transactions[transactionKey].reportID}` : undefined))) { + if (key && key !== `${ONYXKEYS.COLLECTION.REPORT}`) { + reportsToUpdate.add(key); + } + } + } + + // Add reports affected by draft comments updates + if (isFullyComputed && Object.keys(reportsDraftsUpdates).length > 0) { + for (const key of Object.keys(reportsDraftsUpdates).map((draftKey) => draftKey.replace(ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT, ONYXKEYS.COLLECTION.REPORT))) { + reportsToUpdate.add(key); + } + } + + // Add reports affected by policy updates + if (isFullyComputed && Object.keys(policiesUpdates).length > 0) { + const updatedPolicies = new Set(Object.keys(policiesUpdates).map((policyKey) => policyKey.replace(ONYXKEYS.COLLECTION.POLICY, ''))); + for (const key of Object.entries(reports ?? {}) + .filter(([, value]) => { + if (!value?.policyID) { + return false; + } + return updatedPolicies.has(value.policyID); + }) + .map(([reportKey]) => reportKey)) { + reportsToUpdate.add(key); + } + } + + const shouldDoIncrementalUpdate = reportsToUpdate.size > 0 && isFullyComputed && !!currentValue; + let reportsToDisplay: ReportsToDisplayInLHN = {}; + + if (shouldDoIncrementalUpdate) { + // Incremental update + reportsToDisplay = SidebarUtils.updateReportsToDisplayInLHN({ + displayedReports: currentValue.reportsToDisplay, + reports, + updatedReportsKeys: Array.from(reportsToUpdate), + currentReportId: undefined, + isInFocusMode: priorityMode === CONST.PRIORITY_MODE.GSD, + betas, + transactionViolations, + reportNameValuePairs, + reportAttributes, + draftComments: reportsDrafts, + }); + } else { + // Full computation + Log.info('[orderedReportsForLHN] building reportsToDisplay from scratch'); + + // Convert policies to the expected format for SidebarUtils + const partialPolicies: OnyxCollection = {}; + if (policies) { + for (const [key, policy] of Object.entries(policies)) { + if (policy) { + partialPolicies[key] = { + type: policy.type, + name: policy.name, + avatarURL: policy.avatarURL, + employeeList: policy.employeeList, + }; + } + } + } + + reportsToDisplay = SidebarUtils.getReportsToDisplayInLHN( + undefined, + reports, + betas, + partialPolicies, + priorityMode, + reportsDrafts, + transactionViolations, + reportNameValuePairs, + reportAttributes, + ); + } + + // Create locale comparison function + const localeCompare = createLocaleCompare(preferredLocale); + + // Sort the reports + const orderedReportIDs = SidebarUtils.sortReportsToDisplayInLHN(reportsToDisplay, priorityMode, localeCompare, reportsDrafts, reportNameValuePairs); + + // Mark as fully computed after first full iteration + if (!Object.keys(reportUpdates).length && Object.keys(reports ?? {}).length > 0 && !isFullyComputed) { + isFullyComputed = true; + } + + return { + reportsToDisplay, + orderedReportIDs, + currentReportID: undefined, + locale: preferredLocale ?? null, + }; + }, +}); diff --git a/src/libs/actions/OnyxDerived/types.ts b/src/libs/actions/OnyxDerived/types.ts index 8933579ea887..452df8e6784e 100644 --- a/src/libs/actions/OnyxDerived/types.ts +++ b/src/libs/actions/OnyxDerived/types.ts @@ -13,7 +13,7 @@ type DerivedSourceValues = Partial<{ [K in Deps[number]]: OnyxCollectionSourceValue; }>; -type DerivedValueContext>> = { +type DerivedValueContext> = { currentValue?: OnyxValue; sourceValues?: DerivedSourceValues; areAllConnectionsSet: boolean; @@ -26,7 +26,7 @@ type DerivedValueContext, OnyxEntry] */ -type OnyxDerivedValueConfig, Deps extends NonEmptyTuple>> = { +type OnyxDerivedValueConfig, Deps extends NonEmptyTuple> = { key: Key; dependencies: Deps; compute: ( diff --git a/src/types/onyx/DerivedValues.ts b/src/types/onyx/DerivedValues.ts index d39cc88e76e2..8d9f0797bad9 100644 --- a/src/types/onyx/DerivedValues.ts +++ b/src/types/onyx/DerivedValues.ts @@ -70,5 +70,44 @@ type ReportTransactionsAndViolationsDerivedValue = Record>; +/** + * The derived value for ordered reports in the LHN (Left Hand Navigation). + */ +type OrderedReportsForLHNDerivedValue = { + /** + * The reports to display in the LHN. + */ + reportsToDisplay: Record< + string, + Report & { + /** + * Whether the report has errors that need user attention, excluding SmartScan receipt failures. + * This includes transaction violations that need review, policy-related issues, and other reportErrors. + * Used to display a RBR indicator in the LHN. + */ + hasErrorsOtherThanFailedReceipt?: boolean; + } + >; + /** + * The ordered report IDs. + */ + orderedReportIDs: string[]; + /** + * The current report ID. + */ + currentReportID: string | undefined; + /** + * The locale used for sorting. + */ + locale: string | null; +}; + export default ReportAttributesDerivedValue; -export type {ReportAttributes, ReportAttributesDerivedValue, ReportTransactionsAndViolationsDerivedValue, ReportTransactionsAndViolations, OutstandingReportsByPolicyIDDerivedValue}; +export type { + ReportAttributes, + ReportAttributesDerivedValue, + ReportTransactionsAndViolationsDerivedValue, + ReportTransactionsAndViolations, + OutstandingReportsByPolicyIDDerivedValue, + OrderedReportsForLHNDerivedValue, +}; diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index 81e89f0df9e3..52fcd59a8ecc 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -28,7 +28,7 @@ import type Credentials from './Credentials'; import type Currency from './Currency'; import type {CurrencyList} from './Currency'; import type CustomStatusDraft from './CustomStatusDraft'; -import type {OutstandingReportsByPolicyIDDerivedValue, ReportAttributesDerivedValue, ReportTransactionsAndViolationsDerivedValue} from './DerivedValues'; +import type {OrderedReportsForLHNDerivedValue, OutstandingReportsByPolicyIDDerivedValue, ReportAttributesDerivedValue, ReportTransactionsAndViolationsDerivedValue} from './DerivedValues'; import type DismissedProductTraining from './DismissedProductTraining'; import type DismissedReferralBanners from './DismissedReferralBanners'; import type Domain from './Domain'; @@ -307,6 +307,7 @@ export type { LastSearchParams, ReportTransactionsAndViolationsDerivedValue, OutstandingReportsByPolicyIDDerivedValue, + OrderedReportsForLHNDerivedValue, ScheduleCallDraft, ValidateUserAndGetAccessiblePolicies, VacationDelegate, diff --git a/tests/unit/SidebarOrderTest.ts b/tests/unit/SidebarOrderTest.ts index 0a3950912155..c8aec240ec97 100644 --- a/tests/unit/SidebarOrderTest.ts +++ b/tests/unit/SidebarOrderTest.ts @@ -404,8 +404,8 @@ describe('Sidebar', () => { const hintText = TestHelper.translateLocal('accessibilityHints.chatUserDisplayNames'); const displayNames = screen.queryAllByLabelText(hintText); expect(displayNames).toHaveLength(4); - expect(displayNames.at(0)).toHaveTextContent('Email Four'); - expect(displayNames.at(1)).toHaveTextContent('Email Four owes $100.00'); + expect(displayNames.at(0)).toHaveTextContent('Email Four owes $100.00'); + expect(displayNames.at(1)).toHaveTextContent('Email Four'); expect(displayNames.at(2)).toHaveTextContent('Email Three'); expect(displayNames.at(3)).toHaveTextContent('Email Two'); }) @@ -484,8 +484,8 @@ describe('Sidebar', () => { const hintText = TestHelper.translateLocal('accessibilityHints.chatUserDisplayNames'); const displayNames = screen.queryAllByLabelText(hintText); expect(displayNames).toHaveLength(4); - expect(displayNames.at(0)).toHaveTextContent(`Email One's expenses`); - expect(displayNames.at(1)).toHaveTextContent('Report Name'); + expect(displayNames.at(0)).toHaveTextContent('Report Name'); + expect(displayNames.at(1)).toHaveTextContent(`Email One's expenses`); expect(displayNames.at(2)).toHaveTextContent('Email Three'); expect(displayNames.at(3)).toHaveTextContent('Email Two'); }) diff --git a/tests/unit/orderedReportsForLHNTest.ts b/tests/unit/orderedReportsForLHNTest.ts new file mode 100644 index 000000000000..46a010d8d327 --- /dev/null +++ b/tests/unit/orderedReportsForLHNTest.ts @@ -0,0 +1,796 @@ +import type {OnyxCollection} from 'react-native-onyx'; +import orderedReportsForLHNConfig from '@libs/actions/OnyxDerived/configs/orderedReportsForLHN'; +import SidebarUtils from '@libs/SidebarUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Beta, Policy, Report, Transaction} from '@src/types/onyx'; + +describe('orderedReportsForLHN derived value', () => { + const createMockReport = (reportID: string, overrides: Partial = {}): Report => ({ + reportID, + reportName: `Report ${reportID}`, + lastVisibleActionCreated: '2024-01-01 10:00:00', + type: CONST.REPORT.TYPE.CHAT, + ...overrides, + }); + + const createMockPolicy = (policyID: string, overrides: Partial = {}): Policy => + ({ + id: policyID, + name: `Policy ${policyID}`, + type: CONST.POLICY.TYPE.TEAM, + role: CONST.POLICY.ROLE.USER, + ...overrides, + }) as Policy; + + const createMockTransaction = (transactionID: string, reportID: string, overrides: Partial = {}): Transaction => + ({ + transactionID, + reportID, + amount: 100, + currency: 'USD', + ...overrides, + }) as Transaction; + + describe('initial state', () => { + it('should return empty state when connections are not set', () => { + const result = orderedReportsForLHNConfig.compute( + [ + {}, // reports + {}, // policies + {}, // transactions + {}, // transactionViolations + {}, // reportNameValuePairs + {}, // reportsDrafts + [], // betas + {reports: {}, locale: null}, // reportAttributesData + CONST.PRIORITY_MODE.DEFAULT, // priorityMode + CONST.LOCALES.DEFAULT, // preferredLocale + ], + { + currentValue: undefined, + sourceValues: undefined, + areAllConnectionsSet: false, + }, + ); + + expect(result).toEqual({ + reportsToDisplay: {}, + orderedReportIDs: [], + currentReportID: undefined, + locale: null, + }); + }); + + it('should perform full computation on first call with data', () => { + const reports: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.REPORT}1`]: createMockReport('1'), + [`${ONYXKEYS.COLLECTION.REPORT}2`]: createMockReport('2'), + }; + + const result = orderedReportsForLHNConfig.compute( + [ + reports, + {}, // policies + {}, // transactions + {}, // transactionViolations + {}, // reportNameValuePairs + {}, // reportsDrafts + [], // betas + {reports: {}, locale: null}, // reportAttributesData + CONST.PRIORITY_MODE.DEFAULT, + CONST.LOCALES.DEFAULT, + ], + { + currentValue: undefined, + sourceValues: undefined, + areAllConnectionsSet: true, + }, + ); + + expect(result.reportsToDisplay).toBeDefined(); + expect(result.orderedReportIDs).toBeDefined(); + expect(result.locale).toBe(CONST.LOCALES.DEFAULT); + }); + }); + + describe('incremental updates', () => { + it('should perform incremental update when only one report changes', () => { + const report1 = createMockReport('1'); + const report2 = createMockReport('2'); + const reports: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.REPORT}1`]: report1, + [`${ONYXKEYS.COLLECTION.REPORT}2`]: report2, + }; + + // First call - full computation + const firstResult = orderedReportsForLHNConfig.compute([reports, {}, {}, {}, {}, {}, [], {reports: {}, locale: null}, CONST.PRIORITY_MODE.DEFAULT, CONST.LOCALES.DEFAULT], { + currentValue: undefined, + sourceValues: undefined, + areAllConnectionsSet: true, + }); + + // Second call - incremental update + const updatedReport1 = createMockReport('1', {reportName: 'Updated Report 1'}); + const updatedReports: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.REPORT}1`]: updatedReport1, + [`${ONYXKEYS.COLLECTION.REPORT}2`]: report2, + }; + + const secondResult = orderedReportsForLHNConfig.compute( + [updatedReports, {}, {}, {}, {}, {}, [], {reports: {}, locale: null}, CONST.PRIORITY_MODE.DEFAULT, CONST.LOCALES.DEFAULT], + { + currentValue: firstResult, + sourceValues: { + [ONYXKEYS.COLLECTION.REPORT]: { + [`${ONYXKEYS.COLLECTION.REPORT}1`]: updatedReport1, + }, + }, + areAllConnectionsSet: true, + }, + ); + + // Should have performed incremental update + expect(secondResult.reportsToDisplay).toBeDefined(); + expect(secondResult.locale).toBe(CONST.LOCALES.DEFAULT); + }); + + it('should update reports affected by transaction changes', () => { + const report1 = createMockReport('1'); + const reports: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.REPORT}1`]: report1, + }; + + const transaction = createMockTransaction('t1', '1'); + const transactions: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}t1`]: transaction, + }; + + // First call - full computation + const firstResult = orderedReportsForLHNConfig.compute( + [reports, {}, transactions, {}, {}, {}, [], {reports: {}, locale: null}, CONST.PRIORITY_MODE.DEFAULT, CONST.LOCALES.DEFAULT], + { + currentValue: undefined, + sourceValues: undefined, + areAllConnectionsSet: true, + }, + ); + + // Second call - transaction update + const updatedTransaction = createMockTransaction('t1', '1', {amount: 200}); + const updatedTransactions: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}t1`]: updatedTransaction, + }; + + const secondResult = orderedReportsForLHNConfig.compute( + [reports, {}, updatedTransactions, {}, {}, {}, [], {reports: {}, locale: null}, CONST.PRIORITY_MODE.DEFAULT, CONST.LOCALES.DEFAULT], + { + currentValue: firstResult, + sourceValues: { + [ONYXKEYS.COLLECTION.TRANSACTION]: { + [`${ONYXKEYS.COLLECTION.TRANSACTION}t1`]: updatedTransaction, + }, + }, + areAllConnectionsSet: true, + }, + ); + + // Should have updated the report associated with the transaction + expect(secondResult.reportsToDisplay).toBeDefined(); + }); + + it('should update reports affected by policy changes', () => { + const policy1 = createMockPolicy('p1'); + const report1 = createMockReport('1', {policyID: 'p1'}); + const report2 = createMockReport('2', {policyID: 'p2'}); + + const reports: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.REPORT}1`]: report1, + [`${ONYXKEYS.COLLECTION.REPORT}2`]: report2, + }; + + const policies: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.POLICY}p1`]: policy1, + }; + + // First call - full computation + const firstResult = orderedReportsForLHNConfig.compute([reports, policies, {}, {}, {}, {}, [], {reports: {}, locale: null}, CONST.PRIORITY_MODE.DEFAULT, CONST.LOCALES.DEFAULT], { + currentValue: undefined, + sourceValues: undefined, + areAllConnectionsSet: true, + }); + + // Second call - policy update + const updatedPolicy1 = createMockPolicy('p1', {name: 'Updated Policy'}); + const updatedPolicies: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.POLICY}p1`]: updatedPolicy1, + }; + + const secondResult = orderedReportsForLHNConfig.compute( + [reports, updatedPolicies, {}, {}, {}, {}, [], {reports: {}, locale: null}, CONST.PRIORITY_MODE.DEFAULT, CONST.LOCALES.DEFAULT], + { + currentValue: firstResult, + sourceValues: { + [ONYXKEYS.COLLECTION.POLICY]: { + [`${ONYXKEYS.COLLECTION.POLICY}p1`]: updatedPolicy1, + }, + }, + areAllConnectionsSet: true, + }, + ); + + // Should have updated report 1 which belongs to policy p1 + // Report 2 should not be affected + expect(secondResult.reportsToDisplay).toBeDefined(); + }); + + it('should update reports affected by draft comment changes', () => { + const report1 = createMockReport('1'); + const reports: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.REPORT}1`]: report1, + }; + + // First call - full computation + const firstResult = orderedReportsForLHNConfig.compute([reports, {}, {}, {}, {}, {}, [], {reports: {}, locale: null}, CONST.PRIORITY_MODE.DEFAULT, CONST.LOCALES.DEFAULT], { + currentValue: undefined, + sourceValues: undefined, + areAllConnectionsSet: true, + }); + + // Second call - draft comment added + const reportsDrafts: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}1`]: 'Draft message', + }; + + const secondResult = orderedReportsForLHNConfig.compute( + [reports, {}, {}, {}, {}, reportsDrafts, [], {reports: {}, locale: null}, CONST.PRIORITY_MODE.DEFAULT, CONST.LOCALES.DEFAULT], + { + currentValue: firstResult, + sourceValues: { + [ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT]: { + [`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}1`]: 'Draft message', + }, + }, + areAllConnectionsSet: true, + }, + ); + + // Should have updated report 1 which has a draft comment + expect(secondResult.reportsToDisplay).toBeDefined(); + }); + }); + + describe('full recomputation triggers', () => { + it('should perform full recomputation when locale changes', () => { + const reports: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.REPORT}1`]: createMockReport('1'), + [`${ONYXKEYS.COLLECTION.REPORT}2`]: createMockReport('2'), + }; + + // First call with en locale + const firstResult = orderedReportsForLHNConfig.compute([reports, {}, {}, {}, {}, {}, [], {reports: {}, locale: null}, CONST.PRIORITY_MODE.DEFAULT, CONST.LOCALES.EN], { + currentValue: undefined, + sourceValues: undefined, + areAllConnectionsSet: true, + }); + + expect(firstResult.locale).toBe(CONST.LOCALES.EN); + + // Second call with es locale + const secondResult = orderedReportsForLHNConfig.compute([reports, {}, {}, {}, {}, {}, [], {reports: {}, locale: null}, CONST.PRIORITY_MODE.DEFAULT, CONST.LOCALES.ES], { + currentValue: firstResult, + sourceValues: { + [ONYXKEYS.NVP_PREFERRED_LOCALE]: undefined, + }, + areAllConnectionsSet: true, + }); + + expect(secondResult.locale).toBe(CONST.LOCALES.ES); + // Full recomputation should have happened, affecting all reports + }); + + it('should perform full recomputation when betas change', () => { + const reports: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.REPORT}1`]: createMockReport('1'), + }; + + // First call without betas + const firstResult = orderedReportsForLHNConfig.compute([reports, {}, {}, {}, {}, {}, [], {reports: {}, locale: null}, CONST.PRIORITY_MODE.DEFAULT, CONST.LOCALES.DEFAULT], { + currentValue: undefined, + sourceValues: undefined, + areAllConnectionsSet: true, + }); + + // Second call with betas + const betas: Beta[] = [CONST.BETAS.DEFAULT_ROOMS]; + const secondResult = orderedReportsForLHNConfig.compute([reports, {}, {}, {}, {}, {}, betas, {reports: {}, locale: null}, CONST.PRIORITY_MODE.DEFAULT, CONST.LOCALES.DEFAULT], { + currentValue: firstResult, + sourceValues: { + [ONYXKEYS.BETAS]: undefined, + }, + areAllConnectionsSet: true, + }); + + // Full recomputation should have happened + expect(secondResult.reportsToDisplay).toBeDefined(); + }); + + it('should perform full recomputation when priority mode changes', () => { + const reports: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.REPORT}1`]: createMockReport('1'), + }; + + // First call with DEFAULT priority mode + const firstResult = orderedReportsForLHNConfig.compute([reports, {}, {}, {}, {}, {}, [], {reports: {}, locale: null}, CONST.PRIORITY_MODE.DEFAULT, CONST.LOCALES.DEFAULT], { + currentValue: undefined, + sourceValues: undefined, + areAllConnectionsSet: true, + }); + + // Second call with GSD priority mode + const secondResult = orderedReportsForLHNConfig.compute([reports, {}, {}, {}, {}, {}, [], {reports: {}, locale: null}, CONST.PRIORITY_MODE.GSD, CONST.LOCALES.DEFAULT], { + currentValue: firstResult, + sourceValues: { + [ONYXKEYS.NVP_PRIORITY_MODE]: undefined, + }, + areAllConnectionsSet: true, + }); + + // Full recomputation should have happened + expect(secondResult.reportsToDisplay).toBeDefined(); + }); + }); + + describe('caching behavior', () => { + it('should return current value when no updates and fully computed', () => { + const reports: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.REPORT}1`]: createMockReport('1'), + }; + + // First call - full computation + const firstResult = orderedReportsForLHNConfig.compute([reports, {}, {}, {}, {}, {}, [], {reports: {}, locale: null}, CONST.PRIORITY_MODE.DEFAULT, CONST.LOCALES.DEFAULT], { + currentValue: undefined, + sourceValues: undefined, + areAllConnectionsSet: true, + }); + + // Second call with no sourceValues (no changes) + const secondResult = orderedReportsForLHNConfig.compute([reports, {}, {}, {}, {}, {}, [], {reports: {}, locale: null}, CONST.PRIORITY_MODE.DEFAULT, CONST.LOCALES.DEFAULT], { + currentValue: firstResult, + sourceValues: undefined, + areAllConnectionsSet: true, + }); + + // Should return the cached value + expect(secondResult).toBe(firstResult); + }); + + it('should return current value when reports is null/undefined', () => { + const previousResult = { + reportsToDisplay: {}, + orderedReportIDs: ['1'], + currentReportID: undefined, + locale: CONST.LOCALES.DEFAULT, + }; + + const result = orderedReportsForLHNConfig.compute( + [ + undefined as unknown as OnyxCollection, // No reports + {}, + {}, + {}, + {}, + {}, + [], + {reports: {}, locale: null}, + CONST.PRIORITY_MODE.DEFAULT, + CONST.LOCALES.DEFAULT, + ], + { + currentValue: previousResult, + sourceValues: undefined, + areAllConnectionsSet: true, + }, + ); + + // Should return previous value when reports is undefined + expect(result).toBe(previousResult); + }); + }); + + describe('edge cases', () => { + it('should handle transaction with no reportID', () => { + const reports: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.REPORT}1`]: createMockReport('1'), + }; + + const transaction = createMockTransaction('t1', ''); + const transactions: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}t1`]: transaction, + }; + + // First call + const firstResult = orderedReportsForLHNConfig.compute( + [reports, {}, transactions, {}, {}, {}, [], {reports: {}, locale: null}, CONST.PRIORITY_MODE.DEFAULT, CONST.LOCALES.DEFAULT], + { + currentValue: undefined, + sourceValues: undefined, + areAllConnectionsSet: true, + }, + ); + + // Second call with transaction update + const updatedTransaction = createMockTransaction('t1', '', {amount: 200}); + const updatedTransactions: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}t1`]: updatedTransaction, + }; + + const secondResult = orderedReportsForLHNConfig.compute( + [reports, {}, updatedTransactions, {}, {}, {}, [], {reports: {}, locale: null}, CONST.PRIORITY_MODE.DEFAULT, CONST.LOCALES.DEFAULT], + { + currentValue: firstResult, + sourceValues: { + [ONYXKEYS.COLLECTION.TRANSACTION]: { + [`${ONYXKEYS.COLLECTION.TRANSACTION}t1`]: updatedTransaction, + }, + }, + areAllConnectionsSet: true, + }, + ); + + // Should handle gracefully without crashing + expect(secondResult).toBeDefined(); + }); + + it('should handle report with no policyID when policies change', () => { + const report1 = createMockReport('1'); // No policyID + const report2 = createMockReport('2', {policyID: 'p1'}); + + const reports: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.REPORT}1`]: report1, + [`${ONYXKEYS.COLLECTION.REPORT}2`]: report2, + }; + + const policy1 = createMockPolicy('p1'); + const policies: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.POLICY}p1`]: policy1, + }; + + // First call + const firstResult = orderedReportsForLHNConfig.compute([reports, policies, {}, {}, {}, {}, [], {reports: {}, locale: null}, CONST.PRIORITY_MODE.DEFAULT, CONST.LOCALES.DEFAULT], { + currentValue: undefined, + sourceValues: undefined, + areAllConnectionsSet: true, + }); + + // Second call - policy update + const updatedPolicy1 = createMockPolicy('p1', {name: 'Updated'}); + const updatedPolicies: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.POLICY}p1`]: updatedPolicy1, + }; + + const secondResult = orderedReportsForLHNConfig.compute( + [reports, updatedPolicies, {}, {}, {}, {}, [], {reports: {}, locale: null}, CONST.PRIORITY_MODE.DEFAULT, CONST.LOCALES.DEFAULT], + { + currentValue: firstResult, + sourceValues: { + [ONYXKEYS.COLLECTION.POLICY]: { + [`${ONYXKEYS.COLLECTION.POLICY}p1`]: updatedPolicy1, + }, + }, + areAllConnectionsSet: true, + }, + ); + + // Should only update report2, not report1 (which has no policyID) + expect(secondResult).toBeDefined(); + }); + }); + + describe('cache correctness verification', () => { + let updateReportsToDisplayInLHNSpy: jest.SpyInstance; + let getReportsToDisplayInLHNSpy: jest.SpyInstance; + + beforeEach(() => { + updateReportsToDisplayInLHNSpy = jest.spyOn(SidebarUtils, 'updateReportsToDisplayInLHN'); + getReportsToDisplayInLHNSpy = jest.spyOn(SidebarUtils, 'getReportsToDisplayInLHN'); + }); + + afterEach(() => { + updateReportsToDisplayInLHNSpy.mockRestore(); + getReportsToDisplayInLHNSpy.mockRestore(); + }); + + it('should preserve object references for unchanged reports during incremental update', () => { + const report1 = createMockReport('1'); + const report2 = createMockReport('2'); + const report3 = createMockReport('3'); + const reports: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.REPORT}1`]: report1, + [`${ONYXKEYS.COLLECTION.REPORT}2`]: report2, + [`${ONYXKEYS.COLLECTION.REPORT}3`]: report3, + }; + + // First call - full computation to initialize the cache + const firstResult = orderedReportsForLHNConfig.compute([reports, {}, {}, {}, {}, {}, [], {reports: {}, locale: null}, CONST.PRIORITY_MODE.DEFAULT, CONST.LOCALES.DEFAULT], { + currentValue: undefined, + sourceValues: undefined, + areAllConnectionsSet: true, + }); + + // Capture the report2 and report3 objects from the first result + const report2FromFirstResult = firstResult.reportsToDisplay?.[`${ONYXKEYS.COLLECTION.REPORT}2`]; + const report3FromFirstResult = firstResult.reportsToDisplay?.[`${ONYXKEYS.COLLECTION.REPORT}3`]; + + // Second call - only report1 changed (incremental update) + const updatedReport1 = createMockReport('1', {reportName: 'Updated Report 1'}); + const updatedReports: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.REPORT}1`]: updatedReport1, + [`${ONYXKEYS.COLLECTION.REPORT}2`]: report2, // Same reference + [`${ONYXKEYS.COLLECTION.REPORT}3`]: report3, // Same reference + }; + + const secondResult = orderedReportsForLHNConfig.compute( + [updatedReports, {}, {}, {}, {}, {}, [], {reports: {}, locale: null}, CONST.PRIORITY_MODE.DEFAULT, CONST.LOCALES.DEFAULT], + { + currentValue: firstResult, + sourceValues: { + [ONYXKEYS.COLLECTION.REPORT]: { + [`${ONYXKEYS.COLLECTION.REPORT}1`]: updatedReport1, + }, + }, + areAllConnectionsSet: true, + }, + ); + + // CRITICAL: Report2 and Report3's entries should be the exact same object references + // (not recomputed) because they weren't in sourceValues + expect(secondResult.reportsToDisplay?.[`${ONYXKEYS.COLLECTION.REPORT}2`]).toBe(report2FromFirstResult); + expect(secondResult.reportsToDisplay?.[`${ONYXKEYS.COLLECTION.REPORT}3`]).toBe(report3FromFirstResult); + }); + + it('should use incremental update (not full computation) when sourceValues indicates specific reports changed', () => { + const report1 = createMockReport('1'); + const report2 = createMockReport('2'); + const reports: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.REPORT}1`]: report1, + [`${ONYXKEYS.COLLECTION.REPORT}2`]: report2, + }; + + // First call - to initialize isFullyComputed state + // Note: Due to module-level isFullyComputed state, after any previous test runs, + // this may already be true. We focus on verifying the second call behavior. + const firstResult = orderedReportsForLHNConfig.compute([reports, {}, {}, {}, {}, {}, [], {reports: {}, locale: null}, CONST.PRIORITY_MODE.DEFAULT, CONST.LOCALES.DEFAULT], { + currentValue: undefined, + sourceValues: undefined, + areAllConnectionsSet: true, + }); + + // Clear spies for second call - this is the call we're testing + updateReportsToDisplayInLHNSpy.mockClear(); + getReportsToDisplayInLHNSpy.mockClear(); + + // Second call - incremental update + const updatedReport1 = createMockReport('1', {reportName: 'Updated'}); + const updatedReports: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.REPORT}1`]: updatedReport1, + [`${ONYXKEYS.COLLECTION.REPORT}2`]: report2, + }; + + orderedReportsForLHNConfig.compute([updatedReports, {}, {}, {}, {}, {}, [], {reports: {}, locale: null}, CONST.PRIORITY_MODE.DEFAULT, CONST.LOCALES.DEFAULT], { + currentValue: firstResult, + sourceValues: { + [ONYXKEYS.COLLECTION.REPORT]: { + [`${ONYXKEYS.COLLECTION.REPORT}1`]: updatedReport1, + }, + }, + areAllConnectionsSet: true, + }); + + // Second call should use incremental update, not full computation + expect(updateReportsToDisplayInLHNSpy).toHaveBeenCalled(); + expect(getReportsToDisplayInLHNSpy).not.toHaveBeenCalled(); + }); + + it('should call updateReportsToDisplayInLHN with only the changed report keys', () => { + const report1 = createMockReport('1'); + const report2 = createMockReport('2'); + const report3 = createMockReport('3'); + const reports: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.REPORT}1`]: report1, + [`${ONYXKEYS.COLLECTION.REPORT}2`]: report2, + [`${ONYXKEYS.COLLECTION.REPORT}3`]: report3, + }; + + // First call - full computation + const firstResult = orderedReportsForLHNConfig.compute([reports, {}, {}, {}, {}, {}, [], {reports: {}, locale: null}, CONST.PRIORITY_MODE.DEFAULT, CONST.LOCALES.DEFAULT], { + currentValue: undefined, + sourceValues: undefined, + areAllConnectionsSet: true, + }); + + updateReportsToDisplayInLHNSpy.mockClear(); + + // Second call - only report2 changed + const updatedReport2 = createMockReport('2', {reportName: 'Updated Report 2'}); + const updatedReports: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.REPORT}1`]: report1, + [`${ONYXKEYS.COLLECTION.REPORT}2`]: updatedReport2, + [`${ONYXKEYS.COLLECTION.REPORT}3`]: report3, + }; + + orderedReportsForLHNConfig.compute([updatedReports, {}, {}, {}, {}, {}, [], {reports: {}, locale: null}, CONST.PRIORITY_MODE.DEFAULT, CONST.LOCALES.DEFAULT], { + currentValue: firstResult, + sourceValues: { + [ONYXKEYS.COLLECTION.REPORT]: { + [`${ONYXKEYS.COLLECTION.REPORT}2`]: updatedReport2, + }, + }, + areAllConnectionsSet: true, + }); + + // Verify updateReportsToDisplayInLHN was called with only report2's key + expect(updateReportsToDisplayInLHNSpy).toHaveBeenCalledWith( + expect.objectContaining({ + updatedReportsKeys: [`${ONYXKEYS.COLLECTION.REPORT}2`], + }), + ); + }); + + it('should remove deleted reports from reportsToDisplay', () => { + const report1 = createMockReport('1'); + const report2 = createMockReport('2'); + const reports: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.REPORT}1`]: report1, + [`${ONYXKEYS.COLLECTION.REPORT}2`]: report2, + }; + + // First call - full computation with both reports + const firstResult = orderedReportsForLHNConfig.compute([reports, {}, {}, {}, {}, {}, [], {reports: {}, locale: null}, CONST.PRIORITY_MODE.DEFAULT, CONST.LOCALES.DEFAULT], { + currentValue: undefined, + sourceValues: undefined, + areAllConnectionsSet: true, + }); + + // Note: Mock reports may or may not pass shouldDisplayReportInLHN checks. + // We'll manually add report2 to reportsToDisplay to test the deletion behavior. + const firstResultWithReport2: typeof firstResult = { + ...firstResult, + reportsToDisplay: { + ...firstResult.reportsToDisplay, + [`${ONYXKEYS.COLLECTION.REPORT}2`]: report2, + }, + orderedReportIDs: [...firstResult.orderedReportIDs, '2'].filter((v, i, a) => a.indexOf(v) === i), + }; + + // Second call - report2 is deleted (no longer in reports collection) + const reportsWithoutReport2: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.REPORT}1`]: report1, + }; + + const secondResult = orderedReportsForLHNConfig.compute( + [reportsWithoutReport2, {}, {}, {}, {}, {}, [], {reports: {}, locale: null}, CONST.PRIORITY_MODE.DEFAULT, CONST.LOCALES.DEFAULT], + { + currentValue: firstResultWithReport2, + sourceValues: { + [ONYXKEYS.COLLECTION.REPORT]: { + [`${ONYXKEYS.COLLECTION.REPORT}2`]: undefined, // Deleted + }, + }, + areAllConnectionsSet: true, + }, + ); + + // Report2 should be removed from reportsToDisplay after the incremental update + expect(secondResult.reportsToDisplay?.[`${ONYXKEYS.COLLECTION.REPORT}2`]).toBeUndefined(); + + // Report2 should also be removed from orderedReportIDs + expect(secondResult.orderedReportIDs).not.toContain('2'); + }); + + it('should correctly update only reports affected by policy changes, not all reports', () => { + const policy1 = createMockPolicy('p1'); + const policy2 = createMockPolicy('p2'); + const report1 = createMockReport('1', {policyID: 'p1'}); + const report2 = createMockReport('2', {policyID: 'p1'}); + const report3 = createMockReport('3', {policyID: 'p2'}); + const report4 = createMockReport('4'); // No policy + + const reports: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.REPORT}1`]: report1, + [`${ONYXKEYS.COLLECTION.REPORT}2`]: report2, + [`${ONYXKEYS.COLLECTION.REPORT}3`]: report3, + [`${ONYXKEYS.COLLECTION.REPORT}4`]: report4, + }; + + const policies: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.POLICY}p1`]: policy1, + [`${ONYXKEYS.COLLECTION.POLICY}p2`]: policy2, + }; + + // First call - full computation + const firstResult = orderedReportsForLHNConfig.compute([reports, policies, {}, {}, {}, {}, [], {reports: {}, locale: null}, CONST.PRIORITY_MODE.DEFAULT, CONST.LOCALES.DEFAULT], { + currentValue: undefined, + sourceValues: undefined, + areAllConnectionsSet: true, + }); + + // Capture references from first result + const report3FromFirstResult = firstResult.reportsToDisplay?.[`${ONYXKEYS.COLLECTION.REPORT}3`]; + const report4FromFirstResult = firstResult.reportsToDisplay?.[`${ONYXKEYS.COLLECTION.REPORT}4`]; + + updateReportsToDisplayInLHNSpy.mockClear(); + + // Second call - only policy1 changed + const updatedPolicy1 = createMockPolicy('p1', {name: 'Updated Policy 1'}); + const updatedPolicies: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.POLICY}p1`]: updatedPolicy1, + [`${ONYXKEYS.COLLECTION.POLICY}p2`]: policy2, + }; + + const secondResult = orderedReportsForLHNConfig.compute( + [reports, updatedPolicies, {}, {}, {}, {}, [], {reports: {}, locale: null}, CONST.PRIORITY_MODE.DEFAULT, CONST.LOCALES.DEFAULT], + { + currentValue: firstResult, + sourceValues: { + [ONYXKEYS.COLLECTION.POLICY]: { + [`${ONYXKEYS.COLLECTION.POLICY}p1`]: updatedPolicy1, + }, + }, + areAllConnectionsSet: true, + }, + ); + + // Verify updateReportsToDisplayInLHN was called with only reports belonging to policy1 + expect(updateReportsToDisplayInLHNSpy).toHaveBeenCalledWith( + expect.objectContaining({ + updatedReportsKeys: expect.arrayContaining([`${ONYXKEYS.COLLECTION.REPORT}1`, `${ONYXKEYS.COLLECTION.REPORT}2`]), + }), + ); + + // Report3 (belongs to p2) and report4 (no policy) should NOT be in updatedReportsKeys + const mockCalls = updateReportsToDisplayInLHNSpy.mock.calls as Array<[{updatedReportsKeys: string[]}]>; + const callArgs = mockCalls.at(0)?.at(0); + expect(callArgs?.updatedReportsKeys).not.toContain(`${ONYXKEYS.COLLECTION.REPORT}3`); + expect(callArgs?.updatedReportsKeys).not.toContain(`${ONYXKEYS.COLLECTION.REPORT}4`); + + // Report3 and report4 should preserve their object references + expect(secondResult.reportsToDisplay?.[`${ONYXKEYS.COLLECTION.REPORT}3`]).toBe(report3FromFirstResult); + expect(secondResult.reportsToDisplay?.[`${ONYXKEYS.COLLECTION.REPORT}4`]).toBe(report4FromFirstResult); + }); + + it('should use full computation (not incremental) when betas change', () => { + const report1 = createMockReport('1'); + const reports: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.REPORT}1`]: report1, + }; + + // First call - full computation + const firstResult = orderedReportsForLHNConfig.compute([reports, {}, {}, {}, {}, {}, [], {reports: {}, locale: null}, CONST.PRIORITY_MODE.DEFAULT, CONST.LOCALES.DEFAULT], { + currentValue: undefined, + sourceValues: undefined, + areAllConnectionsSet: true, + }); + + updateReportsToDisplayInLHNSpy.mockClear(); + getReportsToDisplayInLHNSpy.mockClear(); + + // Second call - betas changed (should trigger full recomputation) + const newBetas: Beta[] = [CONST.BETAS.DEFAULT_ROOMS]; + orderedReportsForLHNConfig.compute([reports, {}, {}, {}, {}, {}, newBetas, {reports: {}, locale: null}, CONST.PRIORITY_MODE.DEFAULT, CONST.LOCALES.DEFAULT], { + currentValue: firstResult, + sourceValues: { + // hasKeyTriggeredCompute checks if the key exists in sourceValues + // The actual value doesn't matter for non-collection keys + [ONYXKEYS.BETAS]: undefined, + }, + areAllConnectionsSet: true, + }); + + // Should use full computation, not incremental update + expect(getReportsToDisplayInLHNSpy).toHaveBeenCalled(); + // updateReportsToDisplayInLHN should NOT be called for full computation + expect(updateReportsToDisplayInLHNSpy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/unit/useSidebarOrderedReportsTest.tsx b/tests/unit/useSidebarOrderedReportsTest.tsx index 98c7004ca966..1088ae485cfb 100644 --- a/tests/unit/useSidebarOrderedReportsTest.tsx +++ b/tests/unit/useSidebarOrderedReportsTest.tsx @@ -5,28 +5,11 @@ import Onyx from 'react-native-onyx'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; import {CurrentReportIDContextProvider} from '@hooks/useCurrentReportID'; import {SidebarOrderedReportsContextProvider, useSidebarOrderedReports} from '@hooks/useSidebarOrderedReports'; -import SidebarUtils from '@libs/SidebarUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Report} from '@src/types/onyx'; import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; -// Mock dependencies -jest.mock('@libs/SidebarUtils', () => ({ - sortReportsToDisplayInLHN: jest.fn(), - getReportsToDisplayInLHN: jest.fn(), - updateReportsToDisplayInLHN: jest.fn(), -})); -jest.mock('@libs/Navigation/Navigation', () => ({ - getTopmostReportId: jest.fn(), -})); -jest.mock('@libs/ReportUtils', () => ({ - parseReportRouteParams: jest.fn(() => ({reportID: undefined})), - getReportIDFromLink: jest.fn(() => ''), -})); - -const mockSidebarUtils = SidebarUtils as jest.Mocked; - describe('useSidebarOrderedReports', () => { beforeAll(async () => { Onyx.init({keys: ONYXKEYS}); @@ -56,6 +39,7 @@ describe('useSidebarOrderedReports', () => { // Set up required Onyx data that the hook depends on await Onyx.multiSet({ [ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.DEFAULT, + [ONYXKEYS.NVP_PREFERRED_LOCALE]: CONST.LOCALES.DEFAULT, [ONYXKEYS.COLLECTION.REPORT]: {}, [ONYXKEYS.COLLECTION.POLICY]: {}, [ONYXKEYS.COLLECTION.TRANSACTION]: {}, @@ -63,18 +47,17 @@ describe('useSidebarOrderedReports', () => { [ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS]: {}, [ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT]: {}, [ONYXKEYS.BETAS]: [], - [ONYXKEYS.DERIVED.REPORT_ATTRIBUTES]: {reports: {}}, + [ONYXKEYS.DERIVED.REPORT_ATTRIBUTES]: {reports: {}, locale: null}, + [ONYXKEYS.DERIVED.ORDERED_REPORTS_FOR_LHN]: { + reportsToDisplay: {}, + orderedReportIDs: [], + currentReportID: undefined, + locale: null, + }, } as unknown as OnyxMultiSetInput); }); await waitForBatchedUpdatesWithAct(); - - // Default mock implementations - mockSidebarUtils.getReportsToDisplayInLHN.mockImplementation(() => ({})); - mockSidebarUtils.updateReportsToDisplayInLHN.mockImplementation(({displayedReports}) => ({...displayedReports})); - mockSidebarUtils.sortReportsToDisplayInLHN.mockReturnValue([]); - - await waitForBatchedUpdatesWithAct(); }); afterAll(async () => { @@ -111,196 +94,273 @@ describe('useSidebarOrderedReports', () => { ); } - it('should prevent unnecessary re-renders when reports have same content but different references', async () => { - // Given reports with same content but different object references - const reportsContent = { - report1: {reportName: 'Chat 1', lastVisibleActionCreated: '2024-01-01 10:00:00'}, - report2: {reportName: 'Chat 2', lastVisibleActionCreated: '2024-01-01 11:00:00'}, - }; - - // When the initial reports are set - const initialReports = createMockReports(reportsContent); - mockSidebarUtils.getReportsToDisplayInLHN.mockReturnValue(initialReports); - mockSidebarUtils.updateReportsToDisplayInLHN.mockImplementation(({displayedReports}) => ({...displayedReports})); - currentReportIDForTestsValue = '1'; - - // When the hook is rendered - const {rerender} = renderHook(() => useSidebarOrderedReports(), { + it('should return empty arrays when no reports are available', async () => { + const {result} = renderHook(() => useSidebarOrderedReports(), { wrapper: TestWrapper, }); await waitForBatchedUpdatesWithAct(); - // Then the mock calls are cleared - mockSidebarUtils.sortReportsToDisplayInLHN.mockClear(); + expect(result.current.orderedReports).toEqual([]); + expect(result.current.orderedReportIDs).toEqual([]); + }); - // When the reports are updated - const newReportsWithSameContent = createMockReports(reportsContent); - mockSidebarUtils.getReportsToDisplayInLHN.mockReturnValue(newReportsWithSameContent); + it('should return ordered reports when they are available in the derived value', async () => { + const reports = createMockReports({ + report1: {reportName: 'Chat 1'}, + report2: {reportName: 'Chat 2'}, + }); - rerender({}); + await act(async () => { + await Onyx.multiSet({ + [`${ONYXKEYS.COLLECTION.REPORT}1`]: reports['1'], + [`${ONYXKEYS.COLLECTION.REPORT}2`]: reports['2'], + [ONYXKEYS.DERIVED.ORDERED_REPORTS_FOR_LHN]: { + reportsToDisplay: { + [`${ONYXKEYS.COLLECTION.REPORT}1`]: reports['1'], + [`${ONYXKEYS.COLLECTION.REPORT}2`]: reports['2'], + }, + orderedReportIDs: ['1', '2'], + currentReportID: '1', + locale: CONST.LOCALES.DEFAULT, + }, + } as unknown as OnyxMultiSetInput); + }); + + await waitForBatchedUpdatesWithAct(); + + const {result} = renderHook(() => useSidebarOrderedReports(), { + wrapper: TestWrapper, + }); await waitForBatchedUpdatesWithAct(); - // Then sortReportsToDisplayInLHN should not be called again since deep comparison shows no change - expect(mockSidebarUtils.sortReportsToDisplayInLHN).not.toHaveBeenCalled(); + expect(result.current.orderedReportIDs).toEqual(['1', '2']); + expect(result.current.orderedReports).toHaveLength(2); + expect(result.current.orderedReports.at(0)?.reportID).toBe('1'); + expect(result.current.orderedReports.at(1)?.reportID).toBe('2'); }); - it('should trigger re-render when reports content actually changes', async () => { - // Given the initial reports are set + it('should handle when reports content changes in the derived value', async () => { const initialReports = createMockReports({ report1: {reportName: 'Chat 1'}, report2: {reportName: 'Chat 2'}, }); - // When the reports are updated - const updatedReports = createMockReports({ - report1: {reportName: 'Chat 1 Updated'}, // Content changed - report2: {reportName: 'Chat 2'}, - report3: {reportName: 'Chat 3'}, // New report added - }); - - // Then the initial reports are set await act(async () => { await Onyx.multiSet({ [`${ONYXKEYS.COLLECTION.REPORT}1`]: initialReports['1'], [`${ONYXKEYS.COLLECTION.REPORT}2`]: initialReports['2'], + [ONYXKEYS.DERIVED.ORDERED_REPORTS_FOR_LHN]: { + reportsToDisplay: { + [`${ONYXKEYS.COLLECTION.REPORT}1`]: initialReports['1'], + [`${ONYXKEYS.COLLECTION.REPORT}2`]: initialReports['2'], + }, + orderedReportIDs: ['1', '2'], + currentReportID: '1', + locale: CONST.LOCALES.DEFAULT, + }, } as unknown as OnyxMultiSetInput); }); await waitForBatchedUpdatesWithAct(); - // When the mock is updated - mockSidebarUtils.getReportsToDisplayInLHN.mockReturnValue(initialReports); - - // When the hook is rendered - const {rerender} = renderHook(() => useSidebarOrderedReports(), { + const {result} = renderHook(() => useSidebarOrderedReports(), { wrapper: TestWrapper, }); await waitForBatchedUpdatesWithAct(); - // Then the mock calls are cleared - mockSidebarUtils.sortReportsToDisplayInLHN.mockClear(); + expect(result.current.orderedReportIDs).toEqual(['1', '2']); - // When the mock is updated - mockSidebarUtils.getReportsToDisplayInLHN.mockReturnValue(updatedReports); + // Update reports - simulating what the derived value would do + const updatedReports = createMockReports({ + report1: {reportName: 'Chat 1 Updated'}, + report2: {reportName: 'Chat 2'}, + report3: {reportName: 'Chat 3'}, + }); - // When the priority mode is changed await act(async () => { - await Onyx.set(ONYXKEYS.NVP_PRIORITY_MODE, CONST.PRIORITY_MODE.GSD); + await Onyx.multiSet({ + [`${ONYXKEYS.COLLECTION.REPORT}1`]: updatedReports['1'], + [`${ONYXKEYS.COLLECTION.REPORT}2`]: updatedReports['2'], + [`${ONYXKEYS.COLLECTION.REPORT}3`]: updatedReports['3'], + [ONYXKEYS.DERIVED.ORDERED_REPORTS_FOR_LHN]: { + reportsToDisplay: { + [`${ONYXKEYS.COLLECTION.REPORT}1`]: updatedReports['1'], + [`${ONYXKEYS.COLLECTION.REPORT}2`]: updatedReports['2'], + [`${ONYXKEYS.COLLECTION.REPORT}3`]: updatedReports['3'], + }, + orderedReportIDs: ['1', '2', '3'], + currentReportID: '1', + locale: CONST.LOCALES.DEFAULT, + }, + } as unknown as OnyxMultiSetInput); }); - rerender({}); - await waitForBatchedUpdatesWithAct(); - // Then sortReportsToDisplayInLHN should be called with the updated reports - expect(mockSidebarUtils.sortReportsToDisplayInLHN).toHaveBeenCalledWith( - updatedReports, - expect.any(String), // priorityMode - expect.any(Function), // localeCompare - expect.any(Object), // reportsDrafts - expect.any(Object), // reportNameValuePairs - ); + expect(result.current.orderedReportIDs).toEqual(['1', '2', '3']); + expect(result.current.orderedReports).toHaveLength(3); + expect(result.current.orderedReports.at(0)?.reportName).toBe('Chat 1 Updated'); }); - it('should handle empty reports correctly with deep comparison', async () => { - // Given the initial reports are set - mockSidebarUtils.getReportsToDisplayInLHN.mockReturnValue({}); - - // When the hook is rendered - const {rerender} = renderHook(() => useSidebarOrderedReports(), { + it('should handle empty reports correctly', async () => { + const {result} = renderHook(() => useSidebarOrderedReports(), { wrapper: TestWrapper, }); await waitForBatchedUpdatesWithAct(); - // Then the mock calls are cleared - mockSidebarUtils.sortReportsToDisplayInLHN.mockClear(); + expect(result.current.orderedReports).toEqual([]); + expect(result.current.orderedReportIDs).toEqual([]); - // When the mock is updated - mockSidebarUtils.getReportsToDisplayInLHN.mockReturnValue({}); - - rerender({}); + // Update with empty derived value + await act(async () => { + await Onyx.set(ONYXKEYS.DERIVED.ORDERED_REPORTS_FOR_LHN, { + reportsToDisplay: {}, + orderedReportIDs: [], + currentReportID: undefined, + locale: CONST.LOCALES.DEFAULT, + }); + }); await waitForBatchedUpdatesWithAct(); - // Then sortReportsToDisplayInLHN should not be called again since reports are empty - expect(mockSidebarUtils.sortReportsToDisplayInLHN).not.toHaveBeenCalled(); + expect(result.current.orderedReports).toEqual([]); + expect(result.current.orderedReportIDs).toEqual([]); }); - it('should maintain referential stability across multiple renders with same content', async () => { - // Given the initial reports are set - const reportsContent = { + it('should maintain stability when derived value has same content', async () => { + const reports = createMockReports({ report1: {reportName: 'Stable Chat'}, - }; - - // When the initial reports are set - const initialReports = createMockReports(reportsContent); - mockSidebarUtils.getReportsToDisplayInLHN.mockReturnValue(initialReports); - mockSidebarUtils.sortReportsToDisplayInLHN.mockReturnValue(['1']); - currentReportIDForTestsValue = '1'; + }); - const {rerender} = renderHook(() => useSidebarOrderedReports(), { - wrapper: TestWrapper, + await act(async () => { + await Onyx.multiSet({ + [`${ONYXKEYS.COLLECTION.REPORT}1`]: reports['1'], + [ONYXKEYS.DERIVED.ORDERED_REPORTS_FOR_LHN]: { + reportsToDisplay: { + [`${ONYXKEYS.COLLECTION.REPORT}1`]: reports['1'], + }, + orderedReportIDs: ['1'], + currentReportID: '1', + locale: CONST.LOCALES.DEFAULT, + }, + } as unknown as OnyxMultiSetInput); }); await waitForBatchedUpdatesWithAct(); - // When the mock is updated - const newReportsWithSameContent = createMockReports(reportsContent); - mockSidebarUtils.getReportsToDisplayInLHN.mockReturnValue(newReportsWithSameContent); + const {result, rerender} = renderHook(() => useSidebarOrderedReports(), { + wrapper: TestWrapper, + }); - rerender({}); await waitForBatchedUpdatesWithAct(); - currentReportIDForTestsValue = '2'; - // When the mock is updated - const thirdReportsWithSameContent = createMockReports(reportsContent); - mockSidebarUtils.getReportsToDisplayInLHN.mockReturnValue(thirdReportsWithSameContent); + const firstRenderIDs = result.current.orderedReportIDs; + // Re-render without changing data rerender({}); await waitForBatchedUpdatesWithAct(); - currentReportIDForTestsValue = '3'; - // Then sortReportsToDisplayInLHN should be called only once (initial render) - expect(mockSidebarUtils.sortReportsToDisplayInLHN).toHaveBeenCalledTimes(1); + // Should maintain same reference + expect(result.current.orderedReportIDs).toBe(firstRenderIDs); }); - it('should handle priority mode changes correctly with deep comparison', async () => { - // Given the initial reports are set + it('should handle priority mode changes via the derived value', async () => { const reports = createMockReports({ report1: {reportName: 'Chat A'}, report2: {reportName: 'Chat B'}, }); - mockSidebarUtils.getReportsToDisplayInLHN.mockReturnValue(reports); - currentReportIDForTestsValue = '1'; + await act(async () => { + await Onyx.multiSet({ + [`${ONYXKEYS.COLLECTION.REPORT}1`]: reports['1'], + [`${ONYXKEYS.COLLECTION.REPORT}2`]: reports['2'], + [ONYXKEYS.DERIVED.ORDERED_REPORTS_FOR_LHN]: { + reportsToDisplay: { + [`${ONYXKEYS.COLLECTION.REPORT}1`]: reports['1'], + [`${ONYXKEYS.COLLECTION.REPORT}2`]: reports['2'], + }, + orderedReportIDs: ['1', '2'], + currentReportID: '1', + locale: CONST.LOCALES.DEFAULT, + }, + } as unknown as OnyxMultiSetInput); + }); + + await waitForBatchedUpdatesWithAct(); - // When the hook is rendered - const {rerender} = renderHook(() => useSidebarOrderedReports(), { + const {result} = renderHook(() => useSidebarOrderedReports(), { wrapper: TestWrapper, }); await waitForBatchedUpdatesWithAct(); - // Then the mock calls are cleared - mockSidebarUtils.sortReportsToDisplayInLHN.mockClear(); - currentReportIDForTestsValue = '2'; + expect(result.current.orderedReportIDs).toEqual(['1', '2']); - // When the priority mode is changed + // Change priority mode - the derived value would recompute and potentially change the order await act(async () => { - await Onyx.set(ONYXKEYS.NVP_PRIORITY_MODE, CONST.PRIORITY_MODE.GSD); + await Onyx.multiSet({ + [ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.GSD, + // Simulate the derived value recomputing with new priority mode + [ONYXKEYS.DERIVED.ORDERED_REPORTS_FOR_LHN]: { + reportsToDisplay: { + [`${ONYXKEYS.COLLECTION.REPORT}2`]: reports['2'], + [`${ONYXKEYS.COLLECTION.REPORT}1`]: reports['1'], + }, + orderedReportIDs: ['2', '1'], // Order changed due to priority mode + currentReportID: '1', + locale: CONST.LOCALES.DEFAULT, + }, + } as unknown as OnyxMultiSetInput); }); await waitForBatchedUpdatesWithAct(); - rerender({}); + // The hook should reflect the new order from the derived value + expect(result.current.orderedReportIDs).toEqual(['2', '1']); + }); + + it('should handle clearLHNCache correctly', async () => { + const reports = createMockReports({ + report1: {reportName: 'Chat 1'}, + }); + + await act(async () => { + await Onyx.multiSet({ + [`${ONYXKEYS.COLLECTION.REPORT}1`]: reports['1'], + [ONYXKEYS.DERIVED.ORDERED_REPORTS_FOR_LHN]: { + reportsToDisplay: { + [`${ONYXKEYS.COLLECTION.REPORT}1`]: reports['1'], + }, + orderedReportIDs: ['1'], + currentReportID: '1', + locale: CONST.LOCALES.DEFAULT, + }, + } as unknown as OnyxMultiSetInput); + }); + + await waitForBatchedUpdatesWithAct(); + + const {result} = renderHook(() => useSidebarOrderedReports(), { + wrapper: TestWrapper, + }); + + await waitForBatchedUpdatesWithAct(); + + expect(result.current.orderedReportIDs).toEqual(['1']); + + // Clear the cache + await act(async () => { + result.current.clearLHNCache(); + }); await waitForBatchedUpdatesWithAct(); - // Then sortReportsToDisplayInLHN should be called when priority mode changes - expect(mockSidebarUtils.sortReportsToDisplayInLHN).toHaveBeenCalled(); + // The derived value should be cleared (set to null) + // This will trigger a full recomputation + expect(result.current.orderedReportIDs).toEqual([]); }); });